tower / files / test / forest.test.ts
forest.test.ts
| 1 | import { readFileSync } from 'node:fs' |
| 2 | import { describe, expect, it } from 'vitest' |
| 3 | import { RADIUS_M } from '../src/canon/constants.js' |
| 4 | import { compileWorld } from '../src/terrain/compile.js' |
| 5 | import { |
| 6 | type CForest, type Ground, GIANT_DENSITY_PER_HA, HECTARE_M2, TREE, canopyAt, cellSizeM, |
| 7 | forEachTree, stemsAt, |
| 8 | } from '../src/terrain/forest.js' |
| 9 | import { prepare } from '../src/terrain/field.js' |
| 10 | import { cylPoint } from '../src/terrain/noise.js' |
| 11 | import { nearestA, signedDistance } from '../src/terrain/shapes.js' |
| 12 | import { type Forest, parseWorld } from '../src/world/schema.js' |
| 13 | |
| 14 | const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))) |
| 15 | const cw = compileWorld(world) |
| 16 | const rt = prepare(cw) |
| 17 | const greatwood = cw.forests.find((f) => f.id === 'greatwood')! |
| 18 | const tall = cw.forests.find((f) => f.giants)! |
| 19 | const authored = (id: string): Forest => world.features.find((f): f is Forest => f.id === id)! |
| 20 | |
| 21 | const groundAt = (z: number, a: number): Ground => { |
| 22 | const s = rt.sample(a / RADIUS_M, z, 8) |
| 23 | return { h: s.h, water: s.water > s.h, desert: s.desert, rock: s.rock } |
| 24 | } |
| 25 | const stems = (z: number, a: number, f = greatwood): number => |
| 26 | stemsAt(f, cylPoint(a / RADIUS_M, z), z, a, 8, groundAt(z, a)) |
| 27 | /** What a placement asks about a spot: the density there, and the ground. */ |
| 28 | const spotAt = (f: CForest) => (z: number, a: number) => ({ stems: stems(z, a, f), h: groundAt(z, a).h }) |
| 29 | |
| 30 | describe('what Tower planted', () => { |
| 31 | it('fills a forest to the density asked for, and leaves about the meadows asked for', () => { |
| 32 | const f = authored('greatwood') |
| 33 | let open = 0, full = 0, n = 0 |
| 34 | for (let i = 0; i < 600; i++) { |
| 35 | const z = 240_000 + (i % 30) * 4000 |
| 36 | const a = ((i * 7919) % 300) * 1000 |
| 37 | const g = groundAt(z, a) |
| 38 | if (g.water || signedDistance(greatwood.shape, z, a) < 20_000) continue |
| 39 | n++ |
| 40 | const s = stems(z, a) |
| 41 | if (s < 40) open++ |
| 42 | if (s > f.densityPerHa * 0.9) full++ |
| 43 | } |
| 44 | expect(n).toBeGreaterThan(100) |
| 45 | // Meadows are roughly what was asked for, and the rest of the wood is full. |
| 46 | expect(open / n).toBeGreaterThan(f.meadows * 0.4) |
| 47 | expect(open / n).toBeLessThan(f.meadows * 2.2) |
| 48 | expect(full / n).toBeGreaterThan(0.6) |
| 49 | }) |
| 50 | |
| 51 | it('stops at water, at rock, and above the treeline', () => { |
| 52 | const wet = { h: 100, water: true, desert: 0, rock: 0 } |
| 53 | const stone = { h: 100, water: false, desert: 0, rock: 1 } |
| 54 | const high = { h: greatwood.treelineM + 10, water: false, desert: 0, rock: 0 } |
| 55 | const here = cylPoint(0.2, 300_000) |
| 56 | expect(stemsAt(greatwood, here, 300_000, 10_000, 8, wet)).toBe(0) |
| 57 | expect(stemsAt(greatwood, here, 300_000, 10_000, 8, stone)).toBe(0) |
| 58 | expect(stemsAt(greatwood, here, 300_000, 10_000, 8, high)).toBe(0) |
| 59 | }) |
| 60 | |
| 61 | it('gives its ground to a smaller forest standing inside it', () => { |
| 62 | // Inside the tall wood, Greatwood plants nothing: the giants stand instead of it (§4.6). |
| 63 | const centre = { z: (tall.box.z0 + tall.box.z1) / 2, a: (tall.box.a0 + tall.box.a1) / 2 } |
| 64 | expect(stems(centre.z, centre.a, greatwood)).toBe(0) |
| 65 | expect(stems(centre.z, centre.a, tall)).toBeGreaterThan(10) |
| 66 | // And away from it, Greatwood is itself again. |
| 67 | expect(stems(300_000, 40_000, greatwood)).toBeGreaterThan(200) |
| 68 | }) |
| 69 | |
| 70 | it('makes the canopy stand where the trees are and lie down in the meadows', () => { |
| 71 | expect(canopyAt(greatwood, 0)).toBe(0) |
| 72 | expect(canopyAt(greatwood, greatwood.densityPerHa)).toBeCloseTo(greatwood.canopyM, 6) |
| 73 | expect(canopyAt(greatwood, 20)).toBeLessThan(greatwood.canopyM * 0.5) |
| 74 | // The giants' canopy stands where §4.6 says: about 80 m proud of the ordinary wood. |
| 75 | expect(tall.canopyM - greatwood.canopyM).toBeGreaterThan(65) |
| 76 | }) |
| 77 | }) |
| 78 | |
| 79 | describe('where the trees stand', () => { |
| 80 | const box = { z0: 300_000, z1: 300_400, a0: 40_000, a1: 40_400 } |
| 81 | const collect = (f: CForest): { z: number; a: number; heightM: number }[] => { |
| 82 | const out: { z: number; a: number; heightM: number }[] = [] |
| 83 | forEachTree(f, box, spotAt(f), (t) => out.push({ z: t.z, a: t.a, heightM: t.heightM })) |
| 84 | return out |
| 85 | } |
| 86 | |
| 87 | it('puts the same trees in the same places every time it is asked', () => { |
| 88 | expect(collect(greatwood)).toEqual(collect(greatwood)) |
| 89 | }) |
| 90 | |
| 91 | it('agrees about a tree whoever asks: the same ones in an overlapping patch', () => { |
| 92 | const half = { z0: 300_100, z1: 300_400, a0: 40_100, a1: 40_400 } |
| 93 | const inHalf = collect(greatwood).filter((t) => |
| 94 | t.z >= half.z0 && t.z <= half.z1 && t.a >= half.a0 && t.a <= half.a1) |
| 95 | const asked: { z: number; a: number; heightM: number }[] = [] |
| 96 | forEachTree(greatwood, half, spotAt(greatwood), (t) => asked.push({ z: t.z, a: t.a, heightM: t.heightM })) |
| 97 | expect(asked).toEqual(inHalf) |
| 98 | }) |
| 99 | |
| 100 | it('stands them about as thickly as the density says, and never two in a place', () => { |
| 101 | const trees = collect(greatwood) |
| 102 | const hectares = ((box.z1 - box.z0) * (box.a1 - box.a0)) / HECTARE_M2 |
| 103 | const density = trees.length / hectares |
| 104 | expect(density).toBeGreaterThan(greatwood.densityPerHa * 0.5) |
| 105 | expect(density).toBeLessThanOrEqual(greatwood.densityPerHa * 1.1) |
| 106 | // Stratified placement: one tree to a cell of the global grid, and never two. |
| 107 | const cell = cellSizeM(greatwood) |
| 108 | const seen = new Set<string>() |
| 109 | for (const t of trees) { |
| 110 | const key = `${Math.floor(t.z / cell)},${Math.floor(t.a / cell)}` |
| 111 | expect(seen.has(key)).toBe(false) |
| 112 | seen.add(key) |
| 113 | } |
| 114 | }) |
| 115 | |
| 116 | it('grows them at the sizes §4.6 gives, giants included', () => { |
| 117 | for (const t of collect(greatwood)) { |
| 118 | expect(t.heightM).toBeGreaterThan(TREE.broadleaf.minM * 0.5) |
| 119 | expect(t.heightM).toBeLessThanOrEqual(TREE.conifer.maxM) |
| 120 | } |
| 121 | const giants: number[] = [] |
| 122 | const b = { z0: tall.box.z0 + 2000, z1: tall.box.z0 + 2400, a0: tall.box.a0 + 2000, a1: tall.box.a0 + 2400 } |
| 123 | forEachTree(tall, b, spotAt(tall), (t) => giants.push(t.heightM)) |
| 124 | expect(giants.length).toBeGreaterThan(0) |
| 125 | for (const h of giants) { |
| 126 | expect(h).toBeGreaterThan(TREE.giant.minM * 0.5) |
| 127 | expect(h).toBeLessThanOrEqual(TREE.giant.maxM) |
| 128 | } |
| 129 | expect(tall.densityPerHa).toBeLessThanOrEqual(GIANT_DENSITY_PER_HA) |
| 130 | }) |
| 131 | |
| 132 | it('leaves room between the giants: their crowns do not close over twice', () => { |
| 133 | // Crown area against ground area, at the wood's own density: a grove, not a thicket. |
| 134 | const crown = Math.PI * ((TREE.giant.crown * ((TREE.giant.minM + TREE.giant.maxM) / 2)) / 2) ** 2 |
| 135 | expect((crown * tall.densityPerHa) / HECTARE_M2).toBeLessThan(1.1) |
| 136 | }) |
| 137 | }) |
| 138 | |
| 139 | describe('settlements', () => { |
| 140 | it('stand on cleared ground: no forest inside a city', () => { |
| 141 | const capital = world.features.find((f) => f.id === 'the-capital')! |
| 142 | if (capital.kind !== 'settlement' || capital.geometry.type !== 'point') throw new Error('capital') |
| 143 | const { theta, z } = capital.geometry |
| 144 | const a = theta * RADIUS_M |
| 145 | // The Capital stands in the middle of Greatwood's belt, and yet it is not in a wood. |
| 146 | expect(signedDistance(greatwood.shape, z, a)).toBeGreaterThan(0) |
| 147 | expect(stems(z, a)).toBe(0) |
| 148 | expect(stems(z, a + capital.radiusM * 0.5)).toBe(0) |
| 149 | // And the trees come back outside it — antispinward, away from The Deep. |
| 150 | expect(stems(z, a - capital.radiusM * 1.6)).toBeGreaterThan(100) |
| 151 | }) |
| 152 | }) |
| 153 | |
| 154 | describe('the tall wood, placed once', () => { |
| 155 | it('is a giants forest inside Greatwood, near the Capital', () => { |
| 156 | const f = authored('the-tall-wood') |
| 157 | expect(f.giants).toBe(true) |
| 158 | expect(f.geometry.type).toBe('polygon') |
| 159 | const capital = world.features.find((x) => x.id === 'the-capital')! |
| 160 | if (capital.geometry.type !== 'point') throw new Error('capital') |
| 161 | const centre = { z: (tall.box.z0 + tall.box.z1) / 2, a: (tall.box.a0 + tall.box.a1) / 2 } |
| 162 | const ca = capital.geometry.theta * RADIUS_M |
| 163 | const d = Math.hypot(centre.z - capital.geometry.z, nearestA(centre.a, ca) - ca) |
| 164 | expect(d).toBeGreaterThan(15_000) |
| 165 | expect(d).toBeLessThan(50_000) |
| 166 | // On dry ground, inside Greatwood. |
| 167 | expect(signedDistance(greatwood.shape, centre.z, centre.a)).toBeGreaterThan(5000) |
| 168 | expect(rt.sample(centre.a / RADIUS_M, centre.z, 8).water).toBe(-Infinity) |
| 169 | }) |
| 170 | }) |