/** * 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 } from '../player.sim.js'; import { Interact, wireYardActions } from '../interact.js'; import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.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)); /** @param {import('../testkit.js').Suite} t */ export default function run(t) { // ---------------------------------------------------------------- state machine table t.test('state table: every state\'s clip exists in player_anims.glb', () => { const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']); for (const [name, st] of Object.entries(STATES)) { assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`); } }); 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'); }); // ---------------------------------------------------------------- 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: 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('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'); }); }