HardYards/web/world/dev_rigging.html
type-two 4095df8b10 Lane B S11: audit the venturi, land setWorld/setBudget, revive the harness
site_audit's headless false-negative is fixed by the browser front-end
(A's loadSite/createWorld landed in main — that was all it waited on), and
fixing it surfaced a worse bug underneath: the sweep built its wind from the
STORM def alone and never called setVenturi, which main.js:424 does at every
site load. The venturi is SITE data, so every site_02 audit ever run — including
the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The
SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable,
here it called a mean site cheap.

- sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle);
  both front-ends pass the site's funnel and print it, so a funnelled run is
  legible as one.
- sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor.
  Funnel must strictly raise every corner; a funnel-less site must be
  byte-identical. Verified it fails when the setVenturi calls are deleted.
- rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks.
  Markers are rebuilt (the anchor set changes across sites) and disposed; stale
  picks die with the old yard. Deletes A's "one private touch" into this module.
- dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which
  now throws) and nothing noticed, because a dev page has no selftest to go red.
  Fixed, dressed, and given the site switch it should have had (N = next yard).

Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines
affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need
dropping and E's tree stays put. It's a reach problem, not a gain problem: the
throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over
the carport at +50% and reaches the garden at +0.0%.

selftest 300/0/0 (296 + 4 new).
2026-07-17 17:42:54 +10:00

224 lines
8.8 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) {
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) => { rigSail(ids, hw, tension); 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>