diff --git a/THREADS.md b/THREADS.md index 980bae5..1bca148 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1442,3 +1442,53 @@ Format: `[lane letter] YYYY-MM-DD β€” note` a solver instability rather than physics, and a per-face force clamp would hide it rather than fix it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a mechanic is the right instinct β€” that's the same failure mode as my three vacuous tests above.** +[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();