import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js'; import { RINK, insideRink } from '../shared/rink.js'; import { normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer, } from '../shared/player.js'; import { done, near, ok, section } from './harness.mjs'; const DT = 1 / 120; /** * Run the sim for `seconds`, optionally editing the state each step. * * Board clamping is off by default so a test about acceleration is not * secretly a test about the end boards. The containment section turns it back * on, which is the only place it is the subject. */ function run(s, seconds, edit = null, opts = { clampBoards: false }) { const steps = Math.round(seconds / DT); for (let i = 0; i < steps; i++) { if (edit) edit(s, i * DT); stepSkater(s, DT, opts); } return s; } /** Down the ice: the rink's long axis is +X, which is yaw = PI/2. */ const START = { x: 0, z: 0, yaw: Math.PI / 2 }; const forward = (s) => { s.ix = 1; s.iz = 0; }; const coast = (s) => { s.ix = 0; s.iz = 0; }; section('the stride reaches a speed and holds it'); { const s = createSkaterState(0, START); run(s, 8, forward); const cruise = speedOf(s); ok(cruise > 4.5, `cruise settles above 4.5 m/s (got ${cruise.toFixed(2)})`); ok(cruise <= SKATE.cruiseSpeed, `and never exceeds the cruise ceiling (${cruise.toFixed(2)})`); // Another four seconds must not keep adding speed. const before = speedOf(s); run(s, 4, forward); near(speedOf(s), before, 0.05, 'top speed is stable, not creeping'); } section('acceleration takes time — you cannot jump to top speed'); { const s = createSkaterState(0, START); run(s, 0.5, forward); const half = speedOf(s); ok(half > 0.8, `half a second of pushing gets you moving (${half.toFixed(2)} m/s)`); ok(half < 4, 'but nowhere near cruise'); } section('sprinting is meaningfully faster'); { const cruiser = createSkaterState(0, START); run(cruiser, 8, forward); const sprinter = createSkaterState(1, START); run(sprinter, 8, (s) => { forward(s); s.sprint = true; }); ok( speedOf(sprinter) > speedOf(cruiser) + 1.5, `sprint beats cruise by more than 1.5 m/s (${speedOf(sprinter).toFixed(2)} vs ${speedOf(cruiser).toFixed(2)})`, ); ok(speedOf(sprinter) <= SKATE.sprintSpeed, 'and stays under the sprint ceiling'); } section('a glide keeps its momentum'); { const s = createSkaterState(0, START); run(s, 8, forward); const entry = speedOf(s); run(s, 3, coast); const after = speedOf(s); ok(after > entry * 0.6, `three seconds of glide keeps most of the speed (${after.toFixed(2)} of ${entry.toFixed(2)})`); ok(after < entry, 'but not all of it'); } section('braking is much faster than gliding'); { const glide = createSkaterState(0, START); run(glide, 8, forward); const brake = createSkaterState(1, START); run(brake, 8, forward); near(speedOf(glide), speedOf(brake), 0.01, 'both start from the same speed'); run(glide, 1, coast); run(brake, 1, (s) => { coast(s); s.brake = true; }); ok(speedOf(brake) < 0.6, `a hockey stop is done inside a second (${speedOf(brake).toFixed(2)} m/s left)`); ok(speedOf(glide) > speedOf(brake) * 4, 'a glide over the same second is nowhere near stopped'); } section('the blade kills sideways drift'); { const s = createSkaterState(0, START); // Thrown across the blade at 4 m/s: body pointing +X, momentum along +Z. s.vx = 0; s.vz = 4; run(s, 1.5, coast); const velYaw = Math.atan2(s.vx, s.vz); const offBlade = Math.abs(Math.abs(velYaw) - Math.PI / 2); ok(offBlade < 0.25, `momentum ends up along the blade, not across it (${offBlade.toFixed(3)} rad off)`); } section('a carve redirects momentum instead of destroying it'); { const s = createSkaterState(0, START); run(s, 6, forward); const entry = speedOf(s); ok(Math.abs(Math.atan2(s.vx, s.vz) - Math.PI / 2) < 0.05, 'travelling straight down the ice first'); // Ninety degrees of turn: the stick swings from +X to -Z. run(s, 1.2, (st) => { st.ix = 0; st.iz = -1; }); const velYaw = Math.atan2(s.vx, s.vz); ok(velYaw > 2.2, `the velocity vector followed the turn round (${velYaw.toFixed(2)} rad, want ~PI)`); ok(speedOf(s) > entry * 0.4, `and kept real speed through it (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`); ok(speedOf(s) < entry, 'a hard carve is not free'); } section('momentum resists an instant reversal'); { const s = createSkaterState(0, START); run(s, 6, forward); const entryX = s.vx; ok(entryX > 3, 'moving down the ice to begin with'); // A tenth of a second of "go back the other way" must not flip the velocity. run(s, 0.1, (st) => { st.ix = -1; st.iz = 0; }); ok(s.vx > 0, 'still travelling the original way a tenth of a second later'); ok(s.vx < entryX, 'but already losing speed to the edges'); } section('a turn on the spot costs nothing'); { const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 }); run(s, 0.6, (st) => { st.ix = 1; st.iz = 0; }); ok(Math.abs(s.yaw - Math.PI / 2) < 0.35, `a standing skater can pivot (yaw ${s.yaw.toFixed(2)})`); } section('turning is harder at speed than at rest'); { const slow = createSkaterState(0, { x: 0, z: 0, yaw: 0 }); run(slow, 0.3, (st) => { st.ix = 1; st.iz = 0; }); const fast = createSkaterState(1, { x: 0, z: 0, yaw: 0 }); run(fast, 6, (st) => { st.ix = 0; st.iz = 1; st.sprint = true; }); const before = fast.yaw; run(fast, 0.3, (st) => { st.ix = 1; st.iz = 0; st.sprint = true; }); ok( Math.abs(fast.yaw - before) < Math.abs(slow.yaw), `a flying skater turns slower than a standing one (${(fast.yaw - before).toFixed(3)} vs ${slow.yaw.toFixed(3)} rad)`, ); } section('nothing leaves the rink'); { // Point skaters at the boards from centre ice and hold it for ten seconds. for (let i = 0; i < 16; i++) { const a = (i / 16) * Math.PI * 2; const s = createSkaterState(i, { x: 0, z: 0, yaw: a }); run(s, 10, (st) => { st.ix = Math.sin(a); st.iz = Math.cos(a); st.sprint = true; }, { clampBoards: true }); ok(insideRink(s.x, s.z, SKATE.radius), `skater driving at heading ${a.toFixed(2)} stayed on the ice`); ok(Number.isFinite(s.x) && Number.isFinite(s.z), 'and its position stayed finite'); } } section('the sim is deterministic'); { const drive = (st, t) => { st.ix = Math.sin(t * 1.3); st.iz = Math.cos(t * 0.7); st.sprint = t > 3; }; const a = createSkaterState(0, { x: 4, z: -6, yaw: 1 }); const b = createSkaterState(0, { x: 4, z: -6, yaw: 1 }); run(a, 12, drive, { clampBoards: true }); run(b, 12, drive, { clampBoards: true }); near(a.x, b.x, 0, 'same inputs, same x'); near(a.z, b.z, 0, 'same inputs, same z'); near(a.yaw, b.yaw, 0, 'same inputs, same yaw'); } section('intent from a controller is clamped before the sim sees it'); { // This is the seam a gamepad or a network message will come in through, so // it has to survive garbage without the sim ever seeing it. const s = createSkaterState(0, START); applyIntent(s, { ix: 1, iz: 1 }); near(Math.hypot(s.ix, s.iz), 1, 1e-9, 'a diagonal stick is normalised, not sqrt(2) fast'); applyIntent(s, { ix: 0.3, iz: -0.4 }); near(s.ix, 0.3, 1e-9, 'a stick inside the deadzone circle is left alone (x)'); near(s.iz, -0.4, 1e-9, 'a stick inside the deadzone circle is left alone (z)'); applyIntent(s, { ix: 40, iz: -40 }); ok(Math.hypot(s.ix, s.iz) <= 1 + 1e-9, 'an out-of-range stick is clamped'); applyIntent(s, { ix: NaN, iz: undefined, sprint: 'yes', brake: 0 }); ok(s.ix === 0 && s.iz === 0, 'NaN and undefined become a centred stick'); ok(s.sprint === true && s.brake === false, 'and the flags come through as booleans'); // The clamped state must still step without producing garbage. stepSkater(s, DT); ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards'); } section('rink dimensions are the ones we think they are'); { near(RINK.halfX * 2, 60.96, 0.01, 'the rink is 200 feet long'); near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide'); } section('player definition carries shot side'); { ok(normalizeShotSide('left') === 'left', 'left stays left'); ok(normalizeShotSide('L') === 'left', 'L is left'); ok(normalizeShotSide(-1) === 'left', '-1 is left'); ok(normalizeShotSide('right') === 'right', 'right stays right'); ok(normalizeShotSide('R') === 'right', 'R is right'); ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)'); ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1'); ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split'); const packed = packPlayer({ shotSide: 'left' }); ok(packed.ss === 'L', 'pack uses a short wire form'); ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side'); ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side'); const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' }); const righty = createSkaterState(1, START, { shotSide: 'right' }); const plain = createSkaterState(2, START); ok(lefty.shotSide === 'left', 'state stores left shot'); ok(righty.shotSide === 'right', 'state stores right shot'); ok(plain.shotSide === 'right', 'state defaults to right shot'); } done('skaterSim');