Merge lane/d: keep B's nodeSpeed guard (same redesign, D holds veto)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
d3258baaa8
57
THREADS.md
57
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()`.
|
was folding settle transients into storm peaks) and `nodeSpeed()`.
|
||||||
|
|
||||||
Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
|
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.
|
||||||
|
|||||||
@ -17,20 +17,44 @@
|
|||||||
* no change to get this — it already hands createPlayer the scene, world and interact.
|
* no change to get this — it already hands createPlayer the scene, world and interact.
|
||||||
*/
|
*/
|
||||||
import * as THREE from '../vendor/three.module.js';
|
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';
|
export const LADDER_URL = './models/ladder_01_v1.glb';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which anchors you cannot rig from the ground.
|
* 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
|
* NOT a height test, deliberately. A pure "is it above reach?" rule would rope in the posts (3.95 m)
|
||||||
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
* and tree limbs (up to 5.05 m) and turn every repair into a two-trip ladder job — untrue to how
|
||||||
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
* sails are rigged, and it would silently invalidate the recorded §7 run and Lane B's gate asserts.
|
||||||
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
* The real distinction isn't height, it's WHERE THE WORK HAPPENS: a blown fascia corner is re-made
|
||||||
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
* 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. */
|
/** 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
|
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);
|
state.base.copy(home);
|
||||||
|
|
||||||
// --- view -----------------------------------------------------------------
|
// --- view -----------------------------------------------------------------
|
||||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
// Dynamic import: only ever runs in a browser (see the note at the top of the file).
|
||||||
const obj = g.scene;
|
if (scene) {
|
||||||
const top = obj.getObjectByName('ladder_top');
|
import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {
|
||||||
if (top) state.topY = top.position.y;
|
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
const obj = g.scene;
|
||||||
state.view = obj;
|
const top = obj.getObjectByName('ladder_top');
|
||||||
scene.add(obj);
|
if (top) state.topY = top.position.y;
|
||||||
syncView();
|
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
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
|
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
||||||
function syncView() {
|
function syncView() {
|
||||||
|
|||||||
@ -323,8 +323,8 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se
|
|||||||
lost: rig.corners.filter((c) => c.broken).length,
|
lost: rig.corners.filter((c) => c.broken).length,
|
||||||
spent: START_BUDGET - session.budget,
|
spent: START_BUDGET - session.budget,
|
||||||
pond: Math.round(pond),
|
pond: Math.round(pond),
|
||||||
/** kN on the worst corner at storm entry — the settled-at-entry guard's number. */
|
/** mm/s the cloth was still drifting when the storm started — the settled-at-entry guard. */
|
||||||
entryPeak: entryPeak / 1000,
|
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
|
* D's settled-at-entry guard, redesigned to measure SHAPE (SPRINT9, B+D). Full reasoning at the
|
||||||
* the state a player hands it: rigged, breathing on the calm day, corners loaded by nothing worse
|
* measurement site in fly(); the short version is that load could not answer this question and
|
||||||
* than their own tension. It does not decide storm_02's verdict — measured, it doesn't — but a
|
* node drift can, ~6x clear either side of the line.
|
||||||
* 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.
|
* 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);
|
const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r);
|
||||||
for (const [name, r] of runs) {
|
for (const [name, r] of runs) {
|
||||||
if (!(r.entryPeak < 1.0)) {
|
if (!(r.settleDrift < LIMIT)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${name} entered the storm carrying ${r.entryPeak.toFixed(2)} kN — that is an attach ` +
|
`${name} entered the storm with the cloth still drifting ${r.settleDrift.toFixed(0)} mm/s ` +
|
||||||
'transient, not a rig. Settle on the calm day with a running clock, as main.js does.');
|
`(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', () => {
|
t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {
|
||||||
|
|||||||
@ -14,6 +14,9 @@
|
|||||||
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
|
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
|
||||||
import { Interact, wireYardActions } from '../interact.js';
|
import { Interact, wireYardActions } from '../interact.js';
|
||||||
import { createBroom, BROOM_TUNE } from '../broom.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 { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||||
import { FIXED_DT } from '../contracts.js';
|
import { FIXED_DT } from '../contracts.js';
|
||||||
import { loadStorm, createWind } from '../weather.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', () => {
|
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
|
||||||
const L = fakeLadder();
|
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||||
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||||
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
|
|
||||||
'a 4 m post does NOT — you tension it from a cleat at the base');
|
'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');
|
'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', () => {
|
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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||||
const L = fakeLadder({ player: p });
|
const L = fakeLadder({ player: p });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user