nordic-skating / mistral-medium-35-littlejs / files / test / lake.test.ts
Branch mistral-medium-35-littlejs · default branch · all branches
lake.test.ts
8b9467ca 2026-10-09 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
6216d5cb 2026-10-09 Pavel Safronov | 2 | import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js' |
| 3 | import { | |
a8fd3f13 2026-10-10 Pavel Safronov | 4 | type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, |
1cb1fefc 2026-10-09 Pavel Safronov | 5 | ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake, |
6216d5cb 2026-10-09 Pavel Safronov | 6 | } from '../src/world/lake.js' |
8b9467ca 2026-10-09 Pavel Safronov | 7 | |
1cb1fefc 2026-10-09 Pavel Safronov | 8 | const lake = createLake(1) |
| 9 | ||
| 10 | /** Go down the lake as a skater who does nothing would, calling `see` every `step` metres. */ | |
| 11 | function 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. */ | |
| 20 | function 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 Safronov | 26 | describe('lake', () => { |
| 27 | it('is the same lake for the same seed, and another for another', () => { | |
1cb1fefc 2026-10-09 Pavel Safronov | 28 | const again = createLake(1) |
| 29 | const other = createLake(2) | |
8b9467ca 2026-10-09 Pavel Safronov | 30 | for (const s of [0, 123.4, 5_000, 1e7]) { |
1cb1fefc 2026-10-09 Pavel Safronov | 31 | 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 Safronov | 33 | } |
1cb1fefc 2026-10-09 Pavel Safronov | 34 | expect(other.start.centre(123.4)).not.toBe(lake.start.centre(123.4)) |
8b9467ca 2026-10-09 Pavel Safronov | 35 | }) |
| 36 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 37 | 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 Safronov | 45 | expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH) |
1cb1fefc 2026-10-09 Pavel Safronov | 46 | // 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 Safronov | 56 | }) |
| 57 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 58 | it('never bends too sharply to skate at full speed, forks included', () => { |
8b9467ca 2026-10-09 Pavel Safronov | 59 | let steepest = 0 |
1cb1fefc 2026-10-09 Pavel Safronov | 60 | 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 Safronov | 63 | }) |
| 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 Safronov | 73 | |
1cb1fefc 2026-10-09 Pavel Safronov | 74 | 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 Safronov | 80 | expect(left.birth === fork && right.birth === fork).toBe(true) |
| 81 | expect(fork.preferred === left || fork.preferred === right).toBe(true) | |
1cb1fefc 2026-10-09 Pavel Safronov | 82 | // 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 Safronov | 109 | // 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 Safronov | 115 | for (const taken of [fork.left, fork.right]) { |
a8fd3f13 2026-10-10 Pavel Safronov | 116 | 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 Safronov | 119 | // 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 Safronov | 125 | it('has ice between its thinnest and thickest everywhere', () => { |
1cb1fefc 2026-10-09 Pavel Safronov | 126 | follow(50_000, 13, (channel, s) => { |
| 127 | for (const off of [-80, -20, 0, 35, 90]) { | |
a8fd3f13 2026-10-10 Pavel Safronov | 128 | const h = lake.thickness(channel.centre(s) + off, s) |
174b4a1a 2026-10-09 Pavel Safronov | 129 | expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS) |
| 130 | expect(h).toBeLessThanOrEqual(MAX_THICKNESS) | |
| 131 | } | |
1cb1fefc 2026-10-09 Pavel Safronov | 132 | }) |
174b4a1a 2026-10-09 Pavel Safronov | 133 | }) |
6216d5cb 2026-10-09 Pavel Safronov | 134 | |
a8fd3f13 2026-10-10 Pavel Safronov | 135 | 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 Safronov | 151 | } |
a8fd3f13 2026-10-10 Pavel Safronov | 152 | }) |
| 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 Safronov | 163 | } |
a8fd3f13 2026-10-10 Pavel Safronov | 164 | 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 Safronov | 168 | }) |
| 169 | ||
a8fd3f13 2026-10-10 Pavel Safronov | 170 | 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 Safronov | 174 | follow(100_000, 10, (channel, s) => { |
a8fd3f13 2026-10-10 Pavel Safronov | 175 | if (!found && changing.thickness(channel.centre(s) + 10, s) === BREAKING_THICKNESS) found = [channel.centre(s) + 10, s] |
1cb1fefc 2026-10-09 Pavel Safronov | 176 | }) |
a8fd3f13 2026-10-10 Pavel Safronov | 177 | 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 Safronov | 183 | 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 Safronov | 214 | 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 Safronov | 237 | 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 Safronov | 250 | }) |
8b9467ca 2026-10-09 Pavel Safronov | 251 | }) |