From 0ceff91d5aaa02e24fd3b4c2df7638bae7efdc59 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 02:34:49 +1000 Subject: [PATCH] Lane D: the ladder sub-system (decision 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ladder.js. The whole mechanic is 200 mm: the fascia bracket sits at 2.48 m, a 1.72 m person's hands reach 2.20, and E's ladder tops out at 2.90. The asset and the yard were already built for each other; this is the verb between them. Carry-ladder is a second carry type, so the ladder and the spare compete for the same pair of hands and a fascia repair costs two trips while a post repair costs one — DESIGN.md's "limited hands" rule doing real work, and the reason the house is the expensive anchor to depend on (which E's ratingHint 0.35 / collateral "gutter" was already saying in the data). Climb height is code-driven with ClimbLadder playing on top — the knockdown precedent, since _rotOnly strips the root and a clip can no more lift the body than Falling could lay it down. You can't brace up there (both hands on the rungs), the wind's bar drops to 0.6x, and being blown off is a fall that feeds straight into the existing get-up chain. needsLadder is scoped to the fascia on purpose: a height test would have roped in the 3.95 m posts and 5.05 m limbs, made every repair a two-trip job, and silently invalidated the recorded §7 run — and it isn't true to rigging either. Landed with no change to main.js: createLadder self-wires from createPlayer, which Lane A already hands the scene, world and interact. Two bugs found by building on my own API, both now asserted: a canUse that reads player.state cancels its own hold (starting a hold sets busy) — it ate the climb AND the reach gate before I keyed both on physical height instead; and onLadder had to become height-based for the same reason. Documented on register(). Selftest 194/0/0 (was 184). Full loop driven by hand in the real game. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 51 +++++++++ web/world/js/interact.js | 37 +++++- web/world/js/ladder.js | 210 +++++++++++++++++++++++++++++++++++ web/world/js/player.js | 20 +++- web/world/js/player.sim.js | 92 +++++++++++++-- web/world/js/tests/d.test.js | 196 ++++++++++++++++++++++++++++++++ 6 files changed, 590 insertions(+), 16 deletions(-) create mode 100644 web/world/js/ladder.js diff --git a/THREADS.md b/THREADS.md index 1e8f450..a3b00c5 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1181,3 +1181,54 @@ Format: `[lane letter] YYYY-MM-DD — note` represents a whole night of story, so a storm delivers a night's water; that's the time-compression fiat B asked someone to own. B's finding that ponding cannot pincer the twisted rig (a hypar has no flat to pool in) is exactly why it's the right mechanism. + +[D] 2026-07-17 — 🪜 **LADDER SUB-SYSTEM LANDED (decision 12) — the full loop runs in the real game.** + New file `web/world/js/ladder.js` (Lane D). Driven by hand through SHADES.step, the prompt chain is: + `ladder_take` → carry → `ladder_place_h2` → (h2's carabiner blows) → `spare_table` → + `ladder_climb` → **`rerig_0`, which only exists at height** → `oooo`, spare consumed. + Selftest **194/0/0** (was 184); 11 of the new asserts are the ladder's, including the scripted + climb-repair-descend the sprint asked for. + **It needed no change to main.js.** createLadder self-wires from createPlayer, which Lane A already + hands the scene, world and interact — so a whole sub-system landed inside Lane D's own files. The + reach gate finds it via `interact.ladder` (Interact is Lane D's class), and an explicit + `deps.ladder` still wins for tests. + Numbers that made the design: **fascia sits at 2.48 m, a 1.72 m person reaches 2.20 m, E's ladder + tops out at 2.90 m.** Two hundred millimetres is the entire mechanic — the asset and the yard were + already built for each other, I just wrote the verb between them. + +[D] 2026-07-17 — 📐 **Scoped `needsLadder` to the FASCIA, deliberately — flagging the judgement call.** + A pure "is it above head height?" rule would have caught the 3.95 m posts and the 5.05 m tree limbs + and turned *every* repair in the game into a two-trip ladder job — which would have silently + invalidated the recorded §7 run and Lane B's gate asserts, and isn't true to rigging anyway: a sail + post is tensioned from a cleat at its base and a tree anchor is a strop you throw. A bracket bolted + 2.5 m up a bare wall is the one you cannot fake. So `needsLadder = anchor.type === 'house'`, one + line, where it can be found and argued with. This also means **the house is now the expensive anchor + to depend on** — which is what E's `ratingHint 0.35` / `collateral "gutter"` was already saying in + the data. Shout if you wanted it broader. + +[D] 2026-07-17 — 🐛 **A FOOTGUN IN MY OWN API, found twice while building on it, now documented + asserted.** + `interact.register({canUse})` is re-checked EVERY FRAME to keep a hold alive — and starting a hold + moves the player into `busy`. So any `canUse` that reads `player.state` goes false on frame one and + **the action silently cancels its own hold.** No error; the prompt just looks dead. It ate the + ladder's climb (`canUse: p => p.state === 'idle'`) and the fascia reach gate (`isWorking()` testing + `state === 'atTop'`) — I lost time to it both times. Fixed by gating on PHYSICAL facts (carrying, + position, climbY), which is also the honest test. Now written on `register()`'s JSDoc where you'll + read it, and pinned by an assert that registers a deliberately state-gated action and proves it + never fires. **B/C/E: if you ever register an interact target, gate on facts, not on state.** + +[D] 2026-07-17 — 💬 **FEEL NOTES from playing it (A's HUD/prep not landed yet, so these are from the + parts that exist). I'm the lane that plays it, so:** + 1. **No prompt is worse than a refusal.** Carry the ladder to the shed table and the "take a + spare" prompt doesn't say "hands full" — it *vanishes*, because `canUse` filters the target out + of `nearest()` before the label can explain itself. It reads as a broken game, not a full pair + of hands. **Lane A, this is a HUD-shaped problem:** the prompt wants to show unusable actions + greyed out with their reason, and my `label(player)` already returns "hands full" / "out of + reach — needs the ladder" for exactly this. Say the word and I'll surface unusable targets. + 2. **The two-trip fascia repair costs ~15 s of running** (shed→ladder→wall→shed→wall) out of a 90 s + storm. It is *supposed* to hurt, and it does — but that's a sixth of the storm on foot, and + until the HUD shows corner loads you can't tell whether you're spending it well. Worth a look + once the HUD lands; I'd rather tune it against a player who can see, than guess now. + 3. **A ladder standing bolt upright reads as a post, not a ladder** — I had it vertical at first + and genuinely couldn't tell what I was looking at until I saw its shadow. It now leans 15° into + the wall. Small thing; large difference. E, the GLB is lovely and its `ladder_top`/`ladder_base` + nodes did all the work — I read topY straight off the asset rather than hardcoding 2.9. diff --git a/web/world/js/interact.js b/web/world/js/interact.js index e14b97f..5c797ca 100644 --- a/web/world/js/interact.js +++ b/web/world/js/interact.js @@ -28,7 +28,13 @@ export class Interact { * @param {number} [spec.radius] metres * @param {number} [spec.holdSecs] * @param {string|function} [spec.label] string, or (player)->string for live text - * @param {function} [spec.canUse] (player) -> bool + * @param {function} [spec.canUse] (player) -> bool. + * **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive, + * and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes + * false on frame one and the action silently cancels its own hold. It looks exactly like a dead + * prompt. Gate on physical facts instead (carrying, position, height); locked states already + * can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's + * climb and the fascia reach gate. * @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. @@ -149,14 +155,35 @@ export class Interact { */ export function wireYardActions(interact, deps = {}) { const { sailRig, world } = deps; + // createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a + // ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one). + const ladder = deps.ladder || interact.ladder || null; const wired = []; const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null; + const anchorOf = (i) => { + const c = cornerAt(i); + if (!c) return null; + return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || 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; + // A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the + // sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting + // 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it. + if (ladder && ladder.needsLadder(anchorOf(i))) { + const a = anchorOf(i); + return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 }; + } return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null; }; + /** Fascia work needs you up the ladder that's planted under THAT bracket. */ + const canReach = (i, p) => { + const a = anchorOf(i); + if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work + return ladder.isWorking(a.id) && p.reachY >= a.pos.y; + }; if (sailRig && Array.isArray(sailRig.corners)) { sailRig.corners.forEach((_corner, i) => { @@ -166,10 +193,10 @@ export function wireYardActions(interact, deps = {}) { pos: posAt(i), radius: 1.8, holdSecs: 2.5, - label: 're-rig corner', + label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'), clip: 'Crank', canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken) - && p.carrying === 'spare' && !!sailRig.repair, + && p.carrying === 'spare' && !!sailRig.repair && canReach(i, p), onDone: (p) => { p.carrying = null; sailRig.repair(i); }, })); // per-corner turnbuckle trim — new vs the prototype; makes corners individual @@ -178,9 +205,9 @@ export function wireYardActions(interact, deps = {}) { pos: posAt(i), radius: 1.8, holdSecs: 1.2, - label: 'tighten turnbuckle', + label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'), clip: 'Crank', - canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim, + canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p), onDone: () => sailRig.trim(i, +0.1), })); }); diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js new file mode 100644 index 0000000..3860498 --- /dev/null +++ b/web/world/js/ladder.js @@ -0,0 +1,210 @@ +/** + * ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12) + * + * Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20. + * Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the + * ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw — + * but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly + * 2.9 m. The asset and the yard were built for each other; this file is the verb between them. + * + * The loop it creates is the "limited hands" rule from DESIGN.md doing real work: + * ladder and spare are BOTH carry items, and you can only hold one. + * So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare — + * while a post repair costs one. The house is the expensive anchor to depend on, which is + * exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data. + * + * Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs + * no change to get this — it already hands createPlayer the scene, world and interact. + */ +import * as THREE from '../vendor/three.module.js'; +import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js'; + +export const LADDER_URL = './models/ladder_01_v1.glb'; + +/** + * Which anchors you cannot rig from the ground. + * + * Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule + * would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into + * a two-trip ladder job — which is both untrue to how sails are actually rigged and would have + * silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to + * the fascia; this is that scope, in one line, where it can be found and argued with. + */ +export const needsLadder = (anchor) => !!anchor && anchor.type === 'house'; + +/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */ +const STAND_OFF = 0.9; // m clear of the wall face +const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder +const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height + +/** + * @param {THREE.Object3D} scene + * @param {object} world contracts World (must be dressed — anchors are final only after dress()) + * @param {object} interact Lane D's Interact + * @param {object} player the PlayerSim + * @returns {object} the ladder system + */ +export function createLadder(scene, world, interact, player) { + const anchors = (world.anchors || []).filter(needsLadder); + if (!anchors.length) { + // no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it + return { placedAt: null, carried: false, needsLadder, isWorking: () => false, + workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} }; + } + + const state = { + carried: false, // in the player's hands + placedAt: null, // anchor id, or null while stowed/carried + base: new THREE.Vector3(), + topY: 2.9, // overwritten from the GLB's ladder_top node + view: null, + }; + + // Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable + // so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two + // prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of + // thing that reads as a broken game rather than a crowded shed. + const home = new THREE.Vector3(10.4, 0, 3.4); + if (world.shedTable && world.shedTable.pos) { + home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6); + } + home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0; + state.base.copy(home); + + // --- view ----------------------------------------------------------------- + new GLTFLoader().load(LADDER_URL, (g) => { + const obj = g.scene; + const top = obj.getObjectByName('ladder_top'); + if (top) state.topY = top.position.y; + obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } }); + state.view = obj; + scene.add(obj); + syncView(); + }, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ }); + + const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder + function syncView() { + if (!state.view) return; + state.view.visible = !state.carried; + state.view.position.copy(state.base); + const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt); + if (a) { + // planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the + // top toward local +Z, which is the wall — so the feet stand off and the head rests on it. + state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0); + } else { + state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed + } + } + + /** The fascia anchor this ladder is currently serving, if any. */ + const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null); + + /** Standing height at the top of the ladder — feet a rung or two down from the very top. */ + const workY = () => Math.max(0, state.topY - RUNG_CLEAR); + + /** + * True if the player is up THIS ladder and can work the given anchor. + * Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop' + * here would make a fascia repair un-usable the moment it started and cancel its own hold. + */ + function isWorking(anchorId) { + return state.placedAt === anchorId && player.climbY > workY() - 0.15; + } + + // --- interactions --------------------------------------------------------- + const wired = []; + + // 1. pick the ladder up (from its home, or from wherever it's planted) + wired.push(interact.register({ + id: 'ladder_take', + pos: () => (state.carried ? null : state.base), + radius: 1.6, + holdSecs: 0.8, + clip: 'PickUp', + label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'), + canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02, + onDone: (p, t) => { + state.carried = true; + state.placedAt = null; + p.pickUp('ladder', t); + syncView(); + }, + })); + + // 2. plant it at a fascia anchor + for (const a of anchors) { + wired.push(interact.register({ + id: `ladder_place_${a.id}`, + // stand off the wall, on the yard side — the ladder leans in toward the bracket + pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }), + radius: PLACE_RANGE, + holdSecs: 1.0, + clip: 'PickUp', + label: `set the ladder under ${a.id}`, + canUse: (p) => p.carrying === 'ladder', + onDone: (p, t) => { + state.carried = false; + state.placedAt = a.id; + state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0, + a.pos.z + STAND_OFF); + p.carrying = null; + p.events.push({ type: 'ladderPlaced', anchorId: a.id, t }); + syncView(); + }, + })); + } + + // 3. climb it — only when it's planted, and only from the ground + wired.push(interact.register({ + id: 'ladder_climb', + pos: () => (state.placedAt && !state.carried ? state.base : null), + radius: 1.5, + holdSecs: 0.4, + clip: 'PickUp', + label: 'climb', + // NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that + // reads state goes false the instant the hold begins and cancels itself. climbY is the honest + // gate (are you on the ground?), and locked states can't start a hold anyway. + canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02, + onDone: (p, t) => { + p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs + const a = servedAnchor(); + if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z); + p.climbTo(workY(), t); + }, + })); + + const api = { + get placedAt() { return state.placedAt; }, + get carried() { return state.carried; }, + get base() { return state.base; }, + get topY() { return state.topY; }, + workY, + isWorking, + servedAnchor, + needsLadder, + + /** + * Drive descent from input. Held S climbs down; nothing else can strand you up there, and a + * knockdown already drops climbY to 0 on its own. + * player.js calls this each frame from the same input it reads for movement. + */ + update(dt, t, input) { + if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t); + syncView(); + }, + + dispose() { + wired.forEach((un) => un()); + if (interact.ladder === api) interact.ladder = null; + if (state.view) scene.remove(state.view); + }, + }; + + // Publish onto the Interact instance so wireYardActions can find the reach gate without main.js + // (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own + // class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one. + interact.ladder = api; + return api; +} diff --git a/web/world/js/player.js b/web/world/js/player.js index e8f86ba..3efcbe6 100644 --- a/web/world/js/player.js +++ b/web/world/js/player.js @@ -15,9 +15,10 @@ 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, clipFor } from './player.sim.js'; +import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js'; +import { createLadder } from './ladder.js'; -export { PlayerSim, STATES, TUNE, clipFor }; +export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder }; export const CHAR_URL = './models/player_01.glb'; export const ANIM_URL = './models/player_anims.glb'; @@ -193,7 +194,10 @@ export class PlayerView { // 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); + // climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips + // the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip + // just supplies the arms and legs. + this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z); // yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the // feet. At pitch 0 this is exactly the yaw, so upright play is untouched. @@ -278,6 +282,12 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { const keyboard = new KeyboardInput(); const { sim, view } = p; + // The ladder self-wires from here rather than from main.js: createPlayer is already handed the + // scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to + // change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it. + const ladder = (opts.ladder === false || !opts.interact) + ? null : createLadder(scene, world, opts.interact, sim); + return { get pos() { return sim.pos; }, get carrying() { return sim.carrying; }, @@ -288,6 +298,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { update(dt, t) { const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0); sim.step(dt, t, input, opts.wind); + if (ladder) ladder.update(dt, t, input); if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding); view.sync(sim, dt); }, @@ -296,8 +307,9 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { get object() { return view.root; }, sim, view, + ladder, keyboard, - dispose() { keyboard.dispose(); view.dispose(); }, + dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); }, }; } diff --git a/web/world/js/player.sim.js b/web/world/js/player.sim.js index d5ce189..5b98558 100644 --- a/web/world/js/player.sim.js +++ b/web/world/js/player.sim.js @@ -29,8 +29,26 @@ export const STATES = { 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' }, + + // --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the + // knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than + // Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. --- + climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' }, + // atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up + // there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`. + atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' }, }; +/** + * True while the player is on a ladder — movement is off, and the wind is meaner. + * + * Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`, + * so a state-based test would say you'd stepped off the ladder the instant you started the repair + * you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the + * physical truth and survives every state the ladder passes through. + */ +export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb'; + /** * 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) — @@ -96,6 +114,13 @@ export const TUNE = { // 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 + + // Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is + // where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only + // defence is choosing your moment: climb in a lull, not through a gust. + climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote + reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48. + ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off }; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); @@ -140,6 +165,10 @@ export class PlayerSim { this.pitch = 0; // 0 upright … 1 flat on the ground this.knockDir = { x: 0, z: 1 }; // which way the body went down + this.climbY = 0; // m above the ground; >0 means you're up a ladder + this.climbTarget = 0; // where ladder.js asked you to be + this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with + this.groundAt = opts.groundAt || (() => 0); this.collide = opts.collide || null; this.bodyHeight = opts.height || 1.72; @@ -151,6 +180,23 @@ export class PlayerSim { get clip() { return STATES[this.state].clip; } get speed() { return Math.hypot(this.vel.x, this.vel.z); } + /** How high this person's hands get right now. The gate on reaching a fascia bracket. */ + get reachY() { return this.pos.y + this.climbY + this.tune.reach; } + + /** + * Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so + * a climb is deterministic and fast-forwards in selftest like everything else. + * @param {number} y target height above ground; 0 climbs back down + */ + climbTo(y, t = 0) { + this.climbTarget = Math.max(0, y); + if (Math.abs(this.climbTarget - this.climbY) > 0.02) { + this.vel.x = this.vel.z = 0; + this.setState('climb', t); + } + return true; + } + setState(s, t = 0) { if (this.state === s) return false; if (!STATES[s]) throw new Error(`player: unknown state ${s}`); @@ -194,11 +240,16 @@ export class PlayerSim { if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); } const m = Math.hypot(x, z) || 1; this.knockDir = { x: x / m, z: z / m }; + // Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the + // same, so the ladder gets the get-up chain for free — you just arrive on the ground first. + this.fellFrom = this.climbY; + this.climbY = 0; + this.climbTarget = 0; this.setState('knocked', t); this.exposure = 0; this.vel.x = this.vel.z = 0; this.drop(t); - this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } }); + this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom }); return true; } @@ -225,7 +276,9 @@ export class PlayerSim { this.gust = Math.max(0, ws - this.windBase); // --- 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. --- + // even mid-gust. Refused while you're down — you can't brace from your back, and refused on + // a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. --- + const up = onLadder(this); const wantShelter = !!input.shelter; const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run'; if (wantShelter && canShelter) this.setState('shelter', t); @@ -233,12 +286,34 @@ export class PlayerSim { 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; + // Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm + // speaks one language whether you're on your feet or up a rung. --- + const knockAt = T.knockWind + * (braced ? T.shelterKnockMult : 1) + * (up ? T.ladderKnockMult : 1); 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); + // --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) --- + if (this.state === 'climb') { + const dy = this.climbTarget - this.climbY; + const rung = T.climbRate * dt; + if (Math.abs(dy) <= rung) { + this.climbY = this.climbTarget; + this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t); + } else { + this.climbY += Math.sign(dy) * rung; + } + } + // Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js + // releases a finished hold to 'idle' without knowing where you are; this puts you back in the + // work stance instead of leaving you idling in mid-air. + if (this.climbY > 0.02 + && (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) { + this.setState('atTop', t); + } + // --- 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')) { @@ -248,11 +323,12 @@ export class PlayerSim { } const st = STATES[this.state]; + const aloft = onLadder(this); // re-read: the climb block above may have just landed you // --- movement --- const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you let wantX = 0, wantZ = 0; - if (!st.locked) { + if (!st.locked && !aloft) { const ix = input.x || 0, iz = input.z || 0; const mag = Math.hypot(ix, iz); if (mag > 1e-3) { @@ -305,8 +381,10 @@ export class PlayerSim { const pstep = dt / T.pitchSecs; this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1); - // --- locomotion state from actual speed (so shove/slow can't desync the feet) --- - if (!st.locked) { + // --- locomotion state from actual speed (so shove/slow can't desync the feet). + // `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without + // this guard the speed check would immediately re-state you to idle and drop you off. --- + if (!st.locked && !aloft) { const sp = this.speed; this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t); } else if (st.secs && this.stateT >= st.secs && st.next) { diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index 2f63f44..a5a7301 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -343,6 +343,180 @@ export default async function run(t) { 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', () => { + const L = fakeLadder(); + assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it'); + assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }), + 'a 4 m post does NOT — you tension it from a cleat at the base'); + assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }), + 'nor a tree limb — that is a strop you throw'); + }); + + 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); + }); + // ---------------------------------------------------------------- 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 @@ -476,6 +650,28 @@ export default async function run(t) { 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();