fuzzyworld

tower / files / test / desert.test.ts

desert.test.ts

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

51c39d8b 2026-09-17 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 { BENCH_STEP_M, CORRIDOR_M, terrace } from '../src/terrain/desert.js'
6import { prepare } from '../src/terrain/field.js'
7import { MATERIALS, materialsAt } from '../src/terrain/tile.js'
8import { type Desert, parseWorld } from '../src/world/schema.js'
9
10const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
11const cw = compileWorld(world)
12const rt = prepare(cw)
13const reach = world.features.find((f): f is Desert => f.kind === 'desert')!
14const d = cw.deserts.find((x) => x.id === reach.id)!
15const ground = (z: number, a: number): number => rt.sample(a / RADIUS_M, z, 2).h
16
17/** Every cell of the desert's grid, as (index, z, a). */
18function* cells(step = 3): Generator<[number, number, number]> {
19 const { grid } = d
20 for (let i = 0; i < grid.nz; i += step) {
21 for (let j = 0; j < grid.na; j += step) {
22 yield [i * grid.na + j, grid.z0 + i * grid.cell, grid.a0 + j * grid.cell]
23 }
24 }
25}
26
27describe('the terrace profile', () => {
28 it('stays within its range and climbs in the number of tiers asked for', () => {
29 for (const terraces of [1, 3, 5]) {
30 expect(terrace(0, terraces)).toBe(0)
31 expect(terrace(1, terraces)).toBeCloseTo(1, 6)
32 // As many rises as there are tiers, each with a flat bench before the next.
33 let risers = 0, climbing = false
34 let prev = terrace(0, terraces)
35 for (let p = 0; p <= 1; p += 0.002) {
36 const v = terrace(p, terraces)
37 expect(v).toBeGreaterThanOrEqual(prev - 1e-9)
38 const rising = v - prev > 1e-6
39 if (rising && !climbing) risers++
40 climbing = rising
41 prev = v
42 }
43 expect(risers).toBe(terraces)
44 }
45 })
46
47 it('leaves the tops flat: most of a tier is a level bench', () => {
48 let flat = 0, n = 0
49 for (let p = 0; p <= 1; p += 0.001) {
50 const slope = Math.abs(terrace(p + 0.001, 3) - terrace(p, 3)) / 0.001
51 if (slope < 0.05) flat++
52 n++
53 }
54 expect(flat / n).toBeGreaterThan(0.55)
55 })
56})
57
58describe('the Dry Reach as compiled', () => {
59 it('lays out the plateaus over about the coverage asked for', () => {
60 let inside = 0, rock = 0
61 for (const [k] of cells()) {
62 if (d.inside[k]! < 0.9) continue
63 inside++
64 if (d.plateau[k]! > 0.5) rock++
65 }
66 expect(inside).toBeGreaterThan(1000)
67 expect(rock / inside).toBeGreaterThan(reach.plateaus.coverage * 0.6)
68 expect(rock / inside).toBeLessThan(reach.plateaus.coverage * 1.4)
69 })
70
71 it('cuts washes that run: a network, deep where it has gathered', () => {
72 let cutCells = 0, inside = 0, deepest = 0
73 for (const [k] of cells()) {
74 if (d.inside[k]! < 0.9) continue
75 inside++
76 if (d.cut[k]! > 3) cutCells++
77 deepest = Math.max(deepest, d.cut[k]!)
78 }
79 // Channels, not a lowered desert: a minority of the ground, and tens of metres deep.
80 expect(cutCells / inside).toBeGreaterThan(0.05)
81 expect(cutCells / inside).toBeLessThan(0.6)
82 expect(deepest).toBeGreaterThan(15)
83 expect(deepest).toBeLessThan(60)
84 })
85
86 it('lays the salt pans Tower asked for, spaced out, in the low ground', () => {
87 expect(d.pans).toHaveLength(reach.pastWater.saltPans)
88 for (let i = 0; i < d.pans.length; i++) {
89 for (let j = i + 1; j < d.pans.length; j++) {
90 const p = d.pans[i]!, q = d.pans[j]!
91 expect(Math.hypot(p.z - q.z, p.a - q.a)).toBeGreaterThan(p.radiusM * 2)
92 }
93 }
94 })
95
96 it('makes a pan floor dead flat, and steps the ground around it in benches', () => {
97 const pan = d.pans[0]!
98 const level = ground(pan.z, pan.a)
99 for (const off of [-pan.radiusM * 0.5, -pan.radiusM * 0.25, pan.radiusM * 0.25, pan.radiusM * 0.5]) {
100 expect(Math.abs(ground(pan.z + off, pan.a) - level)).toBeLessThan(2)
101 expect(Math.abs(ground(pan.z, pan.a + off) - level)).toBeLessThan(2)
102 }
103 // Outside it, the ground stands at the levels a lake would have left: heights cluster
104 // on multiples of the bench step above the floor, rather than climbing smoothly.
105 let onStep = 0, n = 0
106 for (let off = pan.radiusM * 1.1; off < pan.radiusM + 900; off += 25) {
107 const above = ground(pan.z + off, pan.a) - level
108 if (above <= 0) continue
109 const away = Math.abs(above / BENCH_STEP_M - Math.round(above / BENCH_STEP_M))
110 if (away < 0.22) onStep++
111 n++
112 }
113 expect(n).toBeGreaterThan(10)
114 expect(onStep / n).toBeGreaterThan(0.5)
115 })
116
117 it('keeps its rock off the rivers Tower had already routed', () => {
118 const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
119 for (let i = 0; i < trunk.line.s.length; i += 40) {
120 const z = trunk.line.pts[2 * i]!, a = trunk.line.pts[2 * i + 1]!
121 const { grid } = d
122 const gi = Math.round((z - grid.z0) / grid.cell)
123 if (gi < 0 || gi >= grid.nz) continue
124 const gj = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
125 expect(d.plateau[gi * grid.na + gj]!).toBeLessThan(0.05)
126 }
127 })
128
129 it('claims rock where the rock is, and salt on the pans', () => {
130 const out = new Float32Array(MATERIALS.length)
131 const pan = d.pans[0]!
132 materialsAt(rt.sample(pan.a / RADIUS_M, pan.z, 2), 0, out)
133 expect(out[MATERIALS.indexOf('salt')]!).toBeGreaterThan(0.8)
134 expect(out[MATERIALS.indexOf('desert')]!).toBeLessThan(0.2)
135
136 // A mesa top: bare rock, and no sand claim on it.
137 let mesa: [number, number] | null = null
138 for (const [k, z, a] of cells()) if (!mesa && d.plateau[k]! > 0.98) mesa = [z, a]
139 materialsAt(rt.sample(mesa![1] / RADIUS_M, mesa![0], 2), 0, out)
140 expect(out[MATERIALS.indexOf('rock')]! + out[MATERIALS.indexOf('sandstone')]!).toBeGreaterThan(0.8)
141 })
142
143 it('stands a dune field up in the sand, crest to trough about the height asked for', () => {
144 let sandy: [number, number] | null = null
145 for (const [k, z, a] of cells()) {
146 if (!sandy && d.sand[k]! > 0.95 && d.plateau[k]! < 0.02 && d.cut[k]! < 1) sandy = [z, a]
147 }
148 expect(sandy).not.toBeNull()
149 // Across the wind — the dunes' crests run across it, so a transect along it crosses them.
150 let lo = Infinity, hi = -Infinity
151 for (let i = 0; i < 40; i++) {
152 const h = ground(sandy![0] + i * 60, sandy![1])
153 lo = Math.min(lo, h); hi = Math.max(hi, h)
154 }
155 expect(hi - lo).toBeGreaterThan(reach.dunes.heightM * 0.3)
156 expect(hi - lo).toBeLessThan(reach.dunes.heightM * 2.5)
157 })
158
159 it('costs about a second of the compile, and no seam where theta wraps', () => {
160 const { grid } = d
161 expect(grid.cell * grid.na).toBeCloseTo(2 * Math.PI * RADIUS_M, 3)
162 for (const z of [152_000, 170_000, 200_000]) {
163 const before = ground(z, -30)
164 const after = ground(z, 2 * Math.PI * RADIUS_M - 30)
165 expect(Math.abs(after - before)).toBeLessThan(0.01)
166 }
167 expect(CORRIDOR_M).toBeGreaterThan(1000)
168 })
169})