HardYards/web/world/js/tests/d.test.js
m3ultra d67126eacf Lane D: the broom, greyed prompts, and the label pass
The broom (SPRINT5 §Lane D-1, DESIGN.md's funniest correct mechanic): a third
carry type that queues behind the same hands, walk under the sail belly, hold-E
poke (Crank per E's anim_hint) → B's drainPondAt() → the water lands ON YOU,
sized to the joke: splash under 15 kg, stagger over 60, flat on your back over
120. Everything defers to E's baked metadata (carry_type, poke_tip.use on the
bristle end, anim_hint) — read, not invented. Duck-typed against the ponds[] /
drainPondAt seam I posted to B early, so it carries/walks/refuses-thin-air today
and lights up fully the moment B lands ponding.

Self-wires from createPlayer like the ladder; needs the live sail rig, which it
reads off interact.sailRig (published by wireYardActions, which main.js re-calls
through rigSail whenever attach() swaps the rig).

Greyed prompts (§Lane D-2, my offer, A's HUD hook): interact.visible() shows the
nearest UNAVAILABLE action with its reason when nothing's usable, instead of the
prompt vanishing — the confusion I logged last sprint. nearest() (what hold-E
acts on) is unchanged, so display and action stay separate. step() now reports
`usable` so the HUD can grey the radial. Fixed the ladder-place label to read as
a reason too ("the fascia needs the ladder — it's by the shed").

broom.js keeps no top-level THREE import (the vendored addons need index.html's
importmap) so d.test.js stays headless; the view loads via dynamic import that
only fires in a browser. 217/0/0 (was 207), 13 new asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 03:03:02 +10:00

930 lines
44 KiB
JavaScript

/**
* Lane D selftests — player state machine, weather effects, interactions. (PLAN3D §5-D.5)
*
* Everything asserted here is renderer-free: player.sim.js and interact.js import nothing, so this
* suite is pure logic driven at fixed dt, never rAF. (rAF being throttled in a hidden tab is not
* theoretical — this lane's dev harness froze mid-knockdown at stateT=0.333 until it was driven by
* a fixed loop, which is exactly the trap Lane A called out.)
*
* The rig half of player.js — bone binding, head-bone scale, clip retarget — can't be asserted
* headlessly; it needs a GL context and two GLB fetches. That is verified by hand in
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
* all six clips bound, Hips tracks correctly absent.
*/
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
import { Interact, wireYardActions } from '../interact.js';
import { createBroom, BROOM_TUNE } from '../broom.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
const DT = FIXED_DT;
/** Steady wind blowing +X at `speed` m/s. */
const windX = (speed) => ({ x: speed, y: 0, z: 0 });
/** Drive the sim for `secs` at fixed dt. */
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
/** Count what a real storm actually does to a person standing mid-yard. */
function stormProfile(wind, tune, secs = 90) {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 4 }, tune });
let stumbles = 0, knocks = 0, maxGust = 0, maxWind = 0, shovedFor = 0;
fixedLoop(secs, DT, (dt, t) => {
p.step(dt, t, {}, wind);
maxGust = Math.max(maxGust, p.gust);
maxWind = Math.max(maxWind, p.windSpeed);
if (Math.hypot(p.shove.x, p.shove.z) > 0.15) shovedFor += dt;
for (const e of p.events) if (e.type === 'state') {
if (e.state === 'stumble') stumbles++;
if (e.state === 'knocked') knocks++;
}
p.events.length = 0;
});
return { stumbles, knocks, maxGust, maxWind, shovedFor };
}
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
// The REAL storms, not a mock. Lane D's thresholds are meaningless except against the data they
// fire on, and this sprint's decision 8 has B+C changing the downdraft semantics underneath us —
// exactly the kind of edit that silently made StumbleBack unreachable the first time.
const wild = createWind(await loadStorm('storm_02_wildnight'));
const calm = createWind(await loadStorm('storm_01_gentle'));
// ---------------------------------------------------------------- state machine table
// The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS).
// Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly.
const PACK = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction',
'ClimbLadder', 'Crank', 'Dig', 'PickUp', 'Carry', 'CarryTurn', 'CarryIdle', 'StandUp',
'TakeCover', 'StumbleBack', 'PlantSeeds']);
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
for (const [name, st] of Object.entries(STATES)) {
assert(PACK.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
if (st.carryClip) assert(PACK.has(st.carryClip), `state ${name} wants missing ${st.carryClip}`);
}
});
t.test('clipFor: carrying swaps the locomotion set, an interaction names its own verb', () => {
const s = new PlayerSim();
assertEq(clipFor(s), 'Idle', 'empty-handed idle');
s.state = 'walk'; assertEq(clipFor(s), 'Walk');
s.carrying = 'spare';
assertEq(clipFor(s), 'Carry', 'carrying while walking');
s.state = 'run'; assertEq(clipFor(s), 'Carry', 'no CarryRun clip exists — Carry covers it');
s.state = 'idle'; assertEq(clipFor(s), 'CarryIdle', 'carrying while standing');
s.state = 'busy'; s.busyClip = 'Crank';
assertEq(clipFor(s), 'Crank', 'the verb wins over the carry set while busy');
s.busyClip = null;
assertEq(clipFor(s), 'Idle', 'busy with no named verb falls back to the table');
// locked states have no carry variant — you drop what you held anyway
s.carrying = 'spare'; s.state = 'knocked';
assertEq(clipFor(s), 'Falling', 'knockdown always plays Falling');
});
t.test('state table: no stuck states — every locked state drains to a free one', () => {
for (const [name, st] of Object.entries(STATES)) {
if (!st.locked) continue;
assert((st.next && st.secs > 0) || !!st.releasedBy,
`locked state ${name} has neither a timed exit nor an external releaser`);
if (st.next) assert(!!STATES[st.next], `state ${name}.next=${st.next} is not a state`);
}
for (const name of Object.keys(STATES)) {
let cur = name, hops = 0;
const seen = new Set();
while (STATES[cur].locked && STATES[cur].next && hops < 16 && !seen.has(cur)) {
seen.add(cur); cur = STATES[cur].next; hops++;
}
assert(!STATES[cur].locked || !!STATES[cur].releasedBy,
`state ${name} chains into a dead end at ${cur}`);
}
});
// ---------------------------------------------------------------- locomotion
t.test('locomotion: idle -> walk -> run -> idle at the tuned speeds', () => {
const sim = new PlayerSim();
assertEq(sim.state, 'idle', 'spawns idle');
assert(!sim.busy, 'idle is not busy');
drive(sim, 1.0, { x: 0, z: 1, camYaw: 0 });
assertEq(sim.state, 'walk', 'W walks');
assertClose(sim.speed, TUNE.walkSpeed, 0.05, 'walk speed');
drive(sim, 1.0, { x: 0, z: 1, run: true, camYaw: 0 });
assertEq(sim.state, 'run', 'shift runs');
assertClose(sim.speed, TUNE.runSpeed, 0.05, 'run speed');
drive(sim, 1.0, {});
assertEq(sim.state, 'idle', 'releasing input returns to idle');
assertLess(sim.speed, 0.15, 'and stops');
});
t.test('locomotion: movement is relative to camera.yaw', () => {
const a = new PlayerSim(); drive(a, 1.0, { x: 0, z: 1, camYaw: 0 });
assertLess(a.pos.z, -0.5, 'at yaw 0, W drives -Z (three.js camera-forward)');
assertClose(a.pos.x, 0, 1e-6, 'and not sideways');
const b = new PlayerSim(); drive(b, 1.0, { x: 0, z: 1, camYaw: Math.PI / 2 });
assertLess(b.pos.x, -0.5, 'at yaw 90 deg, the same key drives -X');
assertClose(b.pos.z, 0, 1e-6, 'and not forward');
assertClose(Math.abs(a.facing), Math.PI, 0.05, 'facing chases the movement direction');
});
// ---------------------------------------------------------------- weather on the player
t.test('weather: wind slows you, capped at TUNE.slowMax', () => {
const calm = new PlayerSim(); drive(calm, 2, { x: 0, z: 1, camYaw: 0 }, windX(0));
const blow = new PlayerSim(); drive(blow, 2, { x: 0, z: 1, camYaw: 0 }, windX(20));
assertLess(blow.speed, calm.speed, 'wind slows you');
// Isolate the slow curve from the knockdown — in a real 1e4 m/s you are flat on your back
// inside half a second (which is correct, and is what this assert used to accidentally measure).
const gale = new PlayerSim({ tune: { knockWind: Infinity } });
drive(gale, 2, { x: 0, z: 1, camYaw: 0 }, windX(1e4));
assertClose(gale.speed, TUNE.walkSpeed * (1 - TUNE.slowMax), 0.05,
'slow saturates at slowMax and never goes past it');
});
t.test('weather: steady wind reads as baseline, not as a gust', () => {
const s = new PlayerSim();
drive(s, 60, {}, windX(20));
assertLess(s.gust, 1.0, 'the EMA learns a constant 20 m/s as base');
assertLess(Math.hypot(s.shove.x, s.shove.z), 0.05, 'so it does not shove you forever');
});
t.test('weather: a gust over the baseline shoves you downwind, and scales with speed squared', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(6)); // learn a calm baseline
const x0 = s.pos.x;
drive(s, 1.6, {}, windX(22), 30); // 16 m/s over baseline -> past shoveGustMin
assert(s.pos.x - x0 > 0.05, `gust should shove downwind, dx=${(s.pos.x - x0).toFixed(3)}`);
const push = (ws) => {
const p = new PlayerSim();
drive(p, 30, {}, windX(2));
const p0 = p.pos.x;
drive(p, 1.0, {}, windX(ws), 30);
return p.pos.x - p0;
};
const p15 = push(15), p30 = push(30);
assert(p30 > p15 * 2.5,
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
});
// ---------------------------------------------------------------- knockdown
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
const brief = new PlayerSim();
drive(brief, 0.3, {}, windX(TUNE.knockWind + 5));
assert(brief.state !== 'knocked', 'a brief spike must not put you down');
const held = new PlayerSim();
held.carrying = 'spare';
drive(held, TUNE.knockSustain + 0.2, {}, windX(TUNE.knockWind + 5));
assertEq(held.state, 'knocked', 'sustained overload does');
assertEq(held.carrying, null, 'and drops what you were carrying');
assert(held.knockDir.x > 0.99, 'and you fall downwind');
});
t.test('knockdown: exposure bleeds off — flickering gusts never creep into one', () => {
const s = new PlayerSim();
fixedLoop(10, DT, (dt, t) => {
const on = Math.round(t / DT) % 40 < 10; // 0.17 s on, 0.5 s off
s.step(dt, t, {}, windX(on ? TUNE.knockWind + 5 : 5));
});
assert(s.state !== 'knocked',
`flickering gusts must not accumulate (exposure=${s.exposure.toFixed(3)})`);
});
t.test('knockdown: knocked -> getup -> idle, unaided, and upright again', () => {
const s = new PlayerSim();
s.knockdown(0);
assert(s.busy, 'knocked is locked');
drive(s, 0.5, { x: 0, z: 1, camYaw: 0 });
assertClose(s.pos.z, 0, 1e-9, 'knocked ignores movement input');
assert(s.pitch > 0.99, 'body goes flat');
drive(s, 1.1, {});
assertEq(s.state, 'getup', 'knocked drains to getup on its own');
drive(s, 1.4, {});
assertEq(s.state, 'idle', 'getup drains to idle on its own');
assertLess(s.pitch, 0.01, 'and the body is upright again');
assert(!s.busy, 'player is free');
});
// ---------------------------------------------------------------- shelter (hold C)
t.test('shelter: bracing survives a gust that floors you standing', () => {
const gust = TUNE.knockWind + 8; // over the standing bar, under the braced one
const standing = new PlayerSim();
drive(standing, TUNE.knockSustain + 0.3, {}, windX(gust));
assertEq(standing.state, 'knocked', 'standing, this gust floors you');
const braced = new PlayerSim();
drive(braced, TUNE.knockSustain + 0.3, { shelter: true }, windX(gust));
assertEq(braced.state, 'shelter', 'braced, the same gust does not');
assert(braced.busy, 'shelter is locked — you cannot walk while braced');
});
t.test('shelter: raises the bar, it does not remove it', () => {
const s = new PlayerSim();
drive(s, TUNE.knockSustain + 0.2, { shelter: true }, windX(TUNE.knockWind * TUNE.shelterKnockMult + 5));
assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not');
});
t.test('shelter: releasing the key always frees you — you are never stuck braced', () => {
// steady wind (already learned as baseline, so no apparent gust): straight back to idle
const s = new PlayerSim();
drive(s, 30, {}, windX(20));
drive(s, 1, { shelter: true }, windX(20));
assertEq(s.state, 'shelter', 'braced');
drive(s, 0.5, {}, windX(20));
assertEq(s.state, 'idle', 'let go in steady wind → idle');
assert(!s.busy, 'and free to move');
});
t.test('shelter: unbracing INTO the gust you were hiding from costs you your footing', () => {
// Emergent, and worth pinning: bracing is a commitment. Let go early and the gust takes you.
// This is what makes "wait for the lull" a decision rather than a formality.
const g = new PlayerSim();
drive(g, 30, {}, windX(4)); // learn a calm baseline
drive(g, 1, { shelter: true }, windX(4 + TUNE.stumbleGust + 6), 30);
assertEq(g.state, 'shelter', 'braced through the gust');
drive(g, 0.2, {}, windX(4 + TUNE.stumbleGust + 6), 31);
assert(g.state !== 'shelter', 'releasing always leaves shelter');
assertEq(g.state, 'stumble', 'and straight into the gust → you stumble');
});
t.test('shelter: cannot brace from your back', () => {
const s = new PlayerSim();
s.knockdown(0);
drive(s, 0.3, { shelter: true });
assertEq(s.state, 'knocked', 'holding C while down does not hijack the knockdown');
});
t.test('shelter: the wind barely moves you while braced', () => {
const push = (input) => {
const p = new PlayerSim();
drive(p, 30, input, windX(2)); // learn a calm baseline
const x0 = p.pos.x;
drive(p, 1.2, input, windX(24), 30);
return Math.abs(p.pos.x - x0);
};
assertLess(push({ shelter: true }), push({}) * 0.5, 'bracing must cut the shove hard');
});
// ---------------------------------------------------------------- stumble
t.test('stumble: a gust below the knockdown bar still breaks your stride', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(3)); // calm baseline
drive(s, 0.4, {}, windX(3 + TUNE.stumbleGust + 4), 30);
assertEq(s.state, 'stumble', 'gust over stumbleGust but under knockWind');
assert(s.busy, 'stumble is locked');
drive(s, 1.0, {}, windX(3), 31);
assertEq(s.state, 'idle', 'and it drains on its own');
});
t.test('stumble: one gust hold must not stumble you twice', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(3));
let stumbles = 0;
const before = s.events.length;
drive(s, 2.5, {}, windX(3 + TUNE.stumbleGust + 4), 30); // a full ~1.7 s hold and then some
for (const e of s.events.slice(before)) if (e.type === 'state' && e.state === 'stumble') stumbles++;
assertEq(stumbles, 1, 'stumbleCooldown makes it punctuation, not a stutter');
});
t.test('stumble: bracing means you keep your feet', () => {
const s = new PlayerSim();
drive(s, 30, { shelter: true }, windX(3));
drive(s, 0.5, { shelter: true }, windX(3 + TUNE.stumbleGust + 4), 30);
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
});
// ---------------------------------------------------------------- tuned against the REAL storms
// These are the guard rails the pre-merge tuning didn't have. Every threshold below is only
// meaningful relative to the storm JSON it fires on, so assert against the JSON.
t.test('storm_02: every player threshold is actually REACHABLE in the real storm', () => {
const p = stormProfile(wild, undefined);
assert(p.maxGust > TUNE.stumbleGust,
`StumbleBack is dead code: storm_02 gusts peak at ${p.maxGust.toFixed(1)} m/s over baseline, `
+ `but stumbleGust is ${TUNE.stumbleGust}. Lower it or ask Lane C for angrier gusts.`);
assert(p.maxGust > TUNE.shoveGustMin, `gust shove unreachable: peak ${p.maxGust.toFixed(1)}`);
assert(p.maxWind > TUNE.knockWind,
`knockdown unreachable: storm_02 peaks at ${p.maxWind.toFixed(1)} m/s, knockWind is ${TUNE.knockWind}`);
});
t.test('storm_02: reads as shoved → stumbling → floored, in that order of frequency', () => {
const p = stormProfile(wild, undefined);
assert(p.stumbles >= 2, `a wild night should break your stride more than twice, got ${p.stumbles}`);
assert(p.knocks >= 1, `and floor you at least once, got ${p.knocks}`);
assert(p.stumbles > p.knocks,
`stumble is the beat and knockdown the punchline — got ${p.stumbles} stumbles vs ${p.knocks} knocks`);
assert(p.shovedFor > 10, `you should feel pushed for a real slice of the storm, got ${p.shovedFor.toFixed(1)}s`);
assertLess(p.knocks, 8, `${p.knocks} knockdowns in 90 s is unplayable, not dramatic`);
});
t.test('storm_01: a calm day leaves you completely alone', () => {
const p = stormProfile(calm, undefined);
assertEq(p.stumbles, 0, 'no stumbles on a gentle day');
assertEq(p.knocks, 0, 'no knockdowns on a gentle day');
assertLess(p.shovedFor, 1, 'and essentially no shove');
});
t.test('storm_02: bracing is what makes the worst of it survivable', () => {
const exposed = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
const braced = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
let exposedKnocks = 0, bracedKnocks = 0;
fixedLoop(90, DT, (dt, tt) => {
exposed.step(dt, tt, {}, wild);
braced.step(dt, tt, { shelter: true }, wild);
for (const e of exposed.events) if (e.state === 'knocked') exposedKnocks++;
for (const e of braced.events) if (e.state === 'knocked') bracedKnocks++;
exposed.events.length = 0; braced.events.length = 0;
});
assert(exposedKnocks > 0, 'storm_02 floors you if you just stand in it');
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
});
// ---------------------------------------------------------------- the ladder (decision 12)
// A fake ladder standing in for ladder.js's THREE half: same shape, no GLB, no scene. The real
// one is verified by hand in the game; these pin the legs of the state machine.
const fakeLadder = (opts = {}) => {
const st = { placedAt: opts.placedAt || null, carried: false };
return {
needsLadder: (a) => !!a && a.type === 'house',
workY: () => 2.35,
// height only, mirroring the real ladder.js — see the note there on why testing for 'atTop'
// here makes the repair cancel its own hold
isWorking: (id) => st.placedAt === id && !!opts.player && opts.player.climbY > 2.2,
servedAnchor: () => null,
get placedAt() { return st.placedAt; },
_place: (id) => { st.placedAt = id; },
update() {}, dispose() {},
};
};
t.test('ladder: climb is code-driven and lands in a work stance', () => {
const s = new PlayerSim();
assertEq(s.climbY, 0, 'starts on the ground');
s.climbTo(2.35);
assertEq(s.state, 'climb', 'climbing');
assert(s.busy, 'you cannot be interrupted mid-rung');
assertEq(clipFor(s), 'ClimbLadder', 'and ClimbLadder plays');
drive(s, 0.5);
assert(s.climbY > 0.3 && s.climbY < 2.35, `partway up, got ${s.climbY.toFixed(2)}`);
drive(s, 3);
assertEq(s.state, 'atTop', 'arrives in the work stance');
assertClose(s.climbY, 2.35, 1e-6, 'at the top rung');
assert(!s.busy, 'atTop must NOT be busy — the whole point is that hold-E works up there');
});
t.test('ladder: the fascia is out of reach from the ground and in reach from the top', () => {
const s = new PlayerSim();
const FASCIA_Y = 2.48; // measured in the real yard
assertLess(s.reachY, FASCIA_Y, 'standing on the ground, a 1.72 m person cannot reach the bracket');
s.climbTo(2.35); drive(s, 4);
assert(s.reachY >= FASCIA_Y, `up the ladder they can, reach=${s.reachY.toFixed(2)}`);
});
t.test('ladder: you cannot walk while you are on it', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
assertEq(s.state, 'atTop');
const x0 = s.pos.x, z0 = s.pos.z;
drive(s, 1.5, { x: 1, z: 1, run: true, camYaw: 0 });
assertClose(s.pos.x, x0, 1e-9, 'WASD does not walk you off a ladder');
assertClose(s.pos.z, z0, 1e-9);
assertEq(s.state, 'atTop', 'and you stay in the work stance');
});
t.test('ladder: descending returns you to the ground and frees you', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
s.climbTo(0);
assertEq(s.state, 'climb', 'going down is the same clip');
drive(s, 4);
assertEq(s.state, 'idle', 'back on your feet');
assertEq(s.climbY, 0);
drive(s, 1, { x: 0, z: 1, camYaw: 0 });
assertEq(s.state, 'walk', 'and walking again');
});
t.test('ladder: you cannot brace up there — both hands are on the rungs', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
// a wind under even the ladder's lowered bar, so this isolates the brace refusal from the fall
drive(s, 1, { shelter: true }, windX(TUNE.knockWind * TUNE.ladderKnockMult - 4));
assertEq(s.state, 'atTop', 'holding C on a ladder does nothing');
});
t.test('ladder: the wind is meaner at height, and being blown off is a FALL', () => {
// a wind that is survivable standing must be able to take you off the ladder
const between = TUNE.knockWind * TUNE.ladderKnockMult + 2; // over the ladder bar, under the standing one
assertLess(between, TUNE.knockWind, 'the test wind must be survivable on the ground');
const ground = new PlayerSim();
drive(ground, TUNE.knockSustain + 0.5, {}, windX(between));
assertEq(ground.state, 'idle', 'on your feet this wind is nothing');
const up = new PlayerSim();
up.climbTo(2.35); drive(up, 4);
up.carrying = 'spare';
drive(up, TUNE.knockSustain + 0.3, {}, windX(between));
assertEq(up.state, 'knocked', 'the same wind takes you off the ladder');
assertEq(up.climbY, 0, 'you are on the ground now, not floating at height');
assertClose(up.fellFrom, 2.35, 1e-6, 'and the fall height is recorded');
assertEq(up.carrying, null, 'you dropped the spare on the way down');
drive(up, 3);
assertEq(up.state, 'idle', 'the ladder gets the normal get-up chain for free');
});
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
const L = fakeLadder();
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
'a 4 m post does NOT — you tension it from a cleat at the base');
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
'nor a tree limb — that is a strop you throw');
});
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const L = fakeLadder({ player: p });
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } },
{ id: 'p1', type: 'post', pos: { x: 0, y: 3.95, z: 0 } }];
const corners = [{ anchorId: 'h2', broken: true }, { anchorId: 'p1', broken: true }];
let repaired = [];
const it = new Interact();
wireYardActions(it, {
ladder: L,
world: { anchors },
sailRig: { corners, repair: (i) => repaired.push(i), trim: () => {},
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) },
});
p.carrying = 'spare';
// the POST corner: repairable from the ground, exactly as it was before the ladder existed
p.pos.x = 0; p.pos.z = 0;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired.includes(1), 'post corner still re-rigs from the ground — the ladder changed nothing here');
// the FASCIA corner: same spare, standing right under it, refused
repaired = []; p.carrying = 'spare'; p.pos.x = 0; p.pos.z = 0.9;
it.latched = false;
const near = it.nearest(p);
assert(!near || near.id !== 'rerig_0', 'standing under the bracket is not enough');
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!repaired.includes(0), 'fascia re-rig refused from the ground');
// plant the ladder and climb it → now it lands
L._place('h2');
p.climbTo(2.35); drive(p, 4);
assertEq(p.state, 'atTop');
p.carrying = 'spare';
it.latched = false;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired.includes(0), 'up the ladder, the fascia re-rig lands');
assertEq(p.carrying, null, 'and it ate the spare');
});
t.test('ladder: the scripted loop — carry, plant, fetch spare, climb, repair, descend', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const L = fakeLadder({ player: p });
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } }];
const corners = [{ anchorId: 'h2', broken: true }];
let repaired = false;
const it = new Interact();
wireYardActions(it, { ladder: L, world: { anchors },
sailRig: { corners, repair: () => { repaired = true; }, trim: () => {},
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) } });
// hands-full: the ladder and the spare compete for the same pair of hands
assertEq(p.pickUp('ladder'), true, 'pick the ladder up');
assertEq(p.pickUp('spare'), false, 'you cannot also carry a spare — that is the two-trip cost');
assertEq(p.drop(), 'ladder', 'put it down');
// trip 2: the spare, then up
assertEq(p.pickUp('spare'), true);
L._place('h2');
p.climbTo(L.workY()); drive(p, 4);
assertEq(p.state, 'atTop', 'up the ladder with the spare');
it.latched = false;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired, 'fascia repaired at height');
assertEq(p.carrying, null, 'spare consumed');
// and back down
p.climbTo(0); drive(p, 4);
assertEq(p.state, 'idle', 'down and free');
assertEq(p.climbY, 0);
});
// ---------------------------------------------------------------- the greyed-prompt surface
t.test('prompt: a usable action always wins over a reason', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 3, canUse: () => false,
label: 'hands full' });
it.register({ id: 'open', pos: { x: 0, y: 0, z: 1 }, radius: 3, label: 'take a spare' });
const v = it.visible(p);
assertEq(v.target.id, 'open', 'the thing you CAN do is the thing you are offered');
assert(v.usable, 'and it is offered, not explained');
});
t.test('prompt: an unavailable action explains itself instead of vanishing', () => {
// The bug this fixes, found by playing: walk to the shed table carrying the ladder and the
// prompt disappeared, because canUse filtered the target out of nearest() before its label
// could ever say "hands full". No prompt reads as a broken game; a greyed one reads as a rule.
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'spare_table', pos: { x: 0, y: 0, z: 0 }, radius: 3,
label: (pl) => (pl.carrying ? 'hands full' : 'take a spare'),
canUse: (pl) => !pl.carrying });
assertEq(it.visible(p).label, 'take a spare', 'empty-handed: an offer');
assert(it.visible(p).usable);
p.carrying = 'ladder';
const v = it.visible(p);
assert(!!v, 'carrying something, the prompt must NOT vanish');
assertEq(v.label, 'hands full', 'it says why');
assert(!v.usable, 'and is flagged unusable so the HUD can grey it');
assertEq(it.nearest(p), null, 'while nearest() — what E acts on — still correctly refuses it');
});
t.test('prompt: step() reports usable so the HUD can grey the radial', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 3, holdSecs: 1, label: 'do it',
canUse: (pl) => !pl.carrying });
p.carrying = 'broom';
let r = it.step(DT, 0, p, true);
assert(!r.usable, 'unusable');
assertEq(r.progress, 0, 'and no radial creeps up on an action that cannot run');
p.carrying = null;
r = it.step(DT, 0.1, p, true);
assert(r.usable, 'usable once the hands are free');
});
// ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1)
// Lane B's ponding isn't landed yet, so this stub is the seam I posted in THREADS:
// ponds -> [{node, mass, pos}], drainPondAt(node) -> kg dumped.
const stubRig = (ponds = []) => ({
ponds,
pondMass: () => ponds.reduce((s, p) => s + p.mass, 0),
drainPondAt(node) {
const p = ponds.find((x) => x.node === node);
if (!p) return 0;
const kg = p.mass;
p.mass = 0; // B's sail springs back on its own once the weight is gone
return kg;
},
});
t.test('broom: is a third carry type and queues behind the same hands', () => {
const p = new PlayerSim();
assertEq(p.pickUp('broom'), true, 'take the broom');
assertEq(p.pickUp('spare'), false, 'no spare as well');
assertEq(p.pickUp('ladder'), false, 'no ladder as well');
assertEq(clipFor(p), 'CarryIdle', 'and you read as carrying');
assertEq(p.drop(), 'broom');
});
t.test('broom: refuses to poke thin air, and refuses without the broom', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([]); // nothing pooling
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond(), null, 'no pond, nothing to poke');
assertEq(it.nearest(p), null, 'and no offer');
b.dispose();
});
t.test('broom: poke drains the pond Lane B reports, and the water lands on YOU', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 44, mass: 30, pos: { x: 0, y: 2.6, z: 0 } }]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
const pond = b.targetPond();
assert(!!pond, 'standing under the belly, there is a pond to poke');
assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered');
assert(/30 kg/.test(it.labelOf(it.nearest(p), p)), 'the prompt tells you how much is up there');
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assertEq(rig.pondMass(), 0, 'the pond is gone');
assert(p.events.some((e) => e.type === 'doused' && e.kg === 30), 'and it went over the player');
b.dispose();
});
t.test('broom: the size of the pond decides the size of the joke', () => {
const mk = (kg) => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 1, mass: kg, pos: { x: 0, y: 2.6, z: 0 } }]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
b.dispose();
return p.state;
};
assertEq(mk(5), 'idle', 'a splash just makes you wet');
assertEq(mk(BROOM_TUNE.staggerKg + 5), 'stagger', 'half a bathtub breaks your stride');
assertEq(mk(BROOM_TUNE.staggerKg * 2 + 5), 'knocked', 'a bathtub puts you on your back');
});
t.test('broom: picks the heaviest pond overhead, not the nearest', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([
{ node: 1, mass: 8, pos: { x: 0.2, y: 2.6, z: 0 } }, // nearer
{ node: 2, mass: 90, pos: { x: 1.4, y: 2.6, z: 0 } }, // heavier — the one breaking the rig
]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond().node, 2, 'if you are under two, you meant the one about to kill you');
b.dispose();
});
t.test('broom: a pond out of reach overhead cannot be poked from the grass', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 1, mass: 40, pos: { x: 0, y: 9, z: 0 } }]); // 9 m up
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9');
b.dispose();
});
t.test('broom: self-skips cleanly until Lane B lands drainPondAt', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const noPonding = { }; // a rig with no ponding at all
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding);
p.carrying = 'broom';
assertEq(b.ponds().length, 0, 'no ponds reported');
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws');
b.dispose();
});
// ---------------------------------------------------------------- solids collision
t.test('collision: solids stop you, and the pushout slides you along them', () => {
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
const wall = { x0: -8, x1: 8, z0: -16, z1: -10, y0: 0, y1: 3 };
const R = 0.3;
const collide = (x, z, feetY, headY) => {
if (wall.y1 <= feetY + 0.05 || wall.y0 >= headY) return { x, z };
const cx = Math.min(Math.max(x, wall.x0), wall.x1);
const cz = Math.min(Math.max(z, wall.z0), wall.z1);
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
const d = Math.sqrt(d2);
return { x: cx + dx / d * R, z: cz + dz / d * R };
};
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide });
drive(s, 6, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
assert(s.pos.z >= -10 - 1e-6, `must not enter the wall, z=${s.pos.z.toFixed(3)}`);
assertClose(s.pos.z, -10 + R, 0.02, 'stops exactly one body radius off the face');
// Diagonal into a LONG wall: blocked north, but must still slide east. The wall has to outrun
// the player here — against the 16 m one above, a 4 s diagonal sprint rounds its east end and
// gets past, which is correct behaviour and not what this assert is about.
const long = { ...wall, x0: -100, x1: 100 };
const collideLong = (x, z, feetY, headY) => {
if (long.y1 <= feetY + 0.05 || long.y0 >= headY) return { x, z };
const cx = Math.min(Math.max(x, long.x0), long.x1);
const cz = Math.min(Math.max(z, long.z0), long.z1);
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
const d = Math.sqrt(d2);
return { x: cx + dx / d * R, z: cz + dz / d * R };
};
const g = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide: collideLong });
drive(g, 4, { x: 1, z: 1, run: true, camYaw: 0 }, null);
assertClose(g.pos.z, -10 + R, 0.02, 'held off the wall');
assert(g.pos.x > 2, `pushout must preserve the tangential slide, x=${g.pos.x.toFixed(2)}`);
});
t.test('collision: you can walk under an overhang (eaves are above your head)', () => {
// the real roof: y 2.99..3.21, reaching 0.4 m further into the yard than the wall below it
const collide = (x, z, feetY, headY) => {
const y0 = 2.99, y1 = 3.21;
if (y1 <= feetY + 0.05 || y0 >= headY) return { x, z }; // filtered out for a 1.72 m body
return { x, z: Math.max(z, -9.6 + 0.3) }; // would wall you off if it applied
};
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide, height: 1.72 });
drive(s, 4, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
assert(s.pos.z < -9, `a 3 m eave must not block a 1.7 m person, z=${s.pos.z.toFixed(2)}`);
});
// ---------------------------------------------------------------- determinism (PLAN3D §4)
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
const trace = () => {
const s = new PlayerSim();
const out = [];
fixedLoop(50, DT, (dt, tt) => {
const w = { x: 6 + 24 * Math.max(0, Math.sin(tt * 0.7)), y: 0, z: 3 * Math.cos(tt * 0.31) };
s.step(dt, tt, { x: Math.sin(tt), z: Math.cos(tt * 0.5), run: tt % 4 < 2, camYaw: tt * 0.2 }, w);
out.push(`${s.state}|${s.pos.x.toFixed(9)}|${s.pos.z.toFixed(9)}|${s.pitch.toFixed(9)}`);
});
return out.join(';');
};
assertEq(trace(), trace(), 'same inputs must give the same trace');
});
// ---------------------------------------------------------------- interact
const mk = () => {
const sim = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
let done = 0;
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 1.5, holdSecs: 1, label: 'do it',
onDone: () => { done++; } });
return { sim, it, done: () => done };
};
t.test('interact: radius gates the action', () => {
const { sim, it, done } = mk();
sim.pos.x = 5;
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 0, 'out of radius never fires');
assert(!sim.busy, 'and never makes you busy');
});
t.test('interact: hold to completion fires once and hands the player back', () => {
const { sim, it, done } = mk();
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 1, 'fires once');
assert(!sim.busy && sim.state === 'idle', 'player released');
assert(it.events.some((e) => e.type === 'done' && e.id === 'x'), 'emits a done event');
});
t.test('interact: player is busy mid-hold, with a partial radial', () => {
const { sim, it } = mk();
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assert(sim.busy && sim.state === 'busy', 'busy while holding');
assert(it.progress > 0.4 && it.progress < 0.6, `radial partway, got ${it.progress.toFixed(2)}`);
});
t.test('interact: one press, one action — a held key must not re-arm itself', () => {
const { sim, it, done } = mk();
fixedLoop(6.7, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 1, 'holding E for 6.7 s over a 1 s action still fires once');
it.step(DT, 7, sim, false);
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, 7 + tt, sim, true));
assertEq(done(), 2, 'releasing re-arms it');
});
t.test('interact: releasing or walking away cancels the hold', () => {
const a = mk();
fixedLoop(30 * DT, DT, (dt, tt) => a.it.step(dt, tt, a.sim, true));
a.it.step(DT, 0.5, a.sim, false);
assertEq(a.it.progress, 0, 'release cancels');
assert(!a.sim.busy && a.sim.state === 'idle', 'and frees the player');
const b = mk();
fixedLoop(30 * DT, DT, (dt, tt) => b.it.step(dt, tt, b.sim, true));
b.sim.pos.x = 9;
b.it.step(DT, 0.5, b.sim, true);
assertEq(b.it.progress, 0, 'leaving the radius cancels');
assertEq(b.done(), 0, 'without firing');
});
t.test('interact: a knockdown mid-hold aborts without stomping the knocked state', () => {
const { sim, it, done } = mk();
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
sim.knockdown(0.5);
it.step(DT, 0.51, sim, true);
assertEq(it.progress, 0, 'hold aborted');
assertEq(done(), 0, 'action did not fire');
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
});
t.test('interact: a hold survives the busy transition it causes', () => {
// The bug this pins bit twice while building the ladder, and is invisible: canUse is re-checked
// every frame to keep the hold alive, and starting the hold sets state='busy' — so any canUse
// reading player.state goes false on frame one and cancels itself. Prompt looks dead, no error.
const sim = new PlayerSim();
const it = new Interact();
let fired = 0;
it.register({ id: 'ok', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => p.climbY < 0.02, onDone: () => { fired++; } }); // physical gate — fine
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 1, 'a physically-gated action completes');
const sim2 = new PlayerSim();
const it2 = new Interact();
let fired2 = 0;
it2.register({ id: 'trap', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => p.state === 'idle', onDone: () => { fired2++; } }); // state gate — the trap
fixedLoop(1, DT, (dt, tt) => it2.step(dt, tt, sim2, true));
assertEq(fired2, 0,
'a state-gated canUse cancels its own hold — documented on register(); gate on physical facts');
});
t.test('interact: canUse() gates on the carrying flag', () => {
const sim = new PlayerSim();
const it = new Interact();
let fired = 0;
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => !p.carrying, onDone: () => { fired++; } });
sim.carrying = 'spare';
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 0, 'hands full: gated');
assert(!sim.busy, 'and never went busy');
sim.carrying = null;
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 1, 'fires once the gate opens');
});
// ---------------------------------------------------------------- hands-full + wiring
t.test('hands-full rule: one item at a time', () => {
const sim = new PlayerSim();
assertEq(sim.pickUp('spare'), true, 'first item accepted');
assertEq(sim.pickUp('wrench'), false, 'second refused');
assertEq(sim.carrying, 'spare', 'still holding the first');
assertEq(sim.drop(), 'spare', 'drop returns it');
assertEq(sim.carrying, null, 'hands empty');
});
t.test('interact: the action names the verb the player plays', () => {
const sim = new PlayerSim();
const it = new Interact();
it.register({ id: 'crank', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 1, clip: 'Crank' });
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(sim.busyClip, 'Crank', 'busyClip is set for the hold');
assertEq(clipFor(sim), 'Crank', 'and that is what plays');
it.step(DT, 1, sim, false);
assertEq(sim.busyClip, null, 'cancelling clears the verb');
assertEq(clipFor(sim), 'Idle', 'back to the table');
});
t.test('interact: the verb is cleared on completion, so carry clips win afterwards', () => {
const sim = new PlayerSim();
const it = new Interact();
it.register({ id: 'take', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5, clip: 'PickUp',
onDone: (p, tt) => p.pickUp('spare', tt) });
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(sim.carrying, 'spare', 'picked it up');
assertEq(sim.busyClip, null, 'verb cleared');
assertEq(clipFor(sim), 'CarryIdle', 'and the player now reads as carrying');
});
t.test('wireYardActions: reads corners LIVE, so attach() cannot strand the targets', () => {
// Lane A's warning: attach() REPLACES the corners array. Capturing the corner object at wire
// time would leave these gated on a `broken` flag nothing updates ever again.
const rig = {
corners: [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 } }],
repair: () => {}, trim: () => {},
cornerPos: (i) => rig.corners[i].pos,
};
const it = new Interact();
wireYardActions(it, { sailRig: rig });
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
p.carrying = 'spare';
assertEq(it.nearest(p).id, 'rerig_0', 'broken corner offers a re-rig');
// now do what attach() does: swap the whole array for fresh objects
rig.corners = [{ anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } }];
assertEq(it.nearest(p).id, 'trim_0', 'the NEW corner is unbroken → trim, not re-rig');
rig.corners = [{ anchorId: 'p1', broken: true, pos: { x: 4, y: 2, z: 4 } }];
assertEq(it.nearest(p), null, 'and it followed the corner when it moved out of range');
});
t.test('wireYardActions: prompts track a moving (flogging) corner', () => {
const corner = { anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } };
const rig = { corners: [corner], repair: () => {}, trim: () => {}, cornerPos: (i) => rig.corners[i].pos };
const it = new Interact();
wireYardActions(it, { sailRig: rig });
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
assertEq(it.nearest(p).id, 'trim_0', 'in range at the start');
corner.pos = { x: 9, y: 2, z: 9 }; // the corner blows away
assertEq(it.nearest(p), null, 'prompt follows it out of range, not pinned to where it was');
});
t.test('wireYardActions: duck-types against a half-landed world', () => {
const empty = new Interact();
wireYardActions(empty, {});
assertEq(empty.targets.size, 0, 'no sailRig yet (Lane B) -> no actions, no crash');
const it = new Interact();
const corners = [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 }, load: 0 }];
let repaired = -1;
wireYardActions(it, {
sailRig: { corners, repair: (i) => { repaired = i; }, trim: () => {} },
world: { shedTable: { pos: { x: 10, y: 0, z: 0 } } },
});
assertEq(it.targets.size, 3, 're-rig + trim + spare table');
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assertEq(repaired, -1, 're-rig refuses without a spare');
p.carrying = 'spare';
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, 10 + tt, p, true));
assertEq(repaired, 0, 're-rig fires with a spare');
assertEq(p.carrying, null, 'and consumes it');
});
}