fuzzyworld

tower / files / test / forest.test.ts

forest.test.ts

Blame: the commit that last changed each line · file · history

00ceeedb 2026-09-27 Pavel Safronov1import { readFileSync } from 'node:fs'
2import { describe, expect, it } from 'vitest'
3import { RADIUS_M } from '../src/canon/constants.js'
4import { compileWorld } from '../src/terrain/compile.js'
5import {
6 type CForest, type Ground, GIANT_DENSITY_PER_HA, HECTARE_M2, TREE, canopyAt, cellSizeM,
7 forEachTree, stemsAt,
8} from '../src/terrain/forest.js'
9import { prepare } from '../src/terrain/field.js'
10import { cylPoint } from '../src/terrain/noise.js'
11import { nearestA, signedDistance } from '../src/terrain/shapes.js'
12import { type Forest, parseWorld } from '../src/world/schema.js'
13
14const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
15const cw = compileWorld(world)
16const rt = prepare(cw)
17const greatwood = cw.forests.find((f) => f.id === 'greatwood')!
18const tall = cw.forests.find((f) => f.giants)!
19const authored = (id: string): Forest => world.features.find((f): f is Forest => f.id === id)!
20
21const 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}
25const 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. */
28const spotAt = (f: CForest) => (z: number, a: number) => ({ stems: stems(z, a, f), h: groundAt(z, a).h })
29
30describe('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
79describe('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
139describe('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
154describe('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})