At gate 1 the ped walked straight through the house. Collision now stops it 0.30 m off the wall face (expected -9.70, measured -9.70), off trunks, posts and the fence, and slides along a wall hit at an angle. Injected as opts.collide the way groundAt already was, so player.sim.js stays zero-import and node-runnable. Two things the real yard taught, neither guessable from the plan: - `fence` is a GROUP of 37 child meshes whose combined box is the entire 30x20 m yard, so one box per solids entry is useless. Flattened to 43 leaf boxes. - the house ROOF spans y 2.99-3.21 and reaches 0.4 m FURTHER into the yard than the wall under it (eaves overhang). A flat footprint test would stop a 1.7 m person dead at an invisible eave, so every box is filtered by vertical overlap with the body and the roof drops out on its own. Boxes are built once (solids are static) and distance-pruned — no per-frame raycast, which is the thing Lane A measured as catastrophic on the terrain. The M3 pack is wired: carrying swaps locomotion to Carry/CarryIdle; an interaction names its own verb through a new `clip` field on the interact spec (Crank at a turnbuckle, PickUp at the shed table); StumbleBack fires on a gust that breaks your stride but can't floor you — below knockWind on purpose, so a storm reads as shoved → stumbling → floored rather than fine-fine-fine-flat. TakeCover (hold C) became a real mechanic rather than a pose: brace and knockWind x2.0, shove x0.25. A 38 m/s gale floors you standing and doesn't while braced; let go in the same gale and you're down in half a second. It raises the bar, it does not remove it — a big enough gust still wins, braced or not. So the storm's answer to "the gusts are too strong to cross the yard" is now wait one out and move in the lull, which is the repair-window language DESIGN.md already uses. wireYardActions now reads sailRig.corners[i] live by index instead of capturing the corner object — per Lane A's warning that attach() replaces the array, a captured corner is one the sim no longer steps and would gate forever on a `broken` flag that can never change again. 35 Lane D asserts, 0 fail (was 20). Carry/shelter/knockdown verified against the real 17-clip pack in the assembled game, not only in the harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
498 lines
24 KiB
JavaScript
498 lines
24 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 { 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
|
|
// 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, even mid-gust', () => {
|
|
const s = new PlayerSim();
|
|
drive(s, 1, { shelter: true }, windX(20));
|
|
assertEq(s.state, 'shelter', 'braced');
|
|
drive(s, 0.5, {}, windX(20)); // let go, wind still blowing
|
|
assert(!s.busy && s.state === 'idle', 'released');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- 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: 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');
|
|
});
|
|
}
|