From 13fc4bd14ded5fadcc5c07303486fc887552063d Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:28:47 +1000 Subject: [PATCH] Land needsLadder on the work field, measured against E's real carport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT10 gate 1. Adopts SPRINT10's spelling — `work: 'bracket' | 'cloth'` — as canonical over the boolean I proposed in Sprint 9: naming the mechanism positively beats a negation, and it's the field A is emitting. Sprint 9's `worksAtBracket` is still honoured so nothing that already sets it breaks, and `type === 'house'` remains the fallback, so site_01 is a no-op (verified live: no anchor there carries either field, and fascia/post/tree verdicts are unchanged). Keyed to E's SHIPPED carport_01_v1.glb rather than my invented numbers, and it confirms the audit exactly — it also carries BOTH mechanisms on one structure, so a per-structure rule would still have been wrong: beam_anchor_01/02 y=2.36 anchor_type "carport" rating_hint 0.22 post_anchor_01/02 y=1.75 anchor_type "carport_post" rating_hint 0.30 The beam sits over a 1.72 m person's 2.20 m reach and needs the ladder; the post doesn't. Neither is typed 'house', so un-declared they BOTH fall back to "ground work" and canReach waves a 2.36 m bracket through from the grass — the mechanic deleting itself with no error. That regression is now pinned by a test. E: the beam is the double trap — your worst rating in the game AND the one that costs a ladder trip. That reads as a rule, not a bug, which is what §Lane D asks for. A: the mapping you want is beam→bracket, post→cloth. 63 Lane D asserts headless, 288/0/0 in the browser. Co-Authored-By: Claude Opus 4.8 --- web/world/js/ladder.js | 39 ++++++++++++++++++---------- web/world/js/tests/d.test.js | 50 ++++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js index d86cdd1..1929d42 100644 --- a/web/world/js/ladder.js +++ b/web/world/js/ladder.js @@ -36,23 +36,36 @@ export const LADDER_URL = './models/ladder_01_v1.glb'; * at the bracket on the wall, while a blown post or tree corner is re-made on the cloth, which has * fallen to somewhere you can stand. Height is a coincidence of that; the bracket is the reason. * - * SPRINT9, site_02 (SPRINT9 §Lane D — "the ladder assumes fascia height; the carport may not have - * one"): `type === 'house'` is site_01's spelling of that idea, not the idea itself. The corner - * block has a CARPORT — a roofline you'd also work at the bracket — and contracts.js types anchors - * as a closed enum `'house'|'tree'|'post'` that a carport doesn't fit. Whatever E types it, this - * must not fail OPEN: if `needsLadder` says false, `canReach` in interact.js returns true - * unconditionally and the player re-rigs a 2.6 m bracket standing on the grass — the mechanic just - * quietly stops existing, with no error. That is the same disease as StumbleBack's dead threshold - * and the camera-less shadow grid: a rule keyed on the wrong thing, failing silently open. + * SPRINT10 §Gate 1 — keyed on the MECHANISM, from site data. `type === 'house'` was site_01's + * spelling of the idea, not the idea itself, and contracts.js types anchors as a closed enum + * `'house'|'tree'|'post'` that site_02's carport does not fit. That mattered because it fails OPEN: + * when `needsLadder` says false, `canReach` in interact.js returns true UNCONDITIONALLY, so the + * player re-rigs a bracket well over their head while standing on the grass and the whole mechanic + * quietly stops existing — no error, no red. Same disease as StumbleBack's dead threshold and the + * camera-less shadow grid: a rule keyed on the wrong thing, failing silently open. * - * So it reads DATA first, exactly like `anchor.ratingHint` already does (world.js pulls that from - * the GLB's `userData.rating_hint`), and falls back to site_01's shape. Lane A/E: set - * `anchor.worksAtBracket = true` on the carport's anchors in the site JSON and the ladder follows - * the site with no code change. Nothing on site_01 carries the flag, so this is a no-op there. + * Measured against E's shipped `carport_01_v1.glb`, which is exactly the case the audit predicted — + * and note it carries BOTH mechanisms, so a per-STRUCTURE rule would still be wrong: + * beam_anchor_01/02 y=2.36 anchor_type "carport" → over the 2.20 m reach: BRACKET + * post_anchor_01/02 y=1.75 anchor_type "carport_post" → under it, reachable: CLOTH + * Neither is typed 'house'. The beam is the double trap — E's worst rating in the game (0.22) AND + * the one you need the ladder for. + * + * Reads DATA first, exactly like `anchor.ratingHint` already does from the GLB's `userData`: + * · `work: 'bracket' | 'cloth'` — SPRINT10's spelling, and the canonical one. Positive about the + * mechanism rather than a negation, which is why it beats the boolean I proposed in Sprint 9. + * · `worksAtBracket: boolean` — my Sprint-9 spelling, still honoured so nothing that already + * sets it breaks. + * · else `type === 'house'` — site_01's shape. Nothing there carries either field, so this is + * a no-op on backyard_01 and E's anchor tripwires stay green. + * Lane A: emit `work` per anchor in the site JSON and the ladder follows the site with no code + * change. The carport mapping you want is beam→bracket, post→cloth, per the heights above. */ export const needsLadder = (anchor) => { if (!anchor) return false; - if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // site data wins + if (anchor.work === 'bracket') return true; // SPRINT10 spelling + if (anchor.work === 'cloth') return false; + if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // Sprint-9 spelling return anchor.type === 'house'; // site_01's shape }; diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index a9d3a9c..b38a1f2 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -448,22 +448,46 @@ export default async function run(t) { 'nor a tree limb — that is a strop you throw'); }); - t.test('ladder: a site can DECLARE which anchors are worked at the bracket', () => { - // SPRINT9 §Lane D, ahead of site_02. The corner block has a CARPORT, and contracts.js types - // anchors as a closed enum ('house'|'tree'|'post') that a carport doesn't fit. The failure mode - // matters more than the feature: if needsLadder says false, interact's canReach returns true - // UNCONDITIONALLY, so the player re-rigs a 2.6 m bracket from the grass and the mechanic - // silently stops existing. Data first, site_01's type as the fallback. - assert(realNeedsLadder({ type: 'carport', pos: { y: 2.6 }, worksAtBracket: true }), - 'a carport roofline is bracket work even though its type is not "house"'); - assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false }), - 'and a site can say otherwise — a low pergola you can reach off the deck'); - // and site_01 must be untouched: nothing there carries the flag - assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no flag → site_01 behaviour'); - assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no flag → post is still ground work'); + 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 + // deletes the ladder mechanic. Fail-open is why this is data and not a type string. + assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, work: 'bracket' }), + 'declared bracket work needs the ladder, whatever the type says'); + assert(!realNeedsLadder({ type: 'carport_post', pos: { y: 1.75 }, work: 'cloth' }), + 'declared cloth work does not — you re-make it on the fallen sail'); + assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, work: 'cloth' }), + 'and a site outranks the type in both directions (a low pergola off a deck)'); + + // Sprint-9's spelling still honoured, so anything already setting it keeps working + assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, worksAtBracket: true })); + assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false })); + + // site_01 must be untouched — nothing there carries either field + assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no field → site_01 behaviour'); + assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no field → post is still ground work'); assert(!realNeedsLadder(null) && !realNeedsLadder(undefined), 'and it never throws on a gap'); }); + t.test('ladder: E\'s REAL carport resolves both ways round (site_02\'s trap)', () => { + // Read off carport_01_v1.glb rather than invented: E ships BOTH mechanisms on one structure, + // which is why a per-structure rule would still be wrong. + // beam_anchor_01/02 y=2.36 anchor_type "carport" rating_hint 0.22 + // post_anchor_01/02 y=1.75 anchor_type "carport_post" rating_hint 0.30 + // The beam is the double trap: E's worst rating in the game AND over the 2.20 m reach. + const beam = { id: 'c1', type: 'carport', pos: { y: 2.36 }, work: 'bracket', ratingHint: 0.22 }; + const post = { id: 'c3', type: 'carport_post', pos: { y: 1.75 }, work: 'cloth', ratingHint: 0.30 }; + assert(realNeedsLadder(beam), 'the beam bracket at 2.36 m is over a 1.72 m person\'s 2.20 m reach'); + assert(!realNeedsLadder(post), 'the post fitting at 1.75 m is not — you can just reach it'); + assert(beam.ratingHint < post.ratingHint, 'and the one needing the ladder is also the weaker lie'); + + // The regression that matters: WITHOUT the work field, E's types are not 'house', so the old + // rule said "no ladder" for a 2.36 m bracket and canReach waved it through from the grass. + assert(!realNeedsLadder({ type: 'carport', pos: { y: 2.36 } }), + 'un-declared, it falls back to the type and reads as ground work — which is exactly why ' + + 'Lane A must emit `work` per anchor in the site JSON, and why this test exists'); + }); + 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 });