SPRINT10 gate 1. Adopts SPRINT10's spelling — `work: 'bracket' | 'cloth'` — as
canonical over the boolean I proposed in Sprint 9: naming the mechanism
positively beats a negation, and it's the field A is emitting. Sprint 9's
`worksAtBracket` is still honoured so nothing that already sets it breaks, and
`type === 'house'` remains the fallback, so site_01 is a no-op (verified live:
no anchor there carries either field, and fascia/post/tree verdicts are
unchanged).
Keyed to E's SHIPPED carport_01_v1.glb rather than my invented numbers, and it
confirms the audit exactly — it also carries BOTH mechanisms on one structure,
so a per-structure rule would still have been 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 sits over a 1.72 m person's 2.20 m reach and needs the ladder; the post
doesn't. Neither is typed 'house', so un-declared they BOTH fall back to "ground
work" and canReach waves a 2.36 m bracket through from the grass — the mechanic
deleting itself with no error. That regression is now pinned by a test.
E: the beam is the double trap — your worst rating in the game AND the one that
costs a ladder trip. That reads as a rule, not a bug, which is what §Lane D asks
for. A: the mapping you want is beam→bracket, post→cloth.
63 Lane D asserts headless, 288/0/0 in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
13 KiB
JavaScript
259 lines
13 KiB
JavaScript
/**
|
|
* ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12)
|
|
*
|
|
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
|
|
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
|
|
* ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
|
|
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
|
|
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
|
|
*
|
|
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
|
|
* ladder and spare are BOTH carry items, and you can only hold one.
|
|
* So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
|
|
* while a post repair costs one. The house is the expensive anchor to depend on, which is
|
|
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
|
|
*
|
|
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
|
|
* no change to get this — it already hands createPlayer the scene, world and interact.
|
|
*/
|
|
import * as THREE from '../vendor/three.module.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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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 beam→bracket, post→cloth, per the heights above.
|
|
*/
|
|
export const needsLadder = (anchor) => {
|
|
if (!anchor) return false;
|
|
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
|
|
};
|
|
|
|
/** 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 PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
|
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
|
|
|
/**
|
|
* @param {THREE.Object3D} scene
|
|
* @param {object} world contracts World (must be dressed — anchors are final only after dress())
|
|
* @param {object} interact Lane D's Interact
|
|
* @param {object} player the PlayerSim
|
|
* @returns {object} the ladder system
|
|
*/
|
|
export function createLadder(scene, world, interact, player) {
|
|
const anchors = (world.anchors || []).filter(needsLadder);
|
|
if (!anchors.length) {
|
|
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
|
|
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
|
|
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
|
|
}
|
|
|
|
const state = {
|
|
carried: false, // in the player's hands
|
|
placedAt: null, // anchor id, or null while stowed/carried
|
|
base: new THREE.Vector3(),
|
|
topY: 2.9, // overwritten from the GLB's ladder_top node
|
|
view: null,
|
|
};
|
|
|
|
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
|
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
|
|
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
|
|
// thing that reads as a broken game rather than a crowded shed.
|
|
const home = new THREE.Vector3(10.4, 0, 3.4);
|
|
if (world.shedTable && world.shedTable.pos) {
|
|
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
|
|
}
|
|
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
|
state.base.copy(home);
|
|
|
|
// --- view -----------------------------------------------------------------
|
|
// 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() {
|
|
if (!state.view) return;
|
|
state.view.visible = !state.carried;
|
|
state.view.position.copy(state.base);
|
|
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
|
|
if (a) {
|
|
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
|
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
|
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
|
} else {
|
|
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
|
}
|
|
}
|
|
|
|
/** The fascia anchor this ladder is currently serving, if any. */
|
|
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
|
|
|
|
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
|
|
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
|
|
|
|
/**
|
|
* True if the player is up THIS ladder and can work the given anchor.
|
|
* Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
|
|
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
|
|
*/
|
|
function isWorking(anchorId) {
|
|
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
|
|
}
|
|
|
|
// --- interactions ---------------------------------------------------------
|
|
const wired = [];
|
|
|
|
// 1. pick the ladder up (from its home, or from wherever it's planted)
|
|
wired.push(interact.register({
|
|
id: 'ladder_take',
|
|
pos: () => (state.carried ? null : state.base),
|
|
radius: 1.6,
|
|
holdSecs: 0.8,
|
|
clip: 'PickUp',
|
|
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
|
|
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
|
onDone: (p, t) => {
|
|
state.carried = true;
|
|
state.placedAt = null;
|
|
p.pickUp('ladder', t);
|
|
syncView();
|
|
},
|
|
}));
|
|
|
|
// 2. plant it at a fascia anchor
|
|
for (const a of anchors) {
|
|
wired.push(interact.register({
|
|
id: `ladder_place_${a.id}`,
|
|
// stand off the wall, on the yard side — the ladder leans in toward the bracket
|
|
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
|
|
radius: PLACE_RANGE,
|
|
holdSecs: 1.0,
|
|
clip: 'PickUp',
|
|
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
|
|
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
|
|
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
|
|
// single most useful sentence in the game.
|
|
label: (p) => (p.carrying === 'ladder'
|
|
? `set the ladder under ${a.id}`
|
|
: 'the fascia needs the ladder — it\'s by the shed'),
|
|
canUse: (p) => p.carrying === 'ladder',
|
|
onDone: (p, t) => {
|
|
state.carried = false;
|
|
state.placedAt = a.id;
|
|
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
|
a.pos.z + STAND_OFF);
|
|
p.carrying = null;
|
|
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
|
|
syncView();
|
|
},
|
|
}));
|
|
}
|
|
|
|
// 3. climb it — only when it's planted, and only from the ground
|
|
wired.push(interact.register({
|
|
id: 'ladder_climb',
|
|
pos: () => (state.placedAt && !state.carried ? state.base : null),
|
|
radius: 1.5,
|
|
holdSecs: 0.4,
|
|
clip: 'PickUp',
|
|
label: 'climb',
|
|
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
|
|
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
|
|
// gate (are you on the ground?), and locked states can't start a hold anyway.
|
|
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
|
|
onDone: (p, t) => {
|
|
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
|
|
const a = servedAnchor();
|
|
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
|
|
p.climbTo(workY(), t);
|
|
},
|
|
}));
|
|
|
|
const api = {
|
|
get placedAt() { return state.placedAt; },
|
|
get carried() { return state.carried; },
|
|
get base() { return state.base; },
|
|
get topY() { return state.topY; },
|
|
workY,
|
|
isWorking,
|
|
servedAnchor,
|
|
needsLadder,
|
|
|
|
/**
|
|
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
|
|
* knockdown already drops climbY to 0 on its own.
|
|
* player.js calls this each frame from the same input it reads for movement.
|
|
*/
|
|
update(dt, t, input) {
|
|
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
|
syncView();
|
|
},
|
|
|
|
dispose() {
|
|
wired.forEach((un) => un());
|
|
if (interact.ladder === api) interact.ladder = null;
|
|
if (state.view) scene.remove(state.view);
|
|
},
|
|
};
|
|
|
|
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
|
|
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
|
|
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
|
|
interact.ladder = api;
|
|
return api;
|
|
}
|