nordic-skating / mistral-medium-35-littlejs / files / test / lake.test.ts
Branch mistral-medium-35-littlejs · default branch · all branches
lake.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js' |
| 3 | import { |
| 4 | type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, |
| 5 | ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake, |
| 6 | } from '../src/world/lake.js' |
| 7 | |
| 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 | |
| 26 | describe('lake', () => { |
| 27 | it('is the same lake for the same seed, and another for another', () => { |
| 28 | const again = createLake(1) |
| 29 | const other = createLake(2) |
| 30 | for (const s of [0, 123.4, 5_000, 1e7]) { |
| 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)) |
| 33 | } |
| 34 | expect(other.start.centre(123.4)).not.toBe(lake.start.centre(123.4)) |
| 35 | }) |
| 36 | |
| 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) |
| 45 | expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH) |
| 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) |
| 56 | }) |
| 57 | |
| 58 | it('never bends too sharply to skate at full speed, forks included', () => { |
| 59 | let steepest = 0 |
| 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) |
| 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 | }) |
| 73 | |
| 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)) |
| 80 | expect(left.birth === fork && right.birth === fork).toBe(true) |
| 81 | expect(fork.preferred === left || fork.preferred === right).toBe(true) |
| 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 |
| 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) |
| 115 | for (const taken of [fork.left, fork.right]) { |
| 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) |
| 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 | |
| 125 | it('has ice between its thinnest and thickest everywhere', () => { |
| 126 | follow(50_000, 13, (channel, s) => { |
| 127 | for (const off of [-80, -20, 0, 35, 90]) { |
| 128 | const h = lake.thickness(channel.centre(s) + off, s) |
| 129 | expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS) |
| 130 | expect(h).toBeLessThanOrEqual(MAX_THICKNESS) |
| 131 | } |
| 132 | }) |
| 133 | }) |
| 134 | |
| 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++ |
| 151 | } |
| 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) |
| 163 | } |
| 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 | } |
| 168 | }) |
| 169 | |
| 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 |
| 174 | follow(100_000, 10, (channel, s) => { |
| 175 | if (!found && changing.thickness(channel.centre(s) + 10, s) === BREAKING_THICKNESS) found = [channel.centre(s) + 10, s] |
| 176 | }) |
| 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 | |
| 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 | |
| 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 | |
| 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) |
| 250 | }) |
| 251 | }) |