nordic-skating / files / test / skater.test.ts
skater.test.ts
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { BREAKING_THICKNESS } from '../src/ice/strength.js' |
| 3 | import { |
| 4 | ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, |
| 5 | PACES, PACE_NAMES, RULES, type Skater, createSkater, stepSkater, strideSeconds, |
| 6 | } from '../src/skater.js' |
| 7 | import { type Lake, createLake, mulberry32 } from '../src/world/lake.js' |
| 8 | |
| 9 | const DT = 1 / 60 |
| 10 | const lake = createLake(1) |
| 11 | /** An autopilot that skates a straight line where it is put, for trying what is not about its carving. */ |
| 12 | const STRAIGHT = { falling: false, carve: false } |
| 13 | |
| 14 | function run(k: Skater, steer: number, seconds: number, rules = STRAIGHT, on = lake): void { |
| 15 | const steps = Math.round(seconds / DT) |
| 16 | for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT, rules) |
| 17 | } |
| 18 | /** How far the skater is from the middle of their channel, metres; positive is right. */ |
| 19 | const off = (k: Skater): number => k.x - k.channel.centre(k.s) |
| 20 | /** How far off the line of their channel they point, radians, between a half turn either way. */ |
| 21 | const out = (k: Skater): number => { |
| 22 | const angle = k.heading - Math.atan(k.channel.slope(k.s)) |
| 23 | return angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI)) |
| 24 | } |
| 25 | /** How much ice there is between the skater and the nearest islet's edge, metres. */ |
| 26 | const clearOfIslets = (k: Skater): number => |
| 27 | Math.min(Infinity, ...k.channel.islets(k.s - 30, k.s + 30).map((i) => Math.hypot(k.x - i.x, k.s - i.s) - i.radius)) |
| 28 | /** How much water there is between the skater and the island in their channel, metres; all there could be where there is none. */ |
| 29 | const clearOfIsland = (k: Skater): number => (k.channel.island(k.s) > 0 ? Math.abs(off(k)) - k.channel.island(k.s) : Infinity) |
| 30 | /** Where the skater's channel next has an island at its widest, from `from` on. */ |
| 31 | function nextIsland(from: number): number { |
| 32 | let channel = lake.start |
| 33 | let s = from |
| 34 | for (; ; s += 5) { |
| 35 | while (channel.end.s <= s) channel = channel.end.preferred |
| 36 | if (channel.island(s) > 5 && channel.island(s + 5) < channel.island(s)) return s |
| 37 | } |
| 38 | } |
| 39 | /** The channel a skater who does nothing is in at `s`. */ |
| 40 | function channelAtIsland(s: number): Skater['channel'] { |
| 41 | let channel = lake.start |
| 42 | while (channel.end.s <= s) channel = channel.end.preferred |
| 43 | return channel |
| 44 | } |
| 45 | /** Someone steering this way and that, and sometimes not at all: the same someone every time. */ |
| 46 | function fidget(seed: number): (t: number) => number { |
| 47 | const random = mulberry32(seed) |
| 48 | let until = 0 |
| 49 | let steer = 0 |
| 50 | return (t) => { |
| 51 | if (t >= until) { |
| 52 | until = t + 0.5 + 4 * random() |
| 53 | steer = random() < 0.3 ? 0 : random() * 2 - 1 |
| 54 | } |
| 55 | return steer |
| 56 | } |
| 57 | } |
| 58 | /** Whether there is nothing but the skater's own channel to either side of them. */ |
| 59 | const alone = (k: Skater): boolean => !k.channel.birth || k.s > k.channel.birth.tip |
| 60 | // Channels are compared with `===`, never handed to `expect`: a channel leads to its fork |
| 61 | // and the fork to more channels without end, and a failure that tried to print one would |
| 62 | // follow them until the stack ran out. |
| 63 | |
| 64 | describe('skater', () => { |
| 65 | it('left alone, skates the lake for as long as you like, through every fork', () => { |
| 66 | const k = createSkater(lake) |
| 67 | let forks = 0 |
| 68 | let slowest = Infinity |
| 69 | let fastest = 0 |
| 70 | let widest = 0 |
| 71 | const islands = new Set<number>() |
| 72 | const start = k.s |
| 73 | for (let t = 0; t < 3600; t += DT) { |
| 74 | const before = k.s |
| 75 | const channel = k.channel |
| 76 | stepSkater(k, lake, 0, DT) |
| 77 | expect(k.s).toBeGreaterThan(before) |
| 78 | expect(k.under).toBe(0) |
| 79 | // On the ice, always: never on the shore side of the two metres kept clear of it. |
| 80 | if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(k.channel.halfWidth(k.s) - 0.5) |
| 81 | // Round every islet, with room to spare, and down one side of every island. |
| 82 | expect(clearOfIslets(k)).toBeGreaterThan(ISLET_MARGIN) |
| 83 | expect(clearOfIsland(k)).toBeGreaterThan(SHORE_MARGIN / 2) |
| 84 | if (k.channel.island(k.s) > 0) islands.add(Math.sign(off(k))) |
| 85 | if (k.channel !== channel) { |
| 86 | // One or other way at every fork, whichever the skater was over towards; or, just |
| 87 | // past one, across to its other channel while there is still open ice between. |
| 88 | const taken = k.channel === channel.end.left || k.channel === channel.end.right |
| 89 | expect(taken || k.channel.birth === channel.birth).toBe(true) |
| 90 | if (taken) forks++ |
| 91 | } |
| 92 | slowest = Math.min(slowest, k.speed) |
| 93 | fastest = Math.max(fastest, k.speed) |
| 94 | widest = Math.max(widest, Math.abs(off(k))) |
| 95 | } |
| 96 | expect(forks).toBeGreaterThan(5) |
| 97 | // Some islands passed on the left and some on the right, as each prefers. |
| 98 | expect([...islands].sort()).toEqual([-1, 1]) |
| 99 | // It carves: well off the middle at times, where there is the room. |
| 100 | expect(widest).toBeGreaterThan(4) |
| 101 | // The speed breathes with the stride, around its cruising speed and never far from it. |
| 102 | expect((k.s - start) / 3600).toBeGreaterThan(SPEED * 0.9) |
| 103 | expect((k.s - start) / 3600).toBeLessThan(SPEED * 1.1) |
| 104 | expect(slowest).toBeGreaterThan(SPEED * 0.8) |
| 105 | expect(fastest).toBeLessThan(SPEED * 1.2) |
| 106 | expect(fastest - slowest).toBeGreaterThan(1) |
| 107 | }) |
| 108 | |
| 109 | it('moves smoothly, however it is steered: no jumps, no jerks', () => { |
| 110 | for (const seed of [1, 2, 3]) { |
| 111 | const k = createSkater(lake) |
| 112 | const steer = fidget(seed) |
| 113 | for (let t = 0; t < 600; t += DT) { |
| 114 | const { x, s, heading, speed } = k |
| 115 | stepSkater(k, lake, steer(t), DT) |
| 116 | expect(Math.hypot(k.x - x, k.s - s)).toBeLessThan(0.25) |
| 117 | expect(Math.abs(k.heading - heading)).toBeLessThan(0.05) |
| 118 | expect(Math.abs(k.speed - speed)).toBeLessThan(0.05) |
| 119 | expect(Math.abs(k.turn)).toBeLessThanOrEqual(2 * MAX_TURN) |
| 120 | } |
| 121 | } |
| 122 | }) |
| 123 | |
| 124 | it('steers by turning, with a moment to lean into it', () => { |
| 125 | const k = createSkater(lake) |
| 126 | run(k, 1, 0.1) |
| 127 | // A tenth of a second in, the turn has barely begun. |
| 128 | expect(k.turn).toBeGreaterThan(0) |
| 129 | expect(k.turn).toBeLessThan(MAX_TURN / 2) |
| 130 | run(k, 1, 1) |
| 131 | expect(k.turn).toBeCloseTo(MAX_TURN, 1) |
| 132 | expect(out(k)).toBeGreaterThan(0.2) |
| 133 | expect(off(k)).toBeGreaterThan(0.5) |
| 134 | run(k, -1, 3) |
| 135 | expect(out(k)).toBeLessThan(0) |
| 136 | }) |
| 137 | |
| 138 | it('cannot be steered into a shore or onto an islet, however wide or narrow the lake', () => { |
| 139 | for (const seed of [4, 5, 6]) { |
| 140 | const k = createSkater(lake) |
| 141 | const steer = fidget(seed) |
| 142 | let nearest = Infinity |
| 143 | for (let t = 0; t < 1200; t += DT) { |
| 144 | stepSkater(k, lake, steer(t), DT, STRAIGHT) |
| 145 | if (alone(k)) { |
| 146 | expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3) |
| 147 | } |
| 148 | nearest = Math.min(nearest, clearOfIslets(k)) |
| 149 | expect(clearOfIsland(k)).toBeGreaterThan(0.5) |
| 150 | // Never on one. (Steered straight at one they come inside its margin for a moment.) |
| 151 | expect(nearest).toBeGreaterThan(0.2) |
| 152 | } |
| 153 | // And they did get in among the islets: this was not passed by keeping away. |
| 154 | expect(nearest).toBeLessThan(15) |
| 155 | } |
| 156 | }) |
| 157 | |
| 158 | it('held hard over, turns right round, and left alone turns back down the lake', () => { |
| 159 | // Out at sea, where there is the room to. |
| 160 | const k = createSkater(lake, 3400) |
| 161 | expect(k.channel.halfWidth(k.s)).toBeGreaterThan(100) |
| 162 | let furthestBack = 0 |
| 163 | const start = k.heading |
| 164 | for (let t = 0; t < (2 * Math.PI) / MAX_TURN + 3; t += DT) { |
| 165 | const before = k.s |
| 166 | stepSkater(k, lake, 1, DT, STRAIGHT) |
| 167 | furthestBack = Math.min(furthestBack, k.s - before) |
| 168 | } |
| 169 | // A whole circle, part of it back up the lake. |
| 170 | expect(k.heading - start).toBeGreaterThan(2 * Math.PI * 0.9) |
| 171 | expect(furthestBack).toBeLessThan(-0.05) |
| 172 | // Let go part way round the next one, facing back the way they came. |
| 173 | for (let i = 0; i < 1200 && Math.cos(out(k)) > -0.9; i++) stepSkater(k, lake, 1, DT, STRAIGHT) |
| 174 | expect(Math.cos(out(k))).toBeLessThan(-0.85) |
| 175 | run(k, 0, 25) |
| 176 | expect(Math.abs(out(k))).toBeLessThan(0.1) |
| 177 | const then = k.s |
| 178 | run(k, 0, 5) |
| 179 | expect(k.s - then).toBeGreaterThan(SPEED * 5 * 0.8) |
| 180 | }) |
| 181 | |
| 182 | it('down the middle and left alone, takes the way a fork prefers', () => { |
| 183 | const fork = lake.start.end |
| 184 | const k = createSkater(lake, fork.s - 100) |
| 185 | while (k.s < fork.tip + 50) stepSkater(k, lake, 0, DT, STRAIGHT) |
| 186 | expect(k.channel === fork.preferred).toBe(true) |
| 187 | }) |
| 188 | |
| 189 | it('takes the left of a fork when steered left and the right when steered right', () => { |
| 190 | const fork = lake.start.end |
| 191 | for (const [steer, wanted] of [[-1, fork.left], [1, fork.right]] as const) { |
| 192 | const k = createSkater(lake, fork.s - 30) |
| 193 | // A nudge that way and no more: held over, it would be a circle. |
| 194 | run(k, steer, 1.2) |
| 195 | while (k.s < fork.tip + 50) stepSkater(k, lake, 0, DT, STRAIGHT) |
| 196 | expect(k.channel === wanted).toBe(true) |
| 197 | // And from there on left alone, stays in it. |
| 198 | run(k, 0, 60) |
| 199 | expect(k.channel === wanted).toBe(true) |
| 200 | } |
| 201 | }) |
| 202 | |
| 203 | it('can change its mind at a fork until the island begins, and then runs along its shore', () => { |
| 204 | const fork = lake.start.end |
| 205 | const other = fork.preferred === fork.left ? fork.right : fork.left |
| 206 | const towards = other === fork.right ? 1 : -1 |
| 207 | const k = createSkater(lake, fork.s - 1) |
| 208 | run(k, 0, 1) |
| 209 | expect(k.channel === fork.preferred).toBe(true) |
| 210 | run(k, towards, 1.2) |
| 211 | while (k.s < fork.tip + 5) stepSkater(k, lake, 0, DT, STRAIGHT) |
| 212 | expect(k.channel === other).toBe(true) |
| 213 | // Past the island's tip, a nudge back towards it does not cross it. |
| 214 | run(k, -towards, 1.5) |
| 215 | run(k, 0, 20) |
| 216 | expect(k.channel === other).toBe(true) |
| 217 | }) |
| 218 | |
| 219 | it('passes an island on the side it was steered towards on the way up to it', () => { |
| 220 | const middle = nextIsland(2000) |
| 221 | for (const steer of [-1, 1]) { |
| 222 | // From 150 m short of the island's near end: a touch, and then nothing. |
| 223 | let start = middle |
| 224 | while (channelAtIsland(start).island(start) > 0) start -= 5 |
| 225 | const k = createSkater(lake, start - 100) |
| 226 | run(k, steer, 0.6) |
| 227 | while (k.s < middle) stepSkater(k, lake, 0, DT, STRAIGHT) |
| 228 | expect(Math.sign(off(k))).toBe(steer) |
| 229 | expect(Math.abs(off(k))).toBeGreaterThan(k.channel.island(k.s) + SHORE_MARGIN) |
| 230 | } |
| 231 | }) |
| 232 | |
| 233 | it('left alone after being steered, goes on the way it was going, and does not go back', () => { |
| 234 | // Out at sea, where there is the room. The autopilot has been carving for a minute. |
| 235 | const k = createSkater(lake, 3300) |
| 236 | run(k, 0, 60, RULES) |
| 237 | const before = off(k) |
| 238 | // Steered right for a couple of seconds, and let go. |
| 239 | run(k, 1, 2, RULES) |
| 240 | const letGo = off(k) |
| 241 | expect(out(k)).toBeGreaterThan(0.3) |
| 242 | expect(letGo).toBeGreaterThan(before + 2) |
| 243 | // It does not fight: half a second on it is still heading right, and it never turns |
| 244 | // back harder than a gentle carve does. |
| 245 | let hardest = 0 |
| 246 | for (let i = 0; i < Math.round(4 / DT); i++) { |
| 247 | stepSkater(k, lake, 0, DT, RULES) |
| 248 | if (i === Math.round(0.5 / DT)) expect(out(k)).toBeGreaterThan(0.2) |
| 249 | hardest = Math.min(hardest, k.turn) |
| 250 | } |
| 251 | expect(hardest).toBeGreaterThan(-0.32) |
| 252 | // Four seconds on it has gone further right still. |
| 253 | expect(off(k)).toBeGreaterThan(letGo + 4) |
| 254 | // And for the next two minutes it carves about where it was sent, not where it had been. |
| 255 | let sum = 0 |
| 256 | let count = 0 |
| 257 | for (let t = 0; t < 120; t += DT) { |
| 258 | stepSkater(k, lake, 0, DT, RULES) |
| 259 | sum += off(k) |
| 260 | count++ |
| 261 | } |
| 262 | expect(sum / count).toBeGreaterThan(letGo - 3) |
| 263 | expect(sum / count - before).toBeGreaterThan(5) |
| 264 | }) |
| 265 | |
| 266 | it('with carving off, stays on the line it was steered to', () => { |
| 267 | const k = createSkater(lake, 3300) |
| 268 | run(k, 1, 1.5) |
| 269 | run(k, 0, 15) |
| 270 | const settled = off(k) |
| 271 | expect(settled).toBeGreaterThan(3) |
| 272 | run(k, 0, 30) |
| 273 | expect(off(k)).toBeCloseTo(settled, 0) |
| 274 | expect(Math.abs(out(k))).toBeLessThan(0.02) |
| 275 | }) |
| 276 | |
| 277 | it('takes long strides of varied length, the same ones every time', () => { |
| 278 | const lengths = Array.from({ length: 200 }, (_, n) => strideSeconds(n)) |
| 279 | for (const [n, seconds] of lengths.entries()) { |
| 280 | expect(seconds).toBeGreaterThanOrEqual(MIN_STRIDE) |
| 281 | expect(seconds).toBeLessThanOrEqual(MAX_STRIDE) |
| 282 | expect(strideSeconds(n)).toBe(seconds) |
| 283 | } |
| 284 | // Varied: they fill the range rather than sitting at one length. |
| 285 | expect(Math.max(...lengths) - Math.min(...lengths)).toBeGreaterThan(0.8 * (MAX_STRIDE - MIN_STRIDE)) |
| 286 | // And the skater takes them at that pace: the first three strides, to the frame. |
| 287 | const k = createSkater(lake) |
| 288 | run(k, 0, lengths[0]! + lengths[1]! + lengths[2]! + 2 * DT) |
| 289 | expect(Math.floor(k.stride)).toBe(3) |
| 290 | }) |
| 291 | |
| 292 | it('speeds up on the push and slows through the glide', () => { |
| 293 | const k = createSkater(lake) |
| 294 | const before = k.speed |
| 295 | run(k, 0, 0.5) |
| 296 | expect(k.speed).toBeGreaterThan(before + 0.5) |
| 297 | const pushed = k.speed |
| 298 | run(k, 0, strideSeconds(0) - 0.5 - 2 * DT) |
| 299 | expect(k.speed).toBeLessThan(pushed - 0.3) |
| 300 | }) |
| 301 | |
| 302 | it('is slowed by snow lying on the ice, and picks up again off it', () => { |
| 303 | const snowy: Lake = { ...lake, snow: () => 1 } |
| 304 | const k = createSkater(lake, 3300) |
| 305 | run(k, 0, 40, STRAIGHT, snowy) |
| 306 | // About half speed in the middle of a drift, and still moving. |
| 307 | expect(k.speed).toBeLessThan(SPEED * 0.65) |
| 308 | expect(k.speed).toBeGreaterThan(SPEED * 0.35) |
| 309 | run(k, 0, 40) |
| 310 | expect(k.speed).toBeGreaterThan(SPEED * 0.85) |
| 311 | }) |
| 312 | |
| 313 | it('keeps each pace: settles round its speed, in strides to match, and breathes by the same share', () => { |
| 314 | for (const pace of PACE_NAMES) { |
| 315 | const k = createSkater(lake, 3300) |
| 316 | k.pace = pace |
| 317 | run(k, 0, 40) |
| 318 | let sum = 0 |
| 319 | let slowest = Infinity |
| 320 | let fastest = 0 |
| 321 | const strides = k.stride |
| 322 | const steps = Math.round(120 / DT) |
| 323 | for (let i = 0; i < steps; i++) { |
| 324 | stepSkater(k, lake, 0, DT, STRAIGHT) |
| 325 | sum += k.speed |
| 326 | slowest = Math.min(slowest, k.speed) |
| 327 | fastest = Math.max(fastest, k.speed) |
| 328 | } |
| 329 | const wanted = PACES[pace].speed |
| 330 | expect(sum / steps / wanted).toBeGreaterThan(0.93) |
| 331 | expect(sum / steps / wanted).toBeLessThan(1.07) |
| 332 | // Between a tenth and a third of its speed between the slowest and fastest of a stride. |
| 333 | expect((fastest - slowest) / wanted).toBeGreaterThan(0.1) |
| 334 | expect((fastest - slowest) / wanted).toBeLessThan(0.35) |
| 335 | // Strides as long as the pace takes them: about 2.3 s at a cruise. |
| 336 | expect(120 / (k.stride - strides) / PACES[pace].strides).toBeCloseTo(2.3, 0) |
| 337 | } |
| 338 | }) |
| 339 | |
| 340 | it('works up to a faster pace and eases down to a slower one over a few strides', () => { |
| 341 | const k = createSkater(lake, 3300) |
| 342 | k.pace = 'olympic' |
| 343 | run(k, 0, 2) |
| 344 | // Not at once. |
| 345 | expect(k.speed).toBeLessThan(13) |
| 346 | run(k, 0, 25) |
| 347 | expect(k.speed).toBeGreaterThan(13) |
| 348 | k.pace = 'meander' |
| 349 | run(k, 0, 3) |
| 350 | expect(k.speed).toBeGreaterThan(5) |
| 351 | run(k, 0, 40) |
| 352 | expect(k.speed).toBeLessThan(4.5) |
| 353 | }) |
| 354 | |
| 355 | it('at a racing pace turns in a wider circle, and still keeps to the ice and clear of everything', () => { |
| 356 | const k = createSkater(lake) |
| 357 | k.pace = 'olympic' |
| 358 | const steer = fidget(11) |
| 359 | let hardest = 0 |
| 360 | for (let t = 0; t < 1200; t += DT) { |
| 361 | stepSkater(k, lake, steer(t), DT, RULES) |
| 362 | // Never pressed sideways much harder than half a g, turning away from an edge excepted. |
| 363 | hardest = Math.max(hardest, Math.abs(k.turn) * k.speed) |
| 364 | if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3) |
| 365 | expect(clearOfIslets(k)).toBeGreaterThan(0) |
| 366 | expect(clearOfIsland(k)).toBeGreaterThan(0.3) |
| 367 | } |
| 368 | expect(hardest).toBeLessThan(11) |
| 369 | const auto = createSkater(lake) |
| 370 | auto.pace = 'olympic' |
| 371 | for (let t = 0; t < 1200; t += DT) { |
| 372 | stepSkater(auto, lake, 0, DT, RULES) |
| 373 | if (alone(auto)) expect(Math.abs(off(auto))).toBeLessThan(auto.channel.halfWidth(auto.s) - 0.5) |
| 374 | expect(clearOfIslets(auto)).toBeGreaterThan(0.5) |
| 375 | expect(clearOfIsland(auto)).toBeGreaterThan(0.5) |
| 376 | } |
| 377 | // Twenty minutes at that pace is the better part of eighteen kilometres. |
| 378 | expect(auto.s).toBeGreaterThan(15 * 1200 * 0.85) |
| 379 | }) |
| 380 | |
| 381 | it('skates the same far down the lake as at its start', () => { |
| 382 | const k = createSkater(lake, 1e6) |
| 383 | run(k, 0, 10) |
| 384 | expect(k.s - 1e6).toBeGreaterThan(SPEED * 10 * 0.9) |
| 385 | }) |
| 386 | |
| 387 | it('does not go through the ice, however thin, unless the rules say it can', () => { |
| 388 | // A lake that would give way anywhere 5 m to the right of the middle. |
| 389 | const thin: Lake = { ...lake, thickness: (x, s) => (x - lake.start.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } |
| 390 | const safe = createSkater(lake) |
| 391 | run(safe, 1, 20, STRAIGHT, thin) |
| 392 | expect(off(safe)).toBeGreaterThan(5) |
| 393 | expect(safe.under).toBe(0) |
| 394 | |
| 395 | const k = createSkater(lake) |
| 396 | while (k.under === 0) stepSkater(k, thin, 1, DT, { falling: true, carve: false }) |
| 397 | expect(off(k)).toBeGreaterThan(5) |
| 398 | const where = k.s |
| 399 | for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT, { falling: true, carve: false }) |
| 400 | expect(k.under).toBeGreaterThan(0) |
| 401 | expect(k.s).toBe(where) |
| 402 | for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT, { falling: true, carve: false }) |
| 403 | expect(k.under).toBe(0) |
| 404 | expect(off(k)).toBeCloseTo(0, 1) |
| 405 | expect(k.s).toBeGreaterThan(where) |
| 406 | }) |
| 407 | }) |