HardYards/web/world/js/tests/d.test.js
type-two d7658fb7af Lane D: ladder survives a mid-carry knockdown, and the prompt names the right bracket
SPRINT13 gate 2.6 pool, both found by the integrator's QA and both silent
(no error, no red, just a mechanic that stopped):

- The ladder left the game if the wind took you over while carrying it.
  knockdown() -> drop() nulls player.carrying but nothing told the ladder,
  so state.carried stayed true: the mesh hid (visible = !carried), pos()
  returned null so there was nothing to walk to, and canUse gated on
  !carried so it could never be picked up again. Swept the whole yard post-
  knockdown: no usable ladder offer anywhere, while ladder_place still said
  'it's by the shed'. On the corner block that silently ends the site's
  thesis (cb1/cb2 are bracket work). Fix: reconcile against the player's
  hands every frame in update() and let the ladder fall flat where it was
  lost, findable and re-takeable. Verified in the running game.

- The place label hardcoded 'the fascia' — a lie on a carport beam. Now
  keyed on anchor.type (a checked enum since Sprint 11): house -> the
  fascia, carport -> the carport bracket, unknown -> the bracket (vague but
  never false). And it stops claiming 'by the shed' once the ladder is
  dropped in the yard.

Two new asserts against the REAL createLadder, not the fake: the drop-
reconcile and the label. Mutation-checked — reverting the reconcile turns
both red on the right assertions. Selftest 337/0/0 (335 + 2).
2026-07-18 01:00:01 +10:00

1091 lines
55 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';
// The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements
// is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.)
import { needsLadder as realNeedsLadder, createLadder } from '../ladder.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', () => {
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }),
'a 4 m post does NOT — you tension it from a cleat at the base');
assert(!realNeedsLadder({ type: 'tree', pos: { y: 5.05 } }),
'nor a tree limb — that is a strop you throw');
});
// --- SPRINT13 pool: the real createLadder, not the fake. These two pin the things the QA pass
// found by playing, and both were silent — no error, no red, just a mechanic that stopped.
// (createLadder is scene-free with scene=null: the view is the only THREE it needs.)
const realLadderWorld = () => ({
anchors: [
{ id: 'h2', type: 'house', work: 'bracket', pos: { x: 0, y: 2.48, z: -9.95 } },
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -8.4, y: 2.29, z: -3 } },
],
shedTable: { pos: { x: 9, y: 0.9, z: 6 } },
heightAt: () => 0,
});
t.test('ladder: a knockdown mid-carry drops it in the grass — it does not leave the game', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const takeTarget = [...interact.targets.values()].find((x) => x.id === 'ladder_take');
// in your hands, walking it out to the bracket — through the target's OWN onDone, because
// player.pickUp alone fills your hands without telling the ladder, which is not a state the
// game can produce and would test the fixture rather than the code.
takeTarget.onDone(player, 0);
ladder.update(DT, 0, {});
assert(ladder.carried, 'carried');
assertEq(player.carrying, 'ladder', 'and the hands agree');
assertEq(takeTarget.pos(), null, 'nothing to walk to while it is in your hands');
// the wind takes you over at (4, 2). knockdown() -> drop() nulls carrying, and before SPRINT13
// nothing told the ladder: it stayed "carried" by nobody, invisible and un-takeable forever.
player.knockdown(0, 1, 0);
assertEq(player.carrying, null, 'the knockdown took it out of your hands');
ladder.update(DT, 0, {});
assert(!ladder.carried, 'the ladder knows it is no longer being carried');
const p = takeTarget.pos();
assert(p, 'and it is somewhere you can walk to');
assertClose(p.x, 4, 1e-6, 'it fell where you did (x)');
assertClose(p.z, 2, 1e-6, 'it fell where you did (z)');
assert(takeTarget.canUse({ carrying: null, climbY: 0 }),
'and you can pick it up again — this is the assert that fails if the reconcile is reverted');
});
t.test('ladder: the place prompt names the structure it is under, and where the ladder is', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const placeAt = (id) => [...interact.targets.values()].find((x) => x.id === `ladder_place_${id}`);
const empty = { carrying: null };
// QA pass: this said "the fascia" while you stood under a carport beam.
assert(/fascia/.test(interact.labelOf(placeAt('h2'), empty)), 'the house bracket IS the fascia');
assert(/carport/.test(interact.labelOf(placeAt('cb1'), empty)),
`a carport beam is not a fascia — got "${interact.labelOf(placeAt('cb1'), empty)}"`);
assert(!/fascia/.test(interact.labelOf(placeAt('cb1'), empty)),
'and it must not call it one');
// ...and it must not lie about where the ladder is once the wind has taken it.
assert(/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), 'stowed: it is by the shed');
[...interact.targets.values()].find((x) => x.id === 'ladder_take').onDone(player, 0);
ladder.update(DT, 0, {});
player.knockdown(0, 1, 0);
ladder.update(DT, 0, {});
assert(!/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)),
'dropped: the shed is exactly where it is NOT');
});
t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => {
// SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's
// canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently
// deletes the ladder mechanic. Fail-open is why this is data and not a type string.
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, work: 'bracket' }),
'declared bracket work needs the ladder, whatever the type says');
assert(!realNeedsLadder({ type: 'carport_post', pos: { y: 1.75 }, work: 'cloth' }),
'declared cloth work does not — you re-make it on the fallen sail');
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, work: 'cloth' }),
'and a site outranks the type in both directions (a low pergola off a deck)');
// Sprint-9's spelling still honoured, so anything already setting it keeps working
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, worksAtBracket: true }));
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false }));
// site_01 must be untouched — nothing there carries either field
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no field → site_01 behaviour');
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no field → post is still ground work');
assert(!realNeedsLadder(null) && !realNeedsLadder(undefined), 'and it never throws on a gap');
});
t.test('ladder: E\'s REAL carport resolves both ways round (site_02\'s trap)', () => {
// Read off carport_01_v1.glb rather than invented: E ships BOTH mechanisms on one structure,
// which is why a per-structure rule would still be wrong.
// beam_anchor_01/02 y=2.36 anchor_type "carport" rating_hint 0.22
// post_anchor_01/02 y=1.75 anchor_type "carport_post" rating_hint 0.30
// The beam is the double trap: E's worst rating in the game AND over the 2.20 m reach.
const beam = { id: 'c1', type: 'carport', pos: { y: 2.36 }, work: 'bracket', ratingHint: 0.22 };
const post = { id: 'c3', type: 'carport_post', pos: { y: 1.75 }, work: 'cloth', ratingHint: 0.30 };
assert(realNeedsLadder(beam), 'the beam bracket at 2.36 m is over a 1.72 m person\'s 2.20 m reach');
assert(!realNeedsLadder(post), 'the post fitting at 1.75 m is not — you can just reach it');
assert(beam.ratingHint < post.ratingHint, 'and the one needing the ladder is also the weaker lie');
// The regression that matters: WITHOUT the work field, E's types are not 'house', so the old
// rule said "no ladder" for a 2.36 m bracket and canReach waved it through from the grass.
assert(!realNeedsLadder({ type: 'carport', pos: { y: 2.36 } }),
'un-declared, it falls back to the type and reads as ground work — which is exactly why '
+ 'Lane A must emit `work` per anchor in the site JSON, and why this test exists');
});
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)
// Modelled on Lane B's FROZEN contract (contracts.js SailRig), which is not the shape I originally
// asked for and is better: one pondCentroid() rather than a ponds[] array (B's belly pools into a
// single heaviest place), and drainPondAt(node, dt, radius) draining PER FRAME and returning the kg
// shed by that call. The total is only knowable by accumulating over the hold.
// B measured: a 1.5 s poke sheds ~290 of ~310 kg, i.e. ~93% — this stub matches that rate.
const stubRig = (mass, at = { x: 0, y: 2.6, z: 0 }) => {
const s = { mass, node: 44 };
return {
pondMass: () => s.mass,
pondCentroid: () => (s.mass > 0 ? { ...at, mass: s.mass, node: s.node } : null),
drainPondAt(node, dt) {
if (node !== s.node || !(s.mass > 0)) return 0;
const shed = Math.min(s.mass, s.mass * (dt / 1.5) * 2.62); // ~93% over a 1.5 s hold
s.mass -= shed;
return shed;
},
};
};
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(0); // 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: the poke drains B\'s pond per-frame and the total lands on YOU', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig(300);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assert(!!b.targetPond(), 'standing under the belly, there is a pond to poke');
assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered');
assert(/300 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));
assertLess(rig.pondMass(), 40, 'nearly all of it came down (B measures ~93% over a 1.5 s poke)');
const doused = p.events.find((e) => e.type === 'doused');
assert(!!doused, 'and it went over the player');
assert(doused.kg > 250, `the accumulated per-frame drain is what lands, got ${doused.kg.toFixed(0)} kg`);
b.dispose();
});
t.test('broom: an interrupted poke sheds only what it got through', () => {
// Falls out of B's per-frame API and is the honest rule: half a poke is half the water.
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig(300);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
fixedLoop(0.5, DT, (dt, tt) => it.step(dt, tt, p, true)); // let go a third of the way in
it.step(DT, 1, p, false);
assert(rig.pondMass() > 100, `most of the water is still up there, got ${rig.pondMass().toFixed(0)} kg`);
assert(!p.events.some((e) => e.type === 'doused'), 'and an abandoned poke does not douse you');
b.dispose();
});
t.test('broom: the size of the pond decides the size of the joke', () => {
// CALIBRATED to B's measured masses: right-sized belly tops out ~450 kg, a 1.5 s poke sheds ~93%.
const outcome = (kg) => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig(kg); // ONE rig — `() => stubRig(kg)` would hand the broom a fresh
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); // full pond every frame
p.carrying = 'broom';
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
b.dispose();
return p.state;
};
assertEq(outcome(90), 'idle', 'caught it early: you just get wet');
assertEq(outcome(160), 'stagger', 'a pond you were warned about breaks your stride');
assertEq(outcome(300), 'knocked', 'a belly you ignored puts you on your back');
// Guard, and it is the StumbleBack lesson restated: a threshold is meaningless except against
// the data it fires on, and gate 1's balance pass moves pond masses. These three numbers are
// MEASURED on B's live sim (flat rig, storm_02, kg shed by one 1.5 s poke) — if a rebalance
// pushes a band outside the achievable range, this fails loudly instead of the band quietly
// ceasing to exist. Re-measure after gate 1 and update these, don't just widen the asserts.
const SHED_EARLY = 76, SHED_WARNED = 104, SHED_IGNORED = 190;
assert(BROOM_TUNE.staggerKg > SHED_EARLY,
`an early poke (~${SHED_EARLY} kg) must stay a splash, or every poke floors you`);
assert(BROOM_TUNE.staggerKg <= SHED_WARNED,
`answering the ticker (~${SHED_WARNED} kg) must reach the stagger band — that is the beat`);
assert(BROOM_TUNE.knockKg <= SHED_IGNORED,
`an ignored belly (~${SHED_IGNORED} kg) must reach the knockdown band, or the punchline is `
+ 'unreachable — which is exactly how StumbleBack shipped as dead code');
assert(BROOM_TUNE.knockKg > SHED_WARNED,
'but answering promptly must NOT floor you, or there is nothing left to escalate to');
});
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(300, { 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: a SAGGING belly is pokeable — it sags toward you as it loads', () => {
// Regression. Measured against Lane B's live ponding: a real 280 kg pond's centroid rides from
// y=1.2 down through y=-0.9 as the belly fills. Gating on `up >= 0` made the broom say "nothing
// pooling here" with a quarter-tonne hanging over the garden — a bug no stub would have shown,
// because I'd stubbed the pond politely at head height.
const mk = (y) => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig(280, { x: 0, y, z: 0 });
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
const r = !!b.targetPond();
b.dispose();
return r;
};
assert(mk(2.4), 'overhead: pokeable');
assert(mk(1.0), 'a real 188 kg pond rides here: pokeable');
assert(mk(0.1), 'sagging to your knees: still pokeable — this is the case that was broken');
assert(mk(-0.8), 'a real LIVE 281 kg belly rides here, rig still up: must be pokeable');
assert(!mk(-4.5), 'post-tear wreckage lies here: nothing coherent to poke');
});
t.test('broom: survives a rig with no ponding at all (and a pre-rig null centroid)', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const noPonding = {}; // e.g. an old rig, or none
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding);
p.carrying = 'broom';
assertEq(b.pond(), null, 'no centroid reported');
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws');
// and B's real null case: rigged sail, dry cloth
const dry = { pondMass: () => 0, pondCentroid: () => null, drainPondAt: () => 0 };
const b2 = createBroom(null, { heightAt: () => 0 }, new Interact(), p, () => dry);
assertEq(b2.targetPond(), null, 'a dry sail offers nothing to poke');
b2.dispose(); 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');
});
}