diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js index 1929d42..63cc45b 100644 --- a/web/world/js/ladder.js +++ b/web/world/js/ladder.js @@ -69,7 +69,22 @@ export const needsLadder = (anchor) => { return anchor.type === 'house'; // site_01's shape }; -/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */ +/** + * What to call the thing the ladder serves, in the player's own words. + * + * SPRINT13 pool (the integrator's QA pass): the place label hardcoded "the fascia" — true on + * backyard_01, a lie on the corner block, where the bracket is a carport beam. Keyed on `type`, + * which has been a CHECKED enum since Sprint 11 (`validateSite` rejects an unknown one), so a new + * site can only arrive here with a type somebody typed on purpose. + * + * The fallback names the MECHANISM, not a guessed structure: an unmapped bracket reads "the bracket + * needs the ladder", which is vague but never false. Same instinct as `needsLadder` itself — the + * thing that broke here was a rule keyed on site_01's spelling of the idea instead of the idea. + */ +const BRACKET_NOUN = { house: 'the fascia', carport: 'the carport bracket' }; +const bracketNoun = (a) => BRACKET_NOUN[a?.type] ?? 'the bracket'; + +/** Where the player stands to work a bracket 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 @@ -95,6 +110,8 @@ export function createLadder(scene, world, interact, player) { base: new THREE.Vector3(), topY: 2.9, // overwritten from the GLB's ladder_top node view: null, + dropped: false, // lying in the grass where it was lost (see the reconcile in update) + dropYaw: 0, }; // Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable @@ -134,6 +151,11 @@ export function createLadder(scene, world, interact, player) { // 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 if (state.dropped) { + // Dropped, not stowed: it lies FLAT where it fell. The stowed pose is a slouch against the + // shed wall, and a slouch in open grass reads as a ladder leaning on thin air — which looks + // like a bug at exactly the moment the player is hunting for the thing. + state.view.rotation.set(Math.PI / 2, state.dropYaw, 0); } else { state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed } @@ -169,6 +191,7 @@ export function createLadder(scene, world, interact, player) { onDone: (p, t) => { state.carried = true; state.placedAt = null; + state.dropped = false; p.pickUp('ladder', t); syncView(); }, @@ -185,15 +208,19 @@ export function createLadder(scene, world, interact, player) { clip: 'PickUp', // Reads as an offer when you can, and as a reason when you can't — interact.visible() now // shows unusable targets greyed, and a label written only as an offer explains nothing there. - // Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the - // single most useful sentence in the game. + // Standing under a blown bracket holding a spare, "the fascia needs the ladder" is the single + // most useful sentence in the game — so it has to name the RIGHT structure, or it's the single + // most confusing one. It said "fascia" in the corner block until SPRINT13. + // ...and it must not lie about WHERE. "it's by the shed" is true until the wind takes it out + // of your hands, and then it's the game sending you to the wrong end of the yard mid-storm. label: (p) => (p.carrying === 'ladder' - ? `set the ladder under ${a.id}` - : 'the fascia needs the ladder — it\'s by the shed'), + ? `set the ladder under ${a.id.toUpperCase()}` + : `${bracketNoun(a)} needs the ladder — ${state.dropped ? 'you dropped it out there' : "it's by the shed"}`), canUse: (p) => p.carrying === 'ladder', onDone: (p, t) => { state.carried = false; state.placedAt = a.id; + state.dropped = false; 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; @@ -239,6 +266,29 @@ export function createLadder(scene, world, interact, player) { * player.js calls this each frame from the same input it reads for movement. */ update(dt, t, input) { + // SPRINT13 pool — "the dropped spare's whereabouts are unknown" is the small half of this. + // The player can lose the ladder without telling us: knockdown() calls drop() (player.sim.js), + // which nulls `carrying` and pushes an event nothing here listens for. state.carried stayed + // true forever, and every consequence of that is silent: + // · syncView hides the mesh (`visible = !carried`) → the ladder is invisible + // · ladder_take.pos() returns null while carried → nothing to walk to + // · ladder_take.canUse needs !state.carried → it can never be picked up again + // Blown over mid-carry and the ladder left the game, while ladder_place went on saying "it's + // by the shed". It wasn't. On the corner block that silently ends the site's whole thesis: + // cb1/cb2 are bracket work, and bracket work needs a ladder that no longer exists. + // The player's hands are the one source of truth, so reconcile against them every frame and + // let the ladder FALL where it was lost — the same instinct as STATES.shelter, which enters + // and leaves itself so a dropped release can't strand you. Fetching it is now a cost you can + // see and walk to, which is what the mechanic wanted from the knockdown in the first place. + if (state.carried && player.carrying !== 'ladder') { + state.carried = false; + state.placedAt = null; + state.dropped = true; + state.dropYaw = player.facing ?? 0; + state.base.set(player.pos.x, + world.heightAt ? world.heightAt(player.pos.x, player.pos.z) : 0, + player.pos.z); + } if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t); syncView(); }, diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index b38a1f2..084604d 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -16,7 +16,7 @@ import { Interact, wireYardActions } from '../interact.js'; import { createBroom, BROOM_TUNE } from '../broom.js'; // The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements // is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.) -import { needsLadder as realNeedsLadder } from '../ladder.js'; +import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js'; import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; @@ -448,6 +448,74 @@ export default async function run(t) { 'nor a tree limb — that is a strop you throw'); }); + // --- SPRINT13 pool: the real createLadder, not the fake. These two pin the things the QA pass + // found by playing, and both were silent — no error, no red, just a mechanic that stopped. + // (createLadder is scene-free with scene=null: the view is the only THREE it needs.) + const realLadderWorld = () => ({ + anchors: [ + { id: 'h2', type: 'house', work: 'bracket', pos: { x: 0, y: 2.48, z: -9.95 } }, + { id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -8.4, y: 2.29, z: -3 } }, + ], + shedTable: { pos: { x: 9, y: 0.9, z: 6 } }, + heightAt: () => 0, + }); + + t.test('ladder: a knockdown mid-carry drops it in the grass — it does not leave the game', () => { + const world = realLadderWorld(); + const interact = new Interact(); + const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } }); + const ladder = createLadder(null, world, interact, player); + const takeTarget = [...interact.targets.values()].find((x) => x.id === 'ladder_take'); + + // in your hands, walking it out to the bracket — through the target's OWN onDone, because + // player.pickUp alone fills your hands without telling the ladder, which is not a state the + // game can produce and would test the fixture rather than the code. + takeTarget.onDone(player, 0); + ladder.update(DT, 0, {}); + assert(ladder.carried, 'carried'); + assertEq(player.carrying, 'ladder', 'and the hands agree'); + assertEq(takeTarget.pos(), null, 'nothing to walk to while it is in your hands'); + + // the wind takes you over at (4, 2). knockdown() -> drop() nulls carrying, and before SPRINT13 + // nothing told the ladder: it stayed "carried" by nobody, invisible and un-takeable forever. + player.knockdown(0, 1, 0); + assertEq(player.carrying, null, 'the knockdown took it out of your hands'); + ladder.update(DT, 0, {}); + + assert(!ladder.carried, 'the ladder knows it is no longer being carried'); + const p = takeTarget.pos(); + assert(p, 'and it is somewhere you can walk to'); + assertClose(p.x, 4, 1e-6, 'it fell where you did (x)'); + assertClose(p.z, 2, 1e-6, 'it fell where you did (z)'); + assert(takeTarget.canUse({ carrying: null, climbY: 0 }), + 'and you can pick it up again — this is the assert that fails if the reconcile is reverted'); + }); + + t.test('ladder: the place prompt names the structure it is under, and where the ladder is', () => { + const world = realLadderWorld(); + const interact = new Interact(); + const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } }); + const ladder = createLadder(null, world, interact, player); + const placeAt = (id) => [...interact.targets.values()].find((x) => x.id === `ladder_place_${id}`); + const empty = { carrying: null }; + + // QA pass: this said "the fascia" while you stood under a carport beam. + assert(/fascia/.test(interact.labelOf(placeAt('h2'), empty)), 'the house bracket IS the fascia'); + assert(/carport/.test(interact.labelOf(placeAt('cb1'), empty)), + `a carport beam is not a fascia — got "${interact.labelOf(placeAt('cb1'), empty)}"`); + assert(!/fascia/.test(interact.labelOf(placeAt('cb1'), empty)), + 'and it must not call it one'); + + // ...and it must not lie about where the ladder is once the wind has taken it. + assert(/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), 'stowed: it is by the shed'); + [...interact.targets.values()].find((x) => x.id === 'ladder_take').onDone(player, 0); + ladder.update(DT, 0, {}); + player.knockdown(0, 1, 0); + ladder.update(DT, 0, {}); + assert(!/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), + 'dropped: the shed is exactly where it is NOT'); + }); + t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => { // SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's // canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently