HardYards/web/world/dev_rigging.html
type-two 4718548c38 Lane B S18 gates 2+3+1: ⚠️ THE VARIABLE IS POROSITY; the clean boundary is DWELL; the knife
One landing, three gates and Lane C's clock finding. (Not split: sail.js,
rigging.js and balance.test.js each carry work for more than one gate and a
per-hunk split would have been fiction.)

GATE 2 — THE VARIABLE IS `porosity`, AND THE LIAR WAS THE GAME.
`rig.setFabric()` has exactly one call site, `RiggingSession.commit(rig)`, and
the shipped commit path never reaches it: the rigging UI's commit() calls
onCommit and main.js's rigSail goes straight to rig.attach(). So
session.commit(rig) runs in TESTS AND BENCHES ONLY, and the sim's rig (built
once at boot as `new SailRig({anchors})`, porosity defaulting to 0) has flown
the waterproof membrane every night of every playtest while the panel, the
SCORE IT card, the HUD row, the invoice and every audit number said shade
cloth. The F key moved the paperwork and never the physics.

    shade cloth 0.30 (what every card promised)  0/4 lost  p2 3.07  p3 1.78
    membrane 0      (what the sim actually flew)  2/4 lost  p2 3.48  p3 3.90

Row two is D's cold play AND the pin's own _playedReference (hp 55.7, p2 then
p3) to the decimal, reproduced in the shipped game. It also retires C's
"benches under-read the real UI by 5-15%, cause unfound": with the fabric held
equal, the bench and the FULL game chain agree to three decimals, two sites,
two storms. Debris hits on the sail: 0 — D's candidate ruled out by
measurement. Fixed on my side: commit() carries the fabric as onCommit's 4th
argument; dev_rigging.html picks it up, which is the shape of the one-line
pickup filed for A's rigSail.

GATE 3 — THE CLEAN BOUNDARY IS THE FUSE, NOT A PERCENTAGE. Same flight, two
corners, identical $15 shackles, same 3.2 kN rating:
    p1  3.528 kN = 10.3% OVER -> HELD  (over for 0.150 s)
    p2  3.493 kN =  9.2% OVER -> BROKE (over for 0.400 s)
p1 peaks HIGHER and survives, so "peak within X% of rating" is wrong at every
X. sail.js never used peak — it burns a 0.4 s overload fuse. OVERLOAD_SECS /
OVERLOAD_RECOVER are exported and priceCandidate replays them per corner per
tier, so the audit and the sim now answer "will it hold" with one arithmetic.
AUDIT.MARGIN stops deciding anything and says why in its own comment.

GATE 1 — THE RIG YOU DIDN'T BUILD, AND THE KNIFE. `_releaseCorner` is now the
one definition of a corner letting go (it was open-coded twice); on top of it:
`failCorner` for a corner that arrived down, `cutAway` for DESIGN.md line 165
emitting A's requested `{t, corners, reason}`, and `session.inherit()` which
adopts somebody else's rig for $0 and keeps it out of the refund — because
week.js pays back half of every intact corner, so counting a stranger's steel
as yours would have the ledger PUNISHING the player for every corner they cut.
Neither a cut nor an inherited corner emits `break`: warranty must not chase a
corner you chose to let go or one you never hung.

LANE C's CLOCK FINDING, fixed. `_substep` samples the wind at `this.t`, which
attach() zeroes — safe only while the caller's clock starts with it, exactly as
attach()'s own comment says. An emergency opens at t=30, so the cloth would fly
the storm's first 60 s under a sky flying its last 60. attach() now takes
{at, wind} (seeded, or genuinely pre-flown so the drape and the pond are real),
seedClock() is the late door, and `clockSkew` makes any future drift readable
instead of silent — measured 90 N vs 655 N for the same rig entering at t=0 vs
t=40, which is the size of what was hiding.

+15 asserts, all node-verified; the two gate-2 pins mutation-checked both ways.
2026-07-25 19:53:06 +10:00

230 lines
9.2 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>SHADES — Lane B rigging harness</title>
<style>
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
font: 12px/1.5 ui-monospace, Menlo, monospace; }
canvas { display: block; }
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
white-space: pre; pointer-events: none; }
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
text-align: right; white-space: pre; pointer-events: none; }
</style>
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="dev"></div>
<div id="hint">Lane B harness — the prep phase only.
ENTER commits the rig and starts a storm. R resets to prep. N switches yard.</div>
<script type="module">
/**
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
*
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
* and it can't be exercised in index.html until Lane A wires it in step 8. This
* boots the real world.js, the real anchors and the real cloth so the thing
* being verified is the thing that ships. Retire it once index.html hosts prep.
*/
import * as THREE from 'three';
import { createWorld, loadSite } from './js/world.js';
import { createCameraRig } from './js/camera.js';
import { createWind, loadStorm } from './js/weather.js';
import { SailRig, createSailView } from './js/sail.js';
import { createRiggingUI } from './js/rigging.js';
import { FIXED_DT } from './js/contracts.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
const [calmDef, wildDef] = await Promise.all([
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
]);
// main.js multiplexes these behind a private router; the harness only ever needs
// one at a time, so it just swaps the reference when the storm starts.
const calmWind = createWind(calmDef);
const wildWind = createWind(wildDef);
let wind = calmWind;
const windProxy = {
sample: (p, t, out) => wind.sample(p, t, out),
gustTelegraph: (t) => wind.gustTelegraph(t),
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
// The venturi is the SITE's, so it re-registers on every site switch — same as
// main.js:424. Both storms carry it: the gap doesn't switch off for the calm day.
setVenturi: (list) => { calmWind.setVenturi?.(list); wildWind.setVenturi?.(list); },
};
/**
* SPRINT11: this harness was DEAD and nothing noticed.
*
* SPRINT10 made the yard data — `createWorld` now requires a site and throws
* without one — and this page was still calling `createWorld(scene, {wind})`.
* It had been throwing on boot since that merge, silently, because a dev harness
* has no selftest to go red. (The same change is why `loadSite` appears above.)
* Two sprints of "the picking UI can't be asserted headless, so use the harness"
* with no harness. Fixed here, and given the site switch it should have had.
*/
// NB: createWorld does NOT hand the site back on the world object, so the def is
// kept here — the venturi has to come from somewhere on a switch. (main.js keeps
// its own `siteDef` for the same reason.)
const SITES = ['backyard_01', 'site_02_corner_block'];
let siteIdx = 0;
let siteDef = await loadSite(SITES[siteIdx]);
let world = createWorld(scene, { wind: windProxy, site: siteDef });
await world.dress();
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
const rig = new SailRig({ anchors: world.anchors });
let sailView = null;
async function rigSail(anchorIds, hwChoices, tension, fabric) {
// SPRINT18 gate 2: the fabric BEFORE the attach. Without this line the sail
// flies `new SailRig`'s default porosity (0 — the waterproof membrane) no
// matter what the F key says, which is exactly the bug the shipped game had:
// the panel sold shade cloth and the sim flew full wind load. This is the
// shape of the one-line pickup filed for main.js's own rigSail.
if (fabric) rig.setFabric(fabric);
rig.attach(anchorIds, hwChoices, tension);
if (sailView) {
scene.remove(sailView);
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
}
sailView = await createSailView(rig);
scene.add(sailView);
}
let msg = '';
let msgT = 0;
const ui = await createRiggingUI({
scene, camera: cameraRig.object, domElement: canvas, world,
onCommit: (ids, hw, tension, fabric) => { rigSail(ids, hw, tension, fabric); wind = wildWind; t = 0; phase = 'storm'; },
onMessage: (m) => { msg = m; msgT = 2; },
});
let phase = 'prep';
ui.setActive(true);
/**
* Swap the yard under the running UI — the harness for `rigging.setWorld`.
*
* Deliberately the same ORDER main.js's loadSiteInto uses: build the new world,
* dress it, re-register the site's wind (venturi + tree shelters), re-point the
* rig, then hand the UI the new world. Getting that order wrong here would prove
* nothing about the game.
*/
async function switchSite(idx) {
siteIdx = (idx + SITES.length) % SITES.length;
siteDef = await loadSite(SITES[siteIdx]);
if (sailView) { scene.remove(sailView); sailView = null; }
world.dispose(); // main.js does the same before rebuilding
world = createWorld(scene, { wind: windProxy, site: siteDef });
await world.dress();
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
rig.anchors = world.anchors;
ui.setWorld(world); // ← the API under test: markers + session follow
phase = 'prep';
wind = calmWind;
ui.setActive(true);
window.__laneB.world = world;
}
// harness-only handle, so a debugger (or an agent driving this page) can pick
// anchors without hunting for pixels. Never imported by the game.
window.__laneB = {
ui, rig, world, cameraRig, scene,
get phase() { return phase; },
get site() { return SITES[siteIdx]; },
get anchorIds() { return world.anchors.map((a) => a.id); },
/** rig by id, exactly as if you'd clicked it */
pick: (id) => ui.session.rig(id),
cycle: (id) => ui.session.cycleHardware(id),
switchSite,
/**
* What the MOUSE would actually hit, read off the live scene graph rather than
* asked of the UI — the honest proof that setWorld rebuilt the clickable
* markers, not just the panel text. Every marker carries userData.anchorId on
* its (invisible) pick sphere.
*/
pickTargetIds: () => {
const ids = [];
scene.traverse((o) => { if (o.userData?.anchorId) ids.push(o.userData.anchorId); });
return ids.sort();
},
};
addEventListener('keydown', (e) => {
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
if (e.key.toLowerCase() === 'n') { switchSite(siteIdx + 1); return; } // next yard
if (e.key.toLowerCase() === 'r') {
phase = 'prep';
wind = calmWind;
ui.setActive(true);
if (sailView) { scene.remove(sailView); sailView = null; }
}
});
// follow-cam target: the yard centre, so prep reads as a site walk-around
const focus = new THREE.Vector3(0, 1.2, 0);
const dev = document.getElementById('dev');
const clock = new THREE.Clock();
let t = 0, acc = 0;
renderer.setAnimationLoop(() => {
const raw = Math.min(0.25, clock.getDelta());
acc += raw;
while (acc >= FIXED_DT) {
t += FIXED_DT;
world.update(FIXED_DT, t);
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
acc -= FIXED_DT;
if (msgT > 0) msgT -= FIXED_DT;
}
ui.update(raw, t);
sailView?.update();
cameraRig.update(raw, focus);
const s = ui.summary;
const loads = rig.rigged
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
: '(not rigged)';
dev.textContent =
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
renderer.render(scene, cameraRig.object);
});
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
cameraRig.object.aspect = innerWidth / innerHeight;
cameraRig.object.updateProjectionMatrix();
});
renderer.setSize(innerWidth, innerHeight);
cameraRig.object.aspect = innerWidth / innerHeight;
cameraRig.object.updateProjectionMatrix();
</script>
</body>
</html>