fuzzyworld

nordic-skating / local-littlejs / files / test / lake.test.ts

Branch local-littlejs · default branch · all branches

lake.test.ts

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

8b9467ca 2026-10-09 Pavel Safronov1import { describe, expect, it } from 'vitest'
6216d5cb 2026-10-09 Pavel Safronov2import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js'
3import {
a8fd3f13 2026-10-10 Pavel Safronov4 type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS,
1cb1fefc 2026-10-09 Pavel Safronov5 ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake,
6216d5cb 2026-10-09 Pavel Safronov6} from '../src/world/lake.js'
8b9467ca 2026-10-09 Pavel Safronov7
1cb1fefc 2026-10-09 Pavel Safronov8const lake = createLake(1)
9
10/** Go down the lake as a skater who does nothing would, calling `see` every `step` metres. */
11function follow(to: number, step: number, see: (channel: Channel, s: number) => void): void {
12 let channel = lake.start
13 for (let s = 0; s < to; s += step) {
14 while (channel.end.s <= s) channel = channel.end.preferred
15 see(channel, s)
16 }
17}
18
19/** The first `count` forks that skater comes to. */
20function forks(count: number): Channel['end'][] {
21 const found = []
22 for (let channel = lake.start; found.length < count; channel = channel.end.preferred) found.push(channel.end)
23 return found
24}
25
8b9467ca 2026-10-09 Pavel Safronov26describe('lake', () => {
27 it('is the same lake for the same seed, and another for another', () => {
1cb1fefc 2026-10-09 Pavel Safronov28 const again = createLake(1)
29 const other = createLake(2)
8b9467ca 2026-10-09 Pavel Safronov30 for (const s of [0, 123.4, 5_000, 1e7]) {
1cb1fefc 2026-10-09 Pavel Safronov31 expect(channelAt(again, s).centre(s)).toBe(channelAt(lake, s).centre(s))
32 expect(channelAt(again, s).halfWidth(s)).toBe(channelAt(lake, s).halfWidth(s))
8b9467ca 2026-10-09 Pavel Safronov33 }
1cb1fefc 2026-10-09 Pavel Safronov34 expect(other.start.centre(123.4)).not.toBe(lake.start.centre(123.4))
8b9467ca 2026-10-09 Pavel Safronov35 })
36
1cb1fefc 2026-10-09 Pavel Safronov37 it('is mostly ordinary water, with narrow passes and stretches of open sea', () => {
38 let pass = 0
39 let ordinary = 0
40 let sea = 0
41 let all = 0
42 follow(400_000, 10, (channel, s) => {
43 const w = channel.halfWidth(s)
44 expect(w).toBeGreaterThanOrEqual(MIN_HALF_WIDTH - 1e-9)
8b9467ca 2026-10-09 Pavel Safronov45 expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH)
1cb1fefc 2026-10-09 Pavel Safronov46 // A pass: you could throw a snowball across it. Sea: neither shore within the haze.
47 if (w < 8) pass++
48 else if (w > 600) sea++
49 else if (w <= ORDINARY_HALF_WIDTH[1]) ordinary++
50 all++
51 })
52 console.info(`pass ${((100 * pass) / all).toFixed(1)}%, ordinary ${((100 * ordinary) / all).toFixed(1)}%, sea ${((100 * sea) / all).toFixed(1)}%`)
53 expect(ordinary / all).toBeGreaterThan(0.5)
54 expect(pass / all).toBeGreaterThan(0.03)
55 expect(sea / all).toBeGreaterThan(0.03)
8b9467ca 2026-10-09 Pavel Safronov56 })
57
1cb1fefc 2026-10-09 Pavel Safronov58 it('never bends too sharply to skate at full speed, forks included', () => {
8b9467ca 2026-10-09 Pavel Safronov59 let steepest = 0
1cb1fefc 2026-10-09 Pavel Safronov60 follow(300_000, 3, (channel, s) => (steepest = Math.max(steepest, Math.abs(channel.slope(s)))))
61 // 0.85 sideways per metre forward is a 40 degree turn off straight ahead.
62 expect(steepest).toBeLessThan(0.85)
8b9467ca 2026-10-09 Pavel Safronov63 })
64
65 it('has land that meets the ice at the shore and rises inland', () => {
66 for (const side of [-1, 1] as const) {
67 for (let s = 0; s < 5_000; s += 50) {
68 expect(lake.bankHeight(side, s, 0)).toBe(0)
69 expect(lake.bankHeight(side, s, 60)).toBeGreaterThan(0)
70 }
71 }
72 })
174b4a1a 2026-10-09 Pavel Safronov73
1cb1fefc 2026-10-09 Pavel Safronov74 it('forks into two channels that start as one and draw apart round an island', () => {
75 for (const fork of forks(40)) {
76 const { s, tip, left, right } = fork
77 // At the fork both are still the channel they came from.
78 expect(left.centre(s)).toBe(right.centre(s))
79 expect(left.halfWidth(s)).toBe(right.halfWidth(s))
a8fd3f13 2026-10-10 Pavel Safronov80 expect(left.birth === fork && right.birth === fork).toBe(true)
81 expect(fork.preferred === left || fork.preferred === right).toBe(true)
1cb1fefc 2026-10-09 Pavel Safronov82 // The island begins soon after, and from then on there is always land between.
83 expect(tip - s).toBeGreaterThan(50)
84 expect(tip - s).toBeLessThan(400)
85 for (let at = tip + 20; at < s + FORK_ZONE; at += 10) {
86 const island = right.centre(at) - right.halfWidth(at) - (left.centre(at) + left.halfWidth(at))
87 expect(island).toBeGreaterThan(at < tip + 100 ? 0 : 20)
88 }
89 // Both are ordinary water for as long as both are drawn: a fork reads as a fork.
90 for (let at = s - 300; at < s + FORK_ZONE; at += 25) {
91 for (const channel of [left, right]) expect(channel.halfWidth(at)).toBeLessThanOrEqual(ORDINARY_HALF_WIDTH[1] + 1e-9)
92 }
93 }
94 })
95
96 it('comes to a fork soon, and then every few kilometres', () => {
97 const [first, ...rest] = forks(40).map((fork) => fork.s)
98 expect(first).toBeLessThan(1000)
99 let before = first!
100 for (const s of rest) {
101 expect(s - before).toBeGreaterThanOrEqual(1800)
102 expect(s - before).toBeLessThanOrEqual(4200)
103 before = s
104 }
105 })
106
107 it('draws both channels of a fork, and then only the one taken', () => {
108 const fork = lake.start.end
a8fd3f13 2026-10-10 Pavel Safronov109 // Channels are compared with `===`, never handed to `expect`: a channel leads to its
110 // fork and the fork to more channels without end.
111 const are = (found: Channel[], ...wanted: Channel[]): boolean =>
112 found.length === wanted.length && found.every((channel, i) => channel === wanted[i])
113 expect(are(channelsAt(lake.start, fork.s - 1), lake.start)).toBe(true)
114 expect(are(channelsAt(lake.start, fork.s + 1), fork.left, fork.right)).toBe(true)
1cb1fefc 2026-10-09 Pavel Safronov115 for (const taken of [fork.left, fork.right]) {
a8fd3f13 2026-10-10 Pavel Safronov116 expect(are(channelsAt(taken, fork.s - 1), taken)).toBe(true)
117 expect(are(channelsAt(taken, fork.s + FORK_ZONE - 1), fork.left, fork.right)).toBe(true)
118 expect(are(channelsAt(taken, fork.s + FORK_ZONE + 1), taken)).toBe(true)
1cb1fefc 2026-10-09 Pavel Safronov119 // Before the fork, a channel is the one it forked from.
120 expect(taken.centre(fork.s - 200)).toBe(lake.start.centre(fork.s - 200))
121 expect(taken.halfWidth(fork.s - 200)).toBe(lake.start.halfWidth(fork.s - 200))
122 }
123 })
124
174b4a1a 2026-10-09 Pavel Safronov125 it('has ice between its thinnest and thickest everywhere', () => {
1cb1fefc 2026-10-09 Pavel Safronov126 follow(50_000, 13, (channel, s) => {
127 for (const off of [-80, -20, 0, 35, 90]) {
a8fd3f13 2026-10-10 Pavel Safronov128 const h = lake.thickness(channel.centre(s) + off, s)
174b4a1a 2026-10-09 Pavel Safronov129 expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS)
130 expect(h).toBeLessThanOrEqual(MAX_THICKNESS)
131 }
1cb1fefc 2026-10-09 Pavel Safronov132 })
174b4a1a 2026-10-09 Pavel Safronov133 })
6216d5cb 2026-10-09 Pavel Safronov134
a8fd3f13 2026-10-10 Pavel Safronov135 it('has every kind of real ice within reach of a skater, and impossible ice only when asked', () => {
136 for (const impossibleIce of [false, true]) {
137 const asked = createLake(1, { impossibleIce })
138 const zones = { impossible: 0, cracking: 0, singing: 0, safe: 0 }
139 let all = 0
140 let thinnest = Infinity
141 follow(100_000, 10, (channel, s) => {
142 const reach = channel.halfWidth(s) - 2
143 for (let i = -10; i <= 10; i++) {
144 const h = asked.thickness(channel.centre(s) + (reach * i) / 10, s)
145 thinnest = Math.min(thinnest, h)
146 if (h < BREAKING_THICKNESS) zones.impossible++
147 else if (h < CRACKING_THICKNESS) zones.cracking++
148 else if (h < SAFE_THICKNESS) zones.singing++
149 else zones.safe++
150 all++
1cb1fefc 2026-10-09 Pavel Safronov151 }
a8fd3f13 2026-10-10 Pavel Safronov152 })
153 console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', '), `thinnest ${(thinnest * 1000).toFixed(1)} mm`)
154 if (impossibleIce) {
155 // Thinner than could hold anyone: there to be found, rare, and far thinner.
156 expect(zones.impossible / all).toBeGreaterThan(0.005)
157 expect(zones.impossible / all).toBeLessThan(0.08)
158 expect(thinnest).toBeLessThan(0.012)
159 } else {
160 // The thinnest real ice is ice on the point of giving way, and no thinner.
161 expect(zones.impossible).toBe(0)
162 expect(thinnest).toBe(BREAKING_THICKNESS)
1cb1fefc 2026-10-09 Pavel Safronov163 }
a8fd3f13 2026-10-10 Pavel Safronov164 expect(zones.cracking / all).toBeGreaterThan(0.1)
165 expect(zones.singing / all).toBeGreaterThan(0.2)
166 expect(zones.safe / all).toBeGreaterThan(0.2)
167 }
6216d5cb 2026-10-09 Pavel Safronov168 })
169
a8fd3f13 2026-10-10 Pavel Safronov170 it('can have impossible ice turned on and off under a skater', () => {
171 const options = { impossibleIce: false }
172 const changing = createLake(1, options)
173 let found: [number, number] | undefined
1cb1fefc 2026-10-09 Pavel Safronov174 follow(100_000, 10, (channel, s) => {
a8fd3f13 2026-10-10 Pavel Safronov175 if (!found && changing.thickness(channel.centre(s) + 10, s) === BREAKING_THICKNESS) found = [channel.centre(s) + 10, s]
1cb1fefc 2026-10-09 Pavel Safronov176 })
a8fd3f13 2026-10-10 Pavel Safronov177 options.impossibleIce = true
178 expect(changing.thickness(...found!)).toBeLessThan(BREAKING_THICKNESS)
179 options.impossibleIce = false
180 expect(changing.thickness(...found!)).toBe(BREAKING_THICKNESS)
181 })
182
4f816cf0 2026-10-10 Pavel Safronov183 it('has islets in beds, with ice to skate on all round each, and none near a fork', () => {
184 let count = 0
185 let stretches = 0
186 let withIslets = 0
187 follow(300_000, 500, (channel, s) => {
188 const found = channel.islets(s, s + 500)
189 stretches++
190 if (found.length) withIslets++
191 count += found.length
192 for (const islet of found) {
193 expect(islet.s).toBeGreaterThanOrEqual(s)
194 expect(islet.s).toBeLessThan(s + 500)
195 expect(islet.radius).toBeGreaterThanOrEqual(2.5)
196 expect(islet.radius).toBeLessThanOrEqual(7.5)
197 // Nine metres or more of ice between it and either shore.
198 const room = channel.halfWidth(islet.s) - Math.abs(islet.x - channel.centre(islet.s)) - islet.radius
199 expect(room).toBeGreaterThanOrEqual(9 - 1e-9)
200 // Not where a fork's two channels are both in sight, nor coming up to a fork.
201 if (channel.birth) expect(islet.s).toBeGreaterThan(channel.birth.s + FORK_ZONE)
202 expect(islet.s).toBeLessThan(channel.end.s - 250)
203 }
204 // The same islets however they are asked for.
205 const again = [...channel.islets(s, s + 200), ...channel.islets(s + 200, s + 500)]
206 expect(again.map((i) => i.s)).toEqual(found.map((i) => i.s))
207 })
208 console.info(`${count} islets in 300 km, in ${((100 * withIslets) / stretches).toFixed(0)}% of its half-kilometres`)
209 // In beds: many of them, and most of the lake without any.
210 expect(count).toBeGreaterThan(200)
211 expect(withIslets / stretches).toBeLessThan(0.4)
212 })
213
26ace8f5 2026-10-10 Pavel Safronov214 it('has long islands with water both sides, clear of forks and of islets', () => {
215 let islands = 0
216 let within = false
217 let widest = 0
218 follow(300_000, 5, (channel, s) => {
219 const island = channel.island(s)
220 if (island > 0 && !within) islands++
221 within = island > 0
222 if (island === 0) return
223 widest = Math.max(widest, island)
224 expect(island).toBeLessThanOrEqual(16)
225 // Water either side about as wide as the narrowest ordinary channel is to a side.
226 expect(channel.halfWidth(s) - island).toBeGreaterThanOrEqual(ORDINARY_HALF_WIDTH[0] - 2)
227 expect([-1, 1]).toContain(channel.islandSide(s))
228 if (channel.birth) expect(s).toBeGreaterThan(channel.birth.s + FORK_ZONE)
229 expect(s).toBeLessThan(channel.end.s - 300)
230 expect(channel.islets(s - 30, s + 30)).toEqual([])
231 })
232 console.info(`${islands} islands in 300 km`)
233 expect(islands).toBeGreaterThan(40)
234 expect(widest).toBeGreaterThan(12)
235 })
236
a8fd3f13 2026-10-10 Pavel Safronov237 it('gives the autopilot a line that wanders slowly from side to side', () => {
238 let leftmost = 0
239 let rightmost = 0
240 for (let s = 0; s < 50_000; s += 5) {
241 const c = lake.carve(s)
242 expect(Math.abs(c)).toBeLessThanOrEqual(1)
243 // Slowly: never more than a thirtieth of its whole swing in a metre.
244 expect(Math.abs(lake.carve(s + 1) - c)).toBeLessThan(0.06)
245 leftmost = Math.min(leftmost, c)
246 rightmost = Math.max(rightmost, c)
247 }
248 expect(leftmost).toBeLessThan(-0.7)
249 expect(rightmost).toBeGreaterThan(0.7)
6216d5cb 2026-10-09 Pavel Safronov250 })
8b9467ca 2026-10-09 Pavel Safronov251})