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