diff --git a/THREADS.md b/THREADS.md index da7c55f..b5e3552 100644 --- a/THREADS.md +++ b/THREADS.md @@ -505,3 +505,67 @@ Format: `[lane letter] YYYY-MM-DD — note` a storm from a headless browser — which is what this sprint's "90 s storm_02 run captured" acceptance needs. Same code path the rAF loop uses; no test-only branch that can drift. Everything I reported above was measured through them. + +[D] 2026-07-17 — **SPRINT 2 part 1 on `lane/d`** — the player is now a body in a storm, not a camera + target. Selftest **35 Lane D asserts, 0 fail** (was 20). All verified in the real yard, not just + in asserts: + · **`world.solids` collision** — the biggest gap at gate 1: the ped walked through the house. + Now stops dead 0.30 m off the wall face (expected −9.70, measured −9.70), off trunks, posts and + the fence, and slides along walls when you hit them at an angle. Injected as `opts.collide` + the same way `groundAt` is, so player.sim.js stays zero-import and node-runnable. + · **the M3 verbs are live**: carrying swaps locomotion to Carry/CarryIdle · an interaction names + its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) via a new `clip` field on + the interact spec · `StumbleBack` on a gust that breaks your stride · **`TakeCover` (hold C)** + is now a real mechanic, not a pose — see below. + · Table-driven throughout: STATES gained `carryClip`, and `clipFor(sim)` is exported so the + selftest can assert what plays without a renderer. + +[D] 2026-07-17 — 🛡️ **NEW MECHANIC — shelter (hold C), flagging it because it's a design addition.** + SPRINT2 §Lane D.4 said "TakeCover as the storm shelter verb" and left the transition to me. It + brace-locks you: knockWind ×2.0 and shove ×0.25 while held. Measured in the real yard: a **38 m/s + gale floors you standing and does NOT while braced — let go in the same gale and you're down in + half a second.** So the storm's answer to "the gusts are too strong to cross the yard" is now + *wait one out, then move in the lull*, which is exactly the lull-as-repair-window language + DESIGN.md §Wind already uses. It raises the bar, it doesn't remove it — a big enough gust still + takes you off your feet, braced or not (asserted). You cannot brace from your back. + +[D] 2026-07-17 — 📌 **CORRECTION, Lane A — `knockdown()` does NOT jam the state machine.** Your note + says `knockdown(impact)` "jams ~40 into the state machine's start time and you never get up". + Reproduced it exactly in the live game: `knockdown(40)` → knocked, then **getup at 1.38 s, idle at + 2.68 s. You get up.** `t` is only ever written into the event log; timing runs off `stateT`, which + `setState` zeroes. The only real effect is cosmetic — polluted event timestamps. **Your wiring + (`knockdown(windT, piece.vx, piece.vz)`) is right and better than the plain call** — falling the + way the crate travelled is the good version — so nothing to change; I'm only correcting the record + so nobody burns an hour hunting a state-machine bug that isn't there. Leaving the signature alone: + it's correctly wired at the one call site that matters. + (`player.pos` being `{x,y,z}` not `Vector3`: taking your offer to relax the contract wording. Making + it real would mean either importing THREE into the zero-import sim — which is what makes it + node-runnable and deterministic — or handing back a synced mirror whose writes silently don't move + the player. Neither is worth a nominal type match.) + +[D] 2026-07-17 — ❗ **BLOCKED ON LANE A — `world.shedTable`, and it gates the sprint's "done".** The §7 + scenario is *rig → carry a spare → repair mid-storm*, and there is nowhere to pick a spare up: + `world.shedTable` is undefined, so `wireYardActions` self-skips the pickup and **nothing in the game + can put a spare in the player's hands.** E shipped `shed_01_v1.glb` AND `shed_table_v1.glb` and + they're on disk unused. All I need is world.js to place them and expose + `world.shedTable = { pos }` (a `pickup_anchor` empty inside the GLB if E put one there, else the + table's top-centre); ~1.5 m from the table's edge is reachable. Everything downstream of it is + already wired and asserted. Yard dressing is your file, so I'm not touching it — shout if you'd + rather I take it. + +[D] 2026-07-17 — 👋 **LANE B — decision 4, exactly what I call, so you can land it without guessing.** + I've hardened my side while waiting; `sail.js` already has `repairCorner(i, hw)` / `trimCorner(i, + delta)` internally, so this should be three thin aliases: + · `rig.repair(i)` — I gate on `corners[i].broken && carrying === 'spare'`, hold 2.5 s, then call + it and consume the spare. Pick the hw yourself (the spare is untyped on my side for now). + · `rig.trim(i, delta)` — I call `trim(i, +0.1)` after a 1.2 s hold. Plays `Crank`. + · `rig.cornerPos(i)` — **live world position, fresh vector.** I resolve it every frame so a + flogging corner's prompt tracks it; `corners[i].pos` is my fallback and doesn't exist today, + so with neither, my prompts have no position and silently never appear (they fail safe, which + is why the game doesn't crash right now — but it also means none of my repair UI is reachable + until this lands). + Also: your `attach()` replaces the corners array, per Lane A's warning. **My closures now read + `sailRig.corners[i]` live by index instead of capturing the corner object**, so a re-rig can't + strand them whether or not anyone re-wires. Asserted both ways (swap the array → the targets track + the new objects). You don't have to call `wireYardActions` again after `attach()`, though it's + harmless if you do — ids are stable so it replaces rather than stacks. diff --git a/web/world/js/interact.js b/web/world/js/interact.js index aca72cc..e14b97f 100644 --- a/web/world/js/interact.js +++ b/web/world/js/interact.js @@ -30,12 +30,14 @@ export class Interact { * @param {string|function} [spec.label] string, or (player)->string for live text * @param {function} [spec.canUse] (player) -> bool * @param {function} [spec.onDone] (player, t) -> void + * @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …). + * Must name a clip in player_anims.glb; omitted means the busy state's default Idle. * @returns {function} unregister */ register(spec) { if (!spec || !spec.id) throw new Error('interact.register: id required'); const target = { - radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec, + radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec, }; this.targets.set(target.id, target); return () => this.unregister(target.id); @@ -70,6 +72,7 @@ export class Interact { if (!this.active) return; // only hand the player back if they're still ours — a knockdown mid-hold already re-stated them if (player.state === 'busy') player.setState('idle', t); + player.busyClip = null; this.events.push({ type: 'cancel', id: this.active.id, t }); this.active = null; this.progress = 0; @@ -99,6 +102,7 @@ export class Interact { if (!this.active && holding && !this.latched && near && !player.busy) { this.active = near; this.progress = 0; + player.busyClip = near.clip || null; // the verb: Crank at a turnbuckle, PickUp at the table player.setState('busy', t); } @@ -110,6 +114,7 @@ export class Interact { this.progress = 0; this.latched = true; player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy + player.busyClip = null; // ...and after it, so the carry clips win on the next frame if (done.onDone) done.onDone(player, t); this.events.push({ type: 'done', id: done.id, t }); } @@ -129,38 +134,54 @@ export class Interact { * Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D * never edits Lane B's or Lane A's files — anything not yet landed is simply skipped. * + * Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's + * THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the + * sim no longer steps — the prompt would gate forever on a `broken` flag that can never change + * again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired. + * * @param {Interact} interact - * @param {object} deps {sailRig, world, spares} - * sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B) - * sailRig.repair(i) -> void [PROPOSED — see THREADS.md] - * sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md] - * world.shedTable -> {pos} (Lane A/E) + * @param {object} deps {sailRig, world} + * sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B) + * sailRig.repair(i) -> void (decision 4) + * sailRig.trim(i,d) -> void (decision 4) + * sailRig.cornerPos(i) -> Vector3 (decision 4 — live world position; a flogging corner moves) + * world.shedTable -> {pos} (Lane A — until it lands, the pickup self-skips) */ export function wireYardActions(interact, deps = {}) { const { sailRig, world } = deps; const wired = []; + const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null; + // a flogging corner is MOVING — resolve position every frame, never once at wire time + const posAt = (i) => () => { + const c = cornerAt(i); + if (!c) return null; + return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null; + }; if (sailRig && Array.isArray(sailRig.corners)) { - sailRig.corners.forEach((corner, i) => { + sailRig.corners.forEach((_corner, i) => { // re-rig a broken corner — costs the spare you're carrying wired.push(interact.register({ id: `rerig_${i}`, - pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), + pos: posAt(i), radius: 1.8, holdSecs: 2.5, label: 're-rig corner', - canUse: (p) => corner.broken && p.carrying === 'spare', - onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); }, + clip: 'Crank', + canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken) + && p.carrying === 'spare' && !!sailRig.repair, + onDone: (p) => { p.carrying = null; sailRig.repair(i); }, })); // per-corner turnbuckle trim — new vs the prototype; makes corners individual wired.push(interact.register({ id: `trim_${i}`, - pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), + pos: posAt(i), radius: 1.8, holdSecs: 1.2, label: 'tighten turnbuckle', - canUse: () => !corner.broken && !!sailRig.trim, - onDone: () => sailRig.trim && sailRig.trim(i, +0.1), + clip: 'Crank', + canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim, + onDone: () => sailRig.trim(i, +0.1), })); }); } @@ -172,6 +193,7 @@ export function wireYardActions(interact, deps = {}) { radius: 1.5, holdSecs: 0.6, label: (p) => (p.carrying ? 'hands full' : 'take a spare'), + clip: 'PickUp', canUse: (p) => !p.carrying, // hands-full rule onDone: (p, t) => p.pickUp('spare', t), })); diff --git a/web/world/js/player.js b/web/world/js/player.js index 64d08b4..e8f86ba 100644 --- a/web/world/js/player.js +++ b/web/world/js/player.js @@ -15,9 +15,9 @@ import * as THREE from '../vendor/three.module.js'; import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js'; import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js'; -import { PlayerSim, STATES, TUNE } from './player.sim.js'; +import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js'; -export { PlayerSim, STATES, TUNE }; +export { PlayerSim, STATES, TUNE, clipFor }; export const CHAR_URL = './models/player_01.glb'; export const ANIM_URL = './models/player_anims.glb'; @@ -56,6 +56,69 @@ const _loadGLTF = (loader, url) => new Promise((res, rej) => const UP = new THREE.Vector3(0, 1, 0); +const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +/** + * Build the player's collision test out of `world.solids` (contracts World). + * + * Shape of the problem, measured in the real yard rather than assumed: + * · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard — so one + * box per entry in solids is useless. We flatten to leaf meshes and box each one. + * · the house ROOF is a solid spanning y 2.99–3.21, i.e. entirely above a 1.72 m head. A flat + * footprint test would wall off the eaves, so every box is filtered by vertical overlap with + * the body and the roof simply drops out. + * Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned — no raycast per + * frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.) + * + * @param {object} world contracts World + * @param {object} [opts] {radius} metres, the player's shoulder radius + * @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}} + */ +export function makeSolidCollider(world, opts = {}) { + const radius = opts.radius ?? 0.3; + const boxes = []; + const b = new THREE.Box3(); + for (const root of (world && world.solids) || []) { + root.updateWorldMatrix(true, true); + root.traverse((o) => { + if (!o.isMesh) return; + b.setFromObject(o); + if (!isFinite(b.min.x)) return; + boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y }); + }); + } + const out = { x: 0, z: 0 }; // scratch — copied by the caller immediately, never retained + const r2 = radius * radius; + + return function collide(x, z, feetY, headY) { + out.x = x; out.z = z; + for (let i = 0; i < boxes.length; i++) { + const bx = boxes[i]; + if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue; // under the eaves / over a low wall + // closest point on the box to the body centre, in XZ + const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1); + const dx = out.x - cx, dz = out.z - cz; + const d2 = dx * dx + dz * dz; + if (d2 >= r2) continue; // clear + if (d2 > 1e-10) { // outside: push along the normal + const d = Math.sqrt(d2); + out.x = cx + (dx / d) * radius; + out.z = cz + (dz / d) * radius; + } else { + // centre is inside the box (spawned in a wall, or shoved through): eject through the nearest + // face rather than picking an arbitrary axis, so you pop out the side you came in. + const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z; + const m = Math.min(l, rr, u, dn); + if (m === l) out.x = bx.x0 - radius; + else if (m === rr) out.x = bx.x1 + radius; + else if (m === u) out.z = bx.z0 - radius; + else out.z = bx.z1 + radius; + } + } + return out; + }; +} + export class PlayerView { /** * @param {object} rig {scene, anims} — the character @@ -127,7 +190,8 @@ export class PlayerView { /** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */ sync(sim, dt) { const st = STATES[sim.state]; - this.play(st.clip, st.loop !== false); + // clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb + this.play(clipFor(sim), st.loop !== false); this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z); @@ -202,9 +266,13 @@ export async function loadPlayer(scene, opts = {}) { * @returns {Promise} satisfies checkContract('player', …) */ export async function createPlayer(scene, world, cameraRig, opts = {}) { + const height = opts.height || 1.72; const p = await loadPlayer(scene, { ...opts, + height, groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined, + // built AFTER the world exists so the boxes capture E's real GLBs, not the graybox + collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts), start: opts.start || { x: 0, y: 0, z: 6 }, }); const keyboard = new KeyboardInput(); @@ -257,7 +325,11 @@ export class KeyboardInput { const k = this.keys; const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0); const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0); - return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw }; + return { + x, z, camYaw, + run: k.has('ShiftLeft') || k.has('ShiftRight'), + shelter: k.has('KeyC'), // hold to brace — see STATES.shelter + }; } dispose() { diff --git a/web/world/js/player.sim.js b/web/world/js/player.sim.js index 91e18d4..7f6acd5 100644 --- a/web/world/js/player.sim.js +++ b/web/world/js/player.sim.js @@ -11,23 +11,39 @@ /** * The state machine, as a table (PLAN3D §5-D.5 asks for a table test). - * clip — clip name in player_anims.glb - * locked — movement input is ignored, and `player.busy` is true - * secs — timed states auto-advance to `next` after this long + * clip — clip name in player_anims.glb + * carryClip — clip to use instead when the player has something in their hands + * locked — movement input is ignored, and `player.busy` is true + * secs — timed states auto-advance to `next` after this long * Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own) - * or is released by an external actor. `busy` is the only externally-released state — interact.js - * both enters and leaves it, so a dropped release can't strand the player. + * or names an external releaser. `busy` and `shelter` are the released ones — interact.js and the + * shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you. */ export const STATES = { - idle: { clip: 'Idle', locked: false, loop: true }, - walk: { clip: 'Walk', locked: false, loop: true }, - run: { clip: 'Run', locked: false, loop: true }, + idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true }, + walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true }, + run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true }, busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' }, + shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' }, + stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' }, stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' }, knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' }, getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' }, }; +/** + * Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle), + * and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) — + * interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`. + * Kept here rather than in player.js so the selftest can assert it without a renderer. + */ +export function clipFor(sim) { + const st = STATES[sim.state]; + if (sim.state === 'busy' && sim.busyClip) return sim.busyClip; + if (sim.carrying && st.carryClip) return st.carryClip; + return st.clip; +} + /** * Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and * m/s per PLAN3D §1 ("port the behaviour, retune the constants"). @@ -61,6 +77,16 @@ export const TUNE = { knockSustain: 0.5, // s above knockWind before it happens knockBleed: 2, // exposure drains this many × faster than it fills pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch) + + // A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a + // storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back. + stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet) + stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice + + // Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the + // gusts are too strong to cross the yard" — wait one out, then move in the lull. + shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't + shelterShoveMult: 0.25, // and it barely pushes you }; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); @@ -78,6 +104,10 @@ export class PlayerSim { * @param {object} [opts] * @param {object} [opts.start] {x,y,z} spawn, metres * @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one. + * @param {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids. + * Injected, not imported, for the same reason as groundAt: this file must stay renderer-free. + * player.js#makeSolidCollider builds the real one out of world.solids. + * @param {number} [opts.height] body height, metres — the collider's vertical span * @param {object} [opts.tune] overrides for TUNE */ constructor(opts = {}) { @@ -90,6 +120,8 @@ export class PlayerSim { this.state = 'idle'; this.stateT = 0; this.carrying = null; // contract: player.carrying — one item, hands-full rule + this.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…) + this.stumbleCool = 0; // s until a gust may stumble you again this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD this.exposure = 0; // s spent above knockWind @@ -100,6 +132,8 @@ export class PlayerSim { this.knockDir = { x: 0, z: 1 }; // which way the body went down this.groundAt = opts.groundAt || (() => 0); + this.collide = opts.collide || null; + this.bodyHeight = opts.height || 1.72; this.tune = { ...TUNE, ...(opts.tune || {}) }; } @@ -168,6 +202,7 @@ export class PlayerSim { step(dt, t, input = {}, wind = null) { const T = this.tune; this.stateT += dt; + this.stumbleCool = Math.max(0, this.stumbleCool - dt); // --- local wind, and how much of it is gust --- let wx = 0, wz = 0; @@ -180,11 +215,29 @@ export class PlayerSim { this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1); this.gust = Math.max(0, ws - this.windBase); - // --- sustained extreme wind puts you down (same rule as a sail corner letting go) --- - if (ws > T.knockWind) this.exposure += dt; + // --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you + // even mid-gust. Refused while you're down — you can't brace from your back. --- + const wantShelter = !!input.shelter; + const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run'; + if (wantShelter && canShelter) this.setState('shelter', t); + else if (!wantShelter && this.state === 'shelter') this.setState('idle', t); + const braced = this.state === 'shelter'; + + // --- sustained extreme wind puts you down (same rule as a sail corner letting go). + // Bracing raises the bar rather than removing it: a big enough gust still wins. --- + const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind; + if (ws > knockAt) this.exposure += dt; else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed); if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz); + // --- a gust below the knockdown bar can still break your stride --- + if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0 + && (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) { + this.stumbleCool = T.stumbleCooldown; + this.setState('stumble', t); + this.vel.x = this.vel.z = 0; + } + const st = STATES[this.state]; // --- movement --- @@ -215,7 +268,7 @@ export class PlayerSim { // --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground --- const grounded = this.state === 'knocked' || this.state === 'getup'; if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) { - const a = T.shoveK * ws * ws; + const a = T.shoveK * ws * ws * (braced ? T.shelterShoveMult : 1); this.shove.x += (wx / ws) * a * dt; this.shove.z += (wz / ws) * a * dt; } @@ -226,6 +279,15 @@ export class PlayerSim { this.pos.z += (this.vel.z + this.shove.z) * dt; this.pos.y = this.groundAt(this.pos.x, this.pos.z); + // Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface, + // so walking into a wall at an angle keeps its tangential component and slides along it for free + // — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to + // hold you against a fence, and the pushout wins over it every frame anyway. + if (this.collide) { + const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight); + if (r) { this.pos.x = r.x; this.pos.z = r.z; } + } + // --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND, // which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. --- const wantPitch = this.state === 'knocked' ? 1 diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index 202a9e4..c4ccafb 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -11,7 +11,7 @@ * 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 { 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'; @@ -28,13 +28,36 @@ const drive = (sim, secs, input = {}, wind = null, t0 = 0) => /** @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', () => { - 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}`); + 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; @@ -163,6 +186,130 @@ export default function run(t) { 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 = () => { @@ -270,6 +417,61 @@ export default function run(t) { 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, {});