diff --git a/THREADS.md b/THREADS.md index 9cbb359..904ec34 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2876,3 +2876,60 @@ sail.js gains two small APIs other lanes may want: `resetPeaks()` (peakLoad is p was folding settle transients into storm peaks) and `nodeSpeed()`. Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible. +[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. 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/balance.test.js b/web/world/js/tests/balance.test.js index e26e233..82533c7 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -323,8 +323,8 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se 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, }; } @@ -499,22 +499,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', () => { 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 });