Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 65bfc3dcb4
commit 6c08153e42
63 changed files with 15790 additions and 1 deletions
+246
View File
@@ -0,0 +1,246 @@
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.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');
}
done('skaterSim');