Files
tilt/test/skaterSim.mjs
2026-08-03 19:20:00 -05:00

382 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('LT does not spin the body around');
{
// Facing +X, stick pulled toward X under LT — should retreat heels-first
// without yaw flipping to face the stick.
const s = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
const startYaw = s.yaw;
run(s, 2.5, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const yawDrift = Math.abs(wrap(s.yaw - startYaw));
ok(yawDrift < 0.45, `facing holds under LT (yaw drift ${yawDrift.toFixed(2)} rad)`);
ok(s.bladeSpeed < -1.2, `stick back drives reverse (${s.bladeSpeed.toFixed(2)} m/s)`);
ok(s.x < -1.5, `and the body retreats (x ${s.x.toFixed(2)})`);
}
section('LT with stick forward is a crawl, not a rush');
{
const free = createSkaterState(0, START);
run(free, 5, forward);
const contain = createSkaterState(1, START);
run(contain, 5, (st) => {
forward(st);
st.backskate = true;
});
ok(
speedOf(free) > speedOf(contain) + 1.2,
`contain is much slower than free skate (${speedOf(contain).toFixed(2)} vs ${speedOf(free).toFixed(2)})`,
);
ok(speedOf(contain) <= SKATE.containForwardSpeed + 0.25, 'under the forward-contain ceiling');
ok(contain.bladeSpeed > 0, 'still facing the stick way, not flipped reverse');
}
section('LT reverse and lateral accelerate quickly for gap control');
{
// Reverse from a standstill under LT should get moving fast.
const back = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
run(back, 0.45, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
ok(back.bladeSpeed < -2.5, `quick reverse accel (${back.bladeSpeed.toFixed(2)} m/s in 0.45s)`);
ok(Math.abs(back.x) > 0.6, 'and covers ground retreating');
// Lateral shuffle under LT.
const side = createSkaterState(1, { x: 0, z: 0, yaw: Math.PI / 2 });
run(side, 0.45, (st) => {
st.ix = 0;
st.iz = 1; // body right when facing +X
st.backskate = true;
});
ok(Math.abs(side.z) > 0.7, `quick lateral under LT (z ${side.z.toFixed(2)})`);
ok(Math.abs(side.x) < Math.abs(side.z), 'mostly sideways relative to facing (+X)');
// Flip reverse → forward under LT (offense ↔ defense switch).
const flip = createSkaterState(2, { x: 0, z: 0, yaw: Math.PI / 2 });
run(flip, 0.8, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const rev = flip.bladeSpeed;
ok(rev < -2, 'was reverse');
run(flip, 0.35, (st) => {
st.ix = 1;
st.iz = 0;
st.backskate = true;
});
ok(flip.bladeSpeed > rev + 2.5, `snaps out of reverse toward forward (${flip.bladeSpeed.toFixed(2)} from ${rev.toFixed(2)})`);
}
section('LT stick neutral bleeds a rush toward neutral');
{
const s = createSkaterState(0, START);
run(s, 5, forward);
const entry = speedOf(s);
ok(entry > 3.5, 'at cruise');
// Hold LT, stick centred — sit in the gap instead of flying.
run(s, 1.2, (st) => {
st.ix = 0;
st.iz = 0;
st.backskate = true;
});
ok(speedOf(s) < entry * 0.55, `bled speed under LT (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
ok(speedOf(s) > 0.15, 'but not a hard snowplow to zero');
const yawStill = Math.abs(wrap(s.yaw - Math.PI / 2));
ok(yawStill < 0.3, 'and did not spin to face the other way');
}
section('applyIntent accepts backskate');
{
const s = createSkaterState(0, START);
applyIntent(s, { ix: 0, iz: 1, backskate: 1 });
ok(s.backskate === true, 'backskate flag is boolean true');
applyIntent(s, { ix: 0, iz: 1, backskate: 0 });
ok(s.backskate === false, 'and clears');
}
function wrap(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
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');