/** * 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'; // SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-law pins at the bottom of this // file read the ladder. week.js is contracts-only underneath, so this stays // headless-safe. import { NIGHTS, nightAt } from '../week.js'; // SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL // (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a // site file this suite merely fetch()ed raw could rot invalid and stay green). // loadSite pulls world.js which pulls THREE — browser-only, same as the GLB // facts everywhere else in this repo; the node runner never imports this file. import { POOL, exposureOf } from '../board.js'; import { loadSite } from '../world.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)}`); }); // ---------------------------------------------------------------- wind lean (SPRINT13 gate 2.4) t.test('lean: calm leaves you upright, wind tips you, and it scales with the sustained speed', () => { const calm = new PlayerSim(); drive(calm, 20, {}, windX(TUNE.leanWindMin - 3)); // below the threshold, all storm assertLess(calm.lean, 1e-3, 'under leanWindMin you stand up straight'); const mid = new PlayerSim(); drive(mid, 40, {}, windX((TUNE.leanWindMin + TUNE.leanWindFull) / 2)); assert(mid.lean > 0.35 && mid.lean < 0.65, `mid wind is a partial lean, got ${mid.lean.toFixed(2)}`); const gale = new PlayerSim(); drive(gale, 40, {}, windX(TUNE.leanWindFull + 6)); assertClose(gale.lean, 1, 1e-2, 'past leanWindFull you are fully into it'); assert(gale.lean >= mid.lean, 'more sustained wind is more lean'); }); t.test('lean: keys on the SUSTAINED base, not the gust — a single gust does not snap you over', () => { const s = new PlayerSim(); drive(s, 30, {}, windX(4)); // learn a calm baseline: no lean const before = s.lean; drive(s, 1.2, {}, windX(TUNE.leanWindFull + 10), 30); // one big gust, shorter than leanSecs momentum // windBase barely moves in 1.2 s (baseTrack ~4 s memory), so the posture must not jump to full. assertLess(s.lean, 0.5, `a brief gust is a stumble, not a lean — got ${s.lean.toFixed(2)} from ${before.toFixed(2)}`); }); t.test('lean: it points downwind, and it is suppressed on your back, braced, or up a ladder', () => { // downwind: wind blowing +X should tip leanDir toward +X. const s = new PlayerSim(); drive(s, 30, {}, windX(TUNE.leanWindFull + 5)); assert(s.lean > 0.5, 'leaning'); assertClose(s.leanDir.x, 1, 1e-6, 'leanDir points the way the wind blows (x)'); assertClose(s.leanDir.z, 0, 1e-6, 'and not sideways (z)'); // braced: holding C must zero the lean — TakeCover is its own pose. const braced = new PlayerSim(); drive(braced, 30, { shelter: true }, windX(TUNE.leanWindFull + 5)); assertEq(braced.state, 'shelter', 'C braces'); assertLess(braced.lean, 1e-2, 'and a brace is not a lean'); // knocked down: pitch owns the body, lean eases out. const floored = new PlayerSim(); drive(floored, 30, {}, windX(TUNE.leanWindFull + 5)); assert(floored.lean > 0.5, 'leaning before the fall'); floored.knockdown(0, 1, 0); drive(floored, 1, {}, windX(TUNE.leanWindFull + 5)); assertLess(floored.lean, 1e-2, 'flat on your back is not a lean'); }); // ---------------------------------------------------------------- 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'); }); // ==================================== SPRINT16 gate 4 → SPRINT17 gate 0.1 // THE WEEK'S LAW, pinned next to its owner. Written as the SIX-night law in // S16; John ruled SEVEN (2026-07-21 — the wildnight restored, my own gate-6 // filing's alternative), and the pins moved WITH the ruling, which is them // working, not breaking: every rule below is slot-free, so restoring the // wildnight at night 5 changed no assert body — only this prose, plus one // NEW pin for the ruling itself (last in the block). a.test pins the // ladder's shape (which storm, which site, which slot); these pin the RULES // the ordering must obey, so a future re-order that keeps seven nights but // breaks the law goes red with the law's own words. Every threshold here is // ROADMAP principle 5 or a measured THREADS receipt, not taste. t.test('week law (S16, holds at seven): exactly one new site, exactly one new storm, never the same night', () => { const nights = NIGHTS.map((_, i) => nightAt(i)); const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block']; const PRE_S16_STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight', 'storm_03_southerly', 'storm_03b_earlybuster']; const newSiteNights = nights.filter((n) => !PRE_S16_SITES.includes(n.site)); const newStormNights = nights.filter((n) => !PRE_S16_STORMS.includes(n.storm)); assertEq(newSiteNights.length, 1, 'exactly ONE night is the new site'); assertEq(newSiteNights[0].site, 'site_03_swing_lawn'); assertEq(newStormNights.length, 1, 'exactly ONE night flies the new storm'); assertEq(newStormNights[0].storm, 'storm_06_soaker'); assert(newSiteNights[0] !== newStormNights[0], 'and they are NOT the same night — a player who loses must be able to name what beat them'); }); t.test('week law (S16, holds at seven): the new site debuts under a storm already survived', () => { const idx = NIGHTS.findIndex((_, i) => nightAt(i).site === 'site_03_swing_lawn'); assert(idx > 0, 'the swing lawn is in the rotation and is not night one'); const debut = nightAt(idx); const earlier = NIGHTS.slice(0, idx).map((_, i) => nightAt(i).storm); assert(earlier.includes(debut.storm), `night ${idx + 1} flies ${debut.storm}, which must have flown on an earlier night — ` + 'at the swing lawn\'s debut the yard is the ONLY new variable'); }); t.test("week law (S16, holds at seven): the soaker flies the corner block — C's measured pairing", () => { // THREADS [C] 2026-07-20: the backyard's buyable geometry caps hail cover // over the bed at ~31% — best membrane line there reads 37.5, a bet with // no win in it. The soaker over anything but site_02 needs a re-measure // FIRST; this pin is where a re-pairing finds that out. const soaker = NIGHTS.map((_, i) => nightAt(i)).find((n) => n.storm === 'storm_06_soaker'); assert(soaker, 'the soaker is in the week'); assertEq(soaker.site, 'site_02_corner_block', 'storm_06 over any other yard is unmeasured — fly gardenfly over the new pairing before moving this'); const debutIdx = NIGHTS.findIndex((_, i) => nightAt(i).site === soaker.site); const soakerIdx = NIGHTS.findIndex((_, i) => nightAt(i).storm === 'storm_06_soaker'); assert(debutIdx < soakerIdx, 'and the yard was taught on an earlier night — the storm is the only new variable'); }); t.test('week law (S16, holds at seven): the beyond-saving night is neither new nor final', () => { const flagged = NIGHTS.map((_, i) => nightAt(i)).filter((n) => n.gardenBeyondSaving); assertEq(flagged.length, 1, 'exactly one designed loss in the week'); assertEq(flagged[0].storm, 'storm_02b_icenight', 'and it is the ice night — the flag is a measured fact about'); assertEq(flagged[0].site, 'backyard_01', 'THIS yard under THIS storm (bare 0.0, best buyable 27.7, win at 50)'); const idx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving); assert(idx !== NIGHTS.length - 1, 'the designed loss must not be the final night: its dawn breaks need a morning to book warranty on ' + "(A filed the night-final gap in THREADS — the week's ordering closes it, at six AND at seven)"); }); t.test("SPRINT17 seven-night law: the wildnight is IN the week, over a yard the player knows", () => { // JOHN'S RULING (2026-07-21), pinned so a future re-order can't quietly // re-evict the marquee storm: gate 6 asked six-without-it or seven-with-it, // and the answer was seven. Three facts carry the ruling: const nights = NIGHTS.map((_, i) => nightAt(i)); const wildIdx = nights.findIndex((n) => n.storm === 'storm_02_wildnight'); assert(wildIdx >= 0, "the wildnight flies — its S16 eviction was RULED wrong (THREADS [I] 2026-07-21); " + 'removing it again is a John question, not a re-order'); // The one-variable law at its slot: the wildnight flies over a yard the // player has already rigged — it always did (the pinned separation lives // in backyard_01's own site data, so the storm is the only thing new). const wild = nights[wildIdx]; assertEq(wild.site, 'backyard_01', "the wildnight flies the backyard — the pinned separation is THAT yard's site data; " + 'flying it elsewhere needs the separation re-measured FIRST, like the soaker pin above'); assert(nights.slice(0, wildIdx).some((n) => n.site === wild.site), 'and the yard was rigged on an earlier night — at the marquee storm the yard must not also be new'); // The icenight's brief opens "Less wind than last night" — TRUE only while // the wildnight (peak 30.0) sits immediately before it (28.5). If this // ordering changes, that brief's first sentence moves in the same commit // (it already moved twice: S16 out, S17 back — the sentence follows the facts). const iceIdx = nights.findIndex((n) => n.storm === 'storm_02b_icenight'); assertEq(wildIdx, iceIdx - 1, "the wildnight sits directly before the icenight — the icenight's brief says 'less wind than " + "last night', and the paper never lies: re-order these and re-word that brief in the same commit"); }); // ── SPRINT17 gate 3.2: THE POOL YARD — authored cold, entered through the ── // board, pinned by its author. These pins carry the yard's three claims: // it is CHOSEN or absent (never scripted), its fence is fourteen honest noes // and its rust is one priced yes, and its refusal + wreck apron are facts // with numbers, not vibes. The site file itself goes through loadSite here, // so a hand-edit that breaks validateSite goes red in THIS suite too, not // only at boot. let poolSite = null, poolSiteErr = null; try { poolSite = await loadSite('site_04_pool_yard'); } catch (err) { poolSiteErr = String((err && err.stack) || err); } t.test('pool yard (S17 3.2): in the game ONLY through the board — chosen, never scripted', () => { const entry = POOL.find((p) => p.site === 'site_04_pool_yard'); assert(entry, 'the pool yard has a POOL entry — its one door into the game'); assertEq(entry.storm, 'storm_03_southerly', 'it flies the southerly — the storm nights 2 and 4 teach, so the yard is the only new variable; ' + 'any other pairing needs its own gauntlet run FIRST (the _why receipt describes this one)'); assert(entry._why && entry._why.includes('audit'), 'the entry carries its audit receipt — an unmeasured pairing is a night the board is guessing about'); const scripted = NIGHTS.map((_, i) => nightAt(i)); assert(!scripted.some((n) => n.site === 'site_04_pool_yard'), 'NO scripted night flies the pool yard — "the first yard whose only route in is being chosen" ' + 'is the arc-2 promise, and a week.js slot would quietly break it'); }); t.test('pool yard (S17 3.2): the ring says no fourteen times, the rust says yes once', () => { if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1'); assert(ring, 'the pool ring is in the yard — it IS the thesis'); assertEq((ring.anchors ?? []).length, 0, 'the ring adopts NOTHING in site data — its fourteen noes are baked tie_off:false in the GLB, ' + 'and a site-level anchor here would override a certified fence into steel'); assert(ring.collateralValue == null, 'the ring is unpriced BY RULING (the bike rule) — nothing can bend a pool fence yet'); const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1'); assert(rust, 'the corroded post stands'); assertEq((rust.anchors ?? []).length, 1, 'exactly one adoptable eye'); assertEq(rust.anchors[0].type, 'corroded_post', 'typed to E\'s tier so it rates 0.55, not 1.00'); assertEq(rust.collateralValue, 45, 'the make-safe bill, E\'s number, adopted with its reasoning'); assert(rust.wreckedModel === 'sail_post_corroded_wrecked_v1', 'the wreck variant is wired — a trap that vanishes instead of falling is a free failure'); const rusts = poolSite.structures.filter((s) => s.model === 'sail_post_corroded_v1'); assertEq(rusts.length, 1, 'ONE corroded post on purpose: two would share the baked collateral key and the second failure ' + 'would bill/wreck the wrong one (structFor matches first; filed in THREADS [D] S17)'); }); t.test('pool yard (S17 3.2): separation-JUDGED — refused with receipts, XOR the pin', () => { if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); const pinned = !!poolSite.separation, finding = !!poolSite._separation_finding; assert(pinned || finding, 'the yard is JUDGED — silence is not an option (S16 gate 2.3)'); assert(!(pinned && finding), 'both at once means the finding went stale in the pinning commit'); assert(finding, 'today it is a REFUSAL: bare wins the southerly (mild-night canon, A\'s 0.4 ruling)'); const text = poolSite._separation_finding.join(' '); assert(text.includes('83.7') && text.includes('92.7'), 'the refusal carries its measured receipts (bare 83.7 / best flown 92.7) — a finding without ' + 'numbers is a shrug wearing a ruling\'s clothes'); }); t.test('pool yard (S17 3.2): the wreck apron threads the needle between bed and ring', () => { if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); // E's placement facts (THREADS S17 gate 3.1): the corroded post falls +Z // ~3.9 m, shaft half-width ~0.26 m. The fallen shaft must land in the path // — clear of the bed's east edge AND the ring's west run — or the wreck // interpenetrates client property the moment the trap fires. const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1'); const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1'); const bedEast = poolSite.gardenBed.x + poolSite.gardenBed.w / 2; const shaftWest = rust.x - 0.26, shaftEast = rust.x + 0.26; const ringWest = ring.x - 2.8; // ring proper 5.6 wide (E's measured bbox) assert(bedEast <= shaftWest, `bed east edge ${bedEast} overlaps the fallen shaft (west ${shaftWest.toFixed(2)}) — move c1 or the bed`); assert(shaftEast <= ringWest, `fallen shaft east ${shaftEast.toFixed(2)} reaches the ring (west ${ringWest.toFixed(2)}) — move c1 or the pool`); const apronTip = rust.z + 3.9; assert(apronTip <= poolSite.yard.depth / 2, `the shaft falls past the south fence (tip z ${apronTip}) — it must land IN the yard, that is the point`); }); t.test('pool yard (S17 3.2): the offer card prices the rust and never the fence', () => { if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); const { total, items } = exposureOf(poolSite); assertEq(total, 160, 'exposure $160 = gutter 90 + corroded post 45 + gnome 25'); const labels = items.map((i) => i.what).sort().join(' · '); assertEq(labels, 'garden gnome · the corroded post · the gutter', 'three priced items exactly — the fourteen fence posts are deliberately NOT exposure: ' + 'nothing can bend a pool fence yet (the bike rule), and a $0 line item would teach "free"'); const rustItem = items.find((i) => i.what === 'the corroded post'); assertEq(rustItem.cost, 45, 'the rust is the cheapest structure you can break — the stake E sized the gamble in'); }); }