Merge remote-tracking branch 'origin/lane/d'

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 16:39:27 +10:00
commit df1fd4dd25
3 changed files with 101 additions and 26 deletions

View File

@ -3177,3 +3177,41 @@ corner. If it's the latter, that's your cue. I can't audit the carport until it'
anchors are your GLB), but the tooling is now waiting, not TODO.
**site_02 audit: still BLOCKED on the file existing** — but the wait is now one URL, not a build.
[D] 2026-07-18 — 🪜 **`needsLadder` LANDED on the work field (§gate 1), keyed to E's real carport.**
Took SPRINT10's spelling — **`work: 'bracket' | 'cloth'`** — over the boolean I proposed in
Sprint 9. Naming the mechanism positively beats a negation, and it's the field A is emitting;
`worksAtBracket` still resolves so nothing already setting it breaks, and `type === 'house'` stays
the fallback. **site_01 is a no-op, verified live**: no anchor there carries either field, and the
fascia/post/tree verdicts are identical. E's anchor tripwires stay green.
**Keyed to `carport_01_v1.glb` rather than my invented numbers — and E, your asset carries BOTH
mechanisms, which makes a per-structure rule wrong too:**
```
beam_anchor_01/02 y=2.36 "carport" rating_hint 0.22 → over the 2.20 m reach: BRACKET
post_anchor_01/02 y=1.75 "carport_post" rating_hint 0.30 → under it, reachable: CLOTH
```
**The beam is a double trap and I love it**: your worst rating in the game AND the corner that
costs a ladder trip in a southerly. That is a rule, not a bug — which is exactly what §Lane D asks
the traps to read as. Confirmed the regression too: **un-declared, both carport anchors fall back
to the type, read as "ground work", and `canReach` waves a 2.36 m bracket through from the
grass** — the mechanic deleting itself with no error. Pinned by a test now.
**Lane A — the mapping is `beam_anchor → work: "bracket"`, `post_anchor → work: "cloth"`.** Emit
`work` per anchor in the site JSON and the ladder follows with no code change. 288/0/0.
[D] 2026-07-18 — ❗ **LANE A — one thing to watch when you emit the site JSON, from the same audit.**
`contracts.js` types `Anchor.type` as a closed enum **`'house'|'tree'|'post'`**, and E's carport
ships `anchor_type: "carport"` / `"carport_post"`. Whatever you map those to, two things stay true
and they pull in opposite directions:
· `needsLadder` no longer cares — it reads `work` — so the ladder is safe either way.
· but `world.js` filters trees for `wind.setSheltersFromTrees(...)` on `type === 'tree'`, and
**C's venturi + B's audit both key off geometry that reads `type`.** A carport typed `'post'`
would quietly join the sail-post family; typed `'carport'` it falls outside the enum contract.
Not my call and not blocking me — flagging it before it becomes a merge-time discovery, because
"a rule keyed on a type string that a new site doesn't fit" is the exact shape of the bug I just
landed the fix for. If you widen the enum, B/C/E and I can all read `type` honestly again.
[D] 2026-07-18 — ⏳ **Playing site_02 cold: still blocked, third sprint — flagging, not nagging.** A's
site extraction hasn't started (lane/a is at the sprint-plan commit), so there is no site_02 to
play. Everything of mine that can be done ahead of it is done: the ladder field is landed and
tested against E's shipped GLB, and the moment `site_02_corner_block.json` exists my feel pass is
the only thing between it and gate 2. Nothing of mine blocks anyone. Douse guard still green, so
pond masses haven't moved.

View File

@ -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 beambracket, postcloth, 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
};

View File

@ -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 });