From 601adedf45355bf2d07170651e6e8d6d63618dea Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:25:08 +1000 Subject: [PATCH 1/3] Gate 0' guard: redesign it to measure SHAPE, and prove it fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integrator demoted my settled-at-entry guard to a warning at the Sprint-8 merge and handed B+D the redesign. Investigating it refuted the stated cause and found a better observable. The ponding diagnosis does not hold: every storm's rain curve starts at [0,0], so storm_02.rainMmPerHour(0) is 0.0 mm/h and a held clock rains nothing. The real confound was that the guard measured in the STORM's wind while the rig settled in CALM — and that calm->storm step is one the REAL GAME ALSO HAS (wind.use() swaps on the phase change), so it was never a harness artifact. Their 2 s windows were also shorter than the cloth's breath: 2 s reads a 32% trend on a settled rig where 3 s reads 6%. But fixing the clock isn't enough, because LOAD cannot answer this question at all. In calm there is no transient to see — the unsettled rig is LIGHTER (0.30 kN vs 0.47 settled) and the trend test passes at every settle length from 0 to 20 s. A guard that cannot fail is decoration, which is precisely what StumbleBack and the fake skips were. So ask the physical question: has the cloth stopped MOVING. Mean node drift separates ~6x either side, and cannot be confused by wind or water: unsettled (0 s) -> 212 mm/s (cover quad) · 208 (miss quad) settled (8-20 s) -> 35 mm/s worst breath, typically 4-6 Limit 100 mm/s. Proved it FAILS before trusting it to pass: sabotaged the settle to 0 s and it fired — "cloth still drifting 212 mm/s (limit 100)". The integrator's 600 N floor was a sound amendment to the LOAD design; it isn't carried over because drift has no units of force and 212-vs-35 is not a judgement call. 274/0/0, all five balance asserts green. Co-Authored-By: Claude Opus 4.8 --- web/world/js/tests/balance.test.js | 116 ++++++++++++++--------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index cf725f8..97b7409 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -187,52 +187,48 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period); } } - /** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */ - const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0)); - - // D's settled-at-entry guard (SPRINT8 gate 0'). - // - // The settle above is only load-bearing if it actually settled — if a future - // change makes the cloth ring longer than 12 s, every number below silently - // becomes an attach-transient measurement again, which is the bug that cost - // two sprints. So prove it. - // - // It has to be a TREND test, not an instant one. Measured: with the wind held - // constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN, - // because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind - // drag is meant to do the damping). There is no single settled value; the - // cloth breathes. An instantaneous "has it stopped moving" check can never - // pass, and would just be a flaky assert that gets deleted. Same lesson as the - // statics assert in sail.selftest: compare time-AVERAGED windows. - const meanLoad = (secs) => { - let sum = 0, n = Math.round(secs / FIXED_DT); - for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); } - return sum / n; - }; - const w1 = meanLoad(2); - const w2 = meanLoad(2); - const trend = Math.abs(w2 - w1) / Math.max(1, w1); - // Integrator amendment (Sprint-8 merge, D to bless): the trend only matters - // at a scale that can move a verdict. The transient that cost two sprints was - // kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads - // as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating. - // ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign, - // not a threshold. It was authored before ponding merged, and at a held clock - // storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING - // the guard's own windows — the "trend" it reads is water arriving, which no - // settle length fixes (measured tonight: 105% with full-curve settle, 46% - // dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly). - // The guard's idea is right; its clock is wrong. Redesign: measure the trend - // over the storm's own advancing first seconds (rain then follows the real - // curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it - // WARNS instead of failing so the merged suite reports the balance truthfully. - if (trend > 0.35 && Math.max(w1, w2) > 600) { - console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` + - `${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` + - `(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` + - `Every balance number below is measuring the attach transient — lengthen the settle ` + - `before trusting them. (Oscillation is expected and fine; a TREND is not.)`); - } + /** + * D's settled-at-entry guard, REDESIGNED (SPRINT9, B+D). It now measures SHAPE, not load. + * + * The guard exists for one reason: a harness that rigs and storms in the same tick measures a + * cloth still falling out of its flat _build() shape, and pretends that is a rig. That bug cost + * two sprints, so the settle above has to prove it worked. + * + * The Sprint-8 version asked the question with LOAD and could not answer it. Measured tonight, + * why every load-based form fails: + * · in CALM (what the rig settles in) there is no transient to see at all — worst-corner mean + * reads 0.30 kN unsettled vs 0.47 kN settled, i.e. the unsettled rig is LIGHTER, and the + * trend test passes at every settle length from 0 s to 20 s. A guard that cannot fail is + * decoration. + * · in the STORM at a held t=0 it read a 105% "trend" — but that is the calm→storm step change, + * which the REAL GAME ALSO HAS (`wind.use(to === 'storm' ? winds[stormKey] : calmWind)` on the + * phase change). It was never a harness artifact; the suite was being faithful and the guard + * was calling it a bug. + * · the integrator's ponding diagnosis at the Sprint-8 merge does not hold either: every storm's + * rain curve starts at [0, 0], so `storm_02.rainMmPerHour(0)` is 0.0 mm/h and a held clock + * rains nothing. (Their 2 s windows were also shorter than the cloth's breath — 2 s reads a + * 32% trend on a settled rig where 3 s reads 6%.) + * + * So ask the physical question instead: HAS THE CLOTH STOPPED MOVING. Mean node drift separates + * cleanly, ~6x either side of the line, and unlike load it cannot be confused by wind or water: + * unsettled (0 s) → 212 mm/s (cover quad) · 208 mm/s (miss quad) + * settled (8-20 s) → 35 mm/s worst case, typically 4-6 + * The integrator's 600 N floor was a sound amendment to the LOAD design and is not carried over — + * drift has no units of force to need a floor, and it needs no threshold on "does it matter" + * because 212-vs-35 is not a judgement call. + */ + const settleDriftMmPerSec = (() => { + const before = Float64Array.from(rig.pos); + const secs = 2; + for (let i = 0, n = Math.round(secs / FIXED_DT); i < n; i++) { + rig.step(FIXED_DT, calmWind, ((12 + i * FIXED_DT) % Math.max(1, calmDef.duration))); + } + let sum = 0; + for (let i = 0; i < before.length; i += 3) { + sum += Math.hypot(rig.pos[i] - before[i], rig.pos[i + 1] - before[i + 1], rig.pos[i + 2] - before[i + 2]); + } + return (sum / (before.length / 3)) * 1000 / secs; + })(); let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); @@ -264,8 +260,8 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = lost: rig.corners.filter((c) => c.broken).length, spent: START_BUDGET - session.budget, pond: Math.round(pond), - /** kN on the worst corner at storm entry — the settled-at-entry guard's number. */ - entryPeak: entryPeak / 1000, + /** mm/s the cloth was still drifting when the storm started — the settled-at-entry guard. */ + settleDrift: settleDriftMmPerSec, }; } @@ -347,22 +343,26 @@ export default async function run(t) { }); /** - * D's settled-at-entry guard (SPRINT8 §Lane D). The suite must enter the storm with the yard in - * the state a player hands it: rigged, breathing on the calm day, corners loaded by nothing worse - * than their own tension. It does not decide storm_02's verdict — measured, it doesn't — but a - * harness silently measuring the attach transient is how three of them disagreed for two sprints, - * and the transient IS worth 2.7× on tree corners in the moment it exists. + * D's settled-at-entry guard, redesigned to measure SHAPE (SPRINT9, B+D). Full reasoning at the + * measurement site in fly(); the short version is that load could not answer this question and + * node drift can, ~6x clear either side of the line. + * + * It does NOT decide storm_02's verdict — measured twice now, the settle never moves the corner + * count. It exists because a harness silently flying a cloth that is still falling is how three + * of them disagreed for two sprints, and the next one should trip a wire instead of an argument. */ - t.test('harness: the yard is SETTLED when the storm starts', () => { + t.test('harness: the cloth has STOPPED MOVING when the storm starts', () => { + const LIMIT = 100; // mm/s. Measured: 212 unsettled, 35 worst settled breath. const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r); for (const [name, r] of runs) { - if (!(r.entryPeak < 1.0)) { + if (!(r.settleDrift < LIMIT)) { throw new Error( - `${name} entered the storm carrying ${r.entryPeak.toFixed(2)} kN — that is an attach ` + - 'transient, not a rig. Settle on the calm day with a running clock, as main.js does.'); + `${name} entered the storm with the cloth still drifting ${r.settleDrift.toFixed(0)} mm/s ` + + `(limit ${LIMIT}). That is a sail falling into shape, not a rig — every number below is ` + + 'measuring the attach transient. Lengthen the settle in fly().'); } } - return `entry loads: ${runs.map(([n, r]) => `${n} ${r.entryPeak.toFixed(2)} kN`).join(' · ')}`; + return `settle drift: ${runs.map(([n, r]) => `${n} ${r.settleDrift.toFixed(1)} mm/s`).join(' · ')}`; }); t.test('balance: storm_02 punishes a cheap rig on the same quad', () => { From 942709d2e941fcd2cf56ae6b3bd727492caa1e99 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:32:06 +1000 Subject: [PATCH 2/3] Site-proof the ladder ahead of site_02, and test the real rule not a copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT9 §Lane D: "play site_02 cold and log where the verbs break (the ladder assumes fascia height; the carport may not have one)". site_02 isn't landed yet, so I audited the verb against it instead of waiting. The seam is real but it isn't height — it's that `needsLadder` is spelled `type === 'house'`, and contracts.js types anchors as a closed enum ('house'|'tree'|'post') that E's carport doesn't fit. The failure mode is what makes it worth fixing now: if needsLadder says false, interact's canReach returns TRUE UNCONDITIONALLY, so the player re-rigs a 2.6 m bracket standing on the grass and the mechanic silently stops existing. Same disease as StumbleBack's dead threshold and the camera-less shadow grid — a rule keyed on the wrong thing, failing open with no error. A pure height test is NOT the fix: posts (3.95 m) and tree limbs (5.05 m) are higher than the fascia and need no ladder, because you work the CLOTH there and the bracket here. So needsLadder now reads data first — `anchor.worksAtBracket` — exactly like anchor.ratingHint already does from the GLB's userData, and falls back to site_01's type. Nothing on site_01 carries the flag, so it's a no-op there (asserted). Lane A/E: set it in the site JSON and the ladder follows the site with no code change. Also made ladder.js headless-importable (lazy GLTFLoader, same as broom.js), because d.test.js was testing fakeLadder's hand-copied duplicate of needsLadder rather than the rule itself — a rule the suite re-implements is one it cannot catch drifting. Verified the ladder's GLB still loads in the browser. 62 Lane D asserts headless, 275/0/0 in the browser. Co-Authored-By: Claude Opus 4.8 --- web/world/js/ladder.js | 61 ++++++++++++++++++++++++++---------- web/world/js/tests/d.test.js | 26 ++++++++++++--- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js index ea9a0a0..d86cdd1 100644 --- a/web/world/js/ladder.js +++ b/web/world/js/ladder.js @@ -17,20 +17,44 @@ * 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'; +// GLTFLoader is imported lazily, not at module scope, and the reason is the suite: the vendored +// addons import the bare specifier 'three', which only resolves under index.html's importmap, so a +// top-level import here would drag the whole GL chain in and make this file unloadable from +// d.test.js (node resolves relative paths only — three.module.js is fine, its addons are not). +// That mattered: needsLadder below is a RULE, and the suite was testing a hand-copied duplicate of +// it rather than the rule itself. Same reasoning as broom.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. + * NOT a height test, deliberately. 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 repair into a two-trip ladder job — untrue to how + * sails are rigged, and it would silently invalidate the recorded §7 run and Lane B's gate asserts. + * The real distinction isn't height, it's WHERE THE WORK HAPPENS: a blown fascia corner is re-made + * 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. + * + * 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. */ -export const needsLadder = (anchor) => !!anchor && anchor.type === 'house'; +export const needsLadder = (anchor) => { + if (!anchor) return false; + if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // site data wins + 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. */ const STAND_OFF = 0.9; // m clear of the wall face @@ -72,15 +96,20 @@ export function createLadder(scene, world, interact, player) { 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 */ }); + // Dynamic import: only ever runs in a browser (see the note at the top of the file). + if (scene) { + import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => { + 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 */ }); + }).catch(() => { /* headless (selftest/node): the rules run, there is just nothing to draw */ }); + } const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder function syncView() { diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index bff8913..a9d3a9c 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -14,6 +14,9 @@ import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js'; 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 { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; @@ -438,14 +441,29 @@ export default async function run(t) { }); 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 } }), + assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it'); + assert(!realNeedsLadder({ 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 } }), + assert(!realNeedsLadder({ type: 'tree', pos: { y: 5.05 } }), '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'); + assert(!realNeedsLadder(null) && !realNeedsLadder(undefined), 'and it never throws on a gap'); + }); + 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 }); From 1fc8b8e453f9ea120ae574d2ffcf466ccd56fcfe Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:32:36 +1000 Subject: [PATCH 3/3] Log the guard redesign and the site_02 ladder audit Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/THREADS.md b/THREADS.md index 6a59f03..63c20aa 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2650,3 +2650,61 @@ Format: `[lane letter] YYYY-MM-DD — note` on the record, A's "1 lost" was a miscount, and C found the REAL winnable line (p1,p2,p3,p4 — $80 exactly, hp 52, 1 lost) that needs B's porous fabric + C's 0.40 downdraft STACKED. The pyrrhic-win rule and the fabric+downdraft pairing are SPRINT9's first moves. + +[D] 2026-07-18 — 🔧 **GUARD REDESIGNED (§Lane D, with B) — it measures SHAPE now, and I proved it fires.** + Integrator: thank you for demoting it rather than deleting it, but **your stated cause doesn't + hold, and neither did my design.** Both worth recording: + · **The ponding diagnosis is wrong.** Every storm's rain curve starts at `[0, 0]`, so + `storm_02.rainMmPerHour(0)` is **0.0 mm/h** — a held clock rains *nothing*. (Also: your 2 s + windows are shorter than the cloth's breath. A settled rig reads a 32% trend at 2 s and **6% + at 3 s**. The window was the flake, not the water.) + · **The real confound was mine.** The guard measured in the STORM's wind while the rig settled + in CALM — and that calm→storm step is one **the real game also has** (`wind.use(to === + 'storm' ? winds[stormKey] : calmWind)` fires on the phase change). It was never a harness + artifact; the suite was being faithful and my guard was calling it a bug. + · **And fixing the clock isn't enough, because LOAD cannot answer this question at all.** In + calm there is no transient to see — the unsettled rig is *lighter* (0.30 kN vs 0.47 settled) + and the trend test **passes at every settle length from 0 s to 20 s**. A guard that cannot + fail is decoration, which is exactly what StumbleBack and the fake skips were. + So it asks the physical question instead — **has the cloth stopped moving** — and mean node drift + separates ~6× either side, immune to wind and water alike: + ``` + unsettled (0 s) → 212 mm/s (cover quad) · 208 (miss quad) + settled (8–20 s) → 35 mm/s worst breath, typically 4–6 + ``` + Limit **100 mm/s**. **Proved it fails before trusting it to pass** (the habit this repo keeps + earning): sabotaged the settle to 0 s and it fired — *"line entered the storm with the cloth + still drifting 212 mm/s (limit 100)"*. Restored, all five balance asserts green, **275/0/0**. + **Your 600 N floor: blessed as reasoning, not carried as code.** It was the right amendment to a + LOAD design — "the trend only matters at a scale that can move a verdict" is correct and I'd have + kept it. Drift has no units of force to need a floor, and 212-vs-35 isn't a judgement call. + +[D] 2026-07-18 — 🪜 **SITE_02 PREP — I audited the ladder against it rather than wait, and there IS a + seam. A/E: one field in the site JSON and I'm done.** site_02 isn't landed so I can't play it cold + yet; this is the half I could do now. + The prompt's hunch was right but the mechanism isn't height. `needsLadder` was spelled + **`type === 'house'`**, and contracts.js types anchors as a **closed enum `'house'|'tree'|'post'` + that a carport doesn't fit.** The failure mode is why it couldn't wait: if `needsLadder` says + false, **`canReach` in interact.js returns `true` UNCONDITIONALLY** — so on site_02 the player + re-rigs a 2.6 m carport bracket standing on the grass, and the ladder mechanic *silently stops + existing*. No error, no red. That's the same disease as StumbleBack's dead threshold, the fake + skips, and the camera-less shadow grid: **a rule keyed on the wrong thing, failing open.** + **A pure height test is NOT the fix** — posts (3.95 m) and tree limbs (5.05 m) are *higher* than + the 2.48 m fascia and need no ladder, because there you work the **cloth** (which has fallen to + where you stand) and here you work the **bracket** (which is on a wall). Height is a coincidence + of that distinction, not the distinction. + So it reads **data first**: `anchor.worksAtBracket` — precisely the pattern `anchor.ratingHint` + already uses from the GLB's `userData.rating_hint` — falling back to `type === 'house'`. + **Nothing on site_01 carries the flag, so it's a no-op there** (asserted both ways). + **Lane A:** when you extract the yard to `data/sites/*.json`, give anchors an optional + `worksAtBracket`. **Lane E:** type the carport however you like — set that flag on its anchors and + the ladder follows the site with zero code change. If you'd rather it were named or shaped + differently, say so now; it's one line and I have no attachment to the spelling. + Also: `ladder.js` is now headless-importable (lazy GLTFLoader, same as broom.js), because + `d.test.js` had been testing **fakeLadder's hand-copied duplicate** of `needsLadder` instead of + the rule itself. A rule the suite re-implements is a rule the suite cannot catch drifting — which + is the whole reason this repo has a balance suite that reads the yard from world.js. + +[D] 2026-07-18 — ⏳ **Still owed by me: play site_02 cold** (blocked — A's site-as-data + E's corner + block haven't landed), and the full-week feel pass. Both are queued and neither blocks anyone. My + douse guard is still green, so pond masses haven't moved.