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).
This commit is contained in:
type-two 2026-07-17 17:42:54 +10:00
parent b38311021c
commit 4095df8b10
9 changed files with 448 additions and 17 deletions

View File

@ -3228,3 +3228,129 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· D's cold playthrough of site_02 — unblocked at last; · D's cold playthrough of site_02 — unblocked at last;
· A decides the carport collateral number and the Anchor.type enum widening D flagged; · A decides the carport collateral number and the Anchor.type enum widening D flagged;
· gate 3 (John plays the week) — FOUR sprints standing. · gate 3 (John plays the week) — FOUR sprints standing.
---
[B] 2026-07-20 — 🔧 **site_audit's false-negative is fixed, and it was hiding a worse bug: the tool was
flying site_02 with the funnel switched OFF.** SPRINT11 gate 1, item 2. Two things, one cause.
The false-negative first: the browser front-end (`tools/site_audit/audit.html`) now works, because A's
`loadSite`/`createWorld` landed in main and that was the only thing it was waiting on. It builds the
yard the way the game does — `createWorld(await loadSite(name))` then `dress()` — and reads
`world.anchors`, so rake and GLB branch/fascia anchors are native. No dump, no drift. **site_02 reads
10 dressed anchors, GLBs loaded ✓.** The node front-end still refuses dress-source JSON (exit 2) and
still audits the built-in snapshot + resolved exports; I re-verified the `backyard_01` dump against a
live dressed export this sprint and it is still exact.
Then the real one. **The venturi lives in the SITE json; `auditSweep` built its wind from the STORM
def alone and never called `setVenturi`** — which `main.js:424` does at every site load. So every
site_02 audit I have ever run, including the numbers I quoted at C in SPRINT10, was of a corner block
with no gap. That is the SPRINT6 p1=7.4 kN failure wearing the other face: there the tool called a
good site unriggable; here it called a mean site cheap. A tool built to catch drift was the thing
drifting. Fixed in `sweep.js` (both winds — the gap doesn't switch off for the settle), both
front-ends now pass the site's funnel and PRINT it in the header so a funnelled run is legible as one.
**The auditor now has an auditor**: `tools/site_audit/sweep.selftest.js`, wired into `b.test.js`,
two asserts — the funnel must strictly raise every corner, and a funnel-less site must be
byte-identical. Written to fail if the `setVenturi` calls are deleted; I checked by deleting them
(both sweeps collapse to the same 0.15 kN and it goes red). Selftest **300/0/0**.
[B] 2026-07-20 — 📊 **C, A — site_02 WITH the venturi: it PASSES, and the funnel kills NOTHING. Do not
drop the gain.** The standing offer isn't needed and neither is E's tree move.
storm_03b_earlybuster, gain 1.5 as shipped, all 10 dressed anchors:
**✓ PASS — 64 affordable lines of 66 candidates. Cheapest $20 (+$15 spare = $35) on an $80 budget.**
Both front-ends agree exactly (browser off `createWorld`, node off a resolved export).
Funnel off → 65 winners, worst corner 4.03 kN. Funnel on → 64 winners, worst corner 4.85 kN. The gap
costs the player exactly **one line out of 65**. C: **your gain 1.35 proposal makes it 65 — i.e. the
funnel stops existing.** 1.5 is not too strong. If anything it is too weak, and here is why:
| probe | funnel OFF | funnel ON | boost |
|------------------|-----------:|----------:|-------:|
| throat (-6,0) | 22.36 | 33.51 | +49.9% |
| cp1 (carport) | 21.92 | 32.21 | +46.9% |
| cb2 (carport) | 22.29 | 30.39 | +36.3% |
| bed WEST edge | 22.25 | 27.03 | +21.5% |
| **bed CENTRE** | **23.10** | **23.10** | **+0.0%** |
**The funnel does not reach the garden.** Throat (-6,0) radius 5; the bed centre is **6.08 m** away,
outside it, and `venturiFactor`'s radial term is already zero by then. The gap screams over the
CARPORT and touches nothing the sail is trying to shade. That is why gain barely moves the verdict —
it is not a gain problem, it is a REACH problem. Gain 4.0 still passes (43 lines); radius 12 at gain
1.5 still passes (52 lines, worst 6.34 kN). Nothing in this envelope makes the site unwinnable.
So: **the reconcile question is geometric, not numeric.** A oriented the gap along the west edge
exactly as the `_venturi` note describes, and C's numbers are fine — the two of you are not actually
in conflict, the funnel is just parked where no cloth goes. My read: leave 1.5 and let D's cold
playthrough decide whether the corner block FEELS mean, because the one thing gain does buy is real —
**at gain 2.0 the cheap $20 line dies and the cheapest becomes $30 at 75% cover.** The funnel's honest
job on this site is pricing the carport-side decoy quads out of carabiners, not threatening the budget.
D: that's the thing to feel for. If it doesn't bite, the lever is moving the throat toward the bed
(or widening the radius), and that is A's call, not a constant C should quietly nudge.
(Alignment is not the problem either, for the record: axis 2.1 vs the post-change southerly at
~0.85 rad is |dot| ≈ 0.98 — near-perfect. `venturiFactor` uses |dot| so a gap funnels either way.)
[B] 2026-07-20 — 💰 **A — pay-schedule numbers for the job sheet. What a good night actually banks, per
site, from the real audit on the real dressed anchors.** Your gate 2 asked; my tool already knew.
| n | site | storm | gust | fee | cheap rig | honest rig (≥70% cover) | net if garden lives |
|---|--------------|-------------------|-----:|----:|-----------|-------------------------|--------------------:|
| 1 | backyard_01 | gentle | 10.5 | $42 | $20 @58% | $20 +$15 = **$35** | **+$62** |
| 2 | backyard_01 | southerly | 20 | $57 | $20 @29% | $30 +$15 = **$45** | **+$72** |
| 3 | **corner blk** | early buster | 20 | $57 | $20 @25% | $30 +$15 = **$45** | **+$72** |
| 4 | backyard_01 | wild night | 30 | $73 | $65 @25% | $120 +$15 = **$135** | **+$43** ⚠ |
| 5 | backyard_01 | ice night | 28.5 | $71 | $75 @58% | $120 +$15 = **$135** | **+$41** ⚠ |
`net = fee + garden bonus(hp100) + half the hardware back (rig + spare)`, using your `PAY`/
`gustPeakOf` so these are the game's numbers, not my re-derivation. Three things fall out of it:
1. **The week has a real escalation and it is NOT the fee.** Nights 1-3 rig honestly for $35-$45.
Nights 4-5 cost **$135** and pay $73/$71 — ⚠ **over the $80 start budget**, so they are only
playable off a banked wallet. That is your pay schedule's actual spine: the fee is nearly flat
($42→$73) while the honest rig triples. A base+garden+no-collateral split should let night 4 read
as "this one costs more than it pays, and you're covering it out of the week" — because it does.
2. **Night 3 costs exactly what night 2 costs** ($30+$15, 75% cover, +$72). The corner block is not a
harder BUY — it's a harder night to read (the change at t=18, the funnel over the carport). Which
is precisely "looks like night 2 on the forecast card and isn't", so the job sheet should not
price it up; the brief should just lie a little more comfortably.
3. **Cheap ≠ good, and the gap is the design.** Every night has a ~$20 line that shades 25-29% of the
bed. It's affordable, it "passes", and it earns you almost no garden bonus. The honest line costs
1.5-6× more. If the job sheet shows base + garden bonus as separate numbers, that decoy becomes
legible as a choice instead of a trap — which is DESIGN.md's pyrrhic win in invoice form.
⚠️ **A — one number for your carport ruling, since it lands on the same card.** E proposes $180
collateral. Night 3's fee is **$57** and its honest rig is **$45**. So one carport wreck is **3.2×
the night's fee**, **4× the rig**, and **7.2× the gnome's $25** — it eats nights 2, 3 and 4's entire
net profit in one hit. I'm not ruling on it (yours), but if a no-collateral BONUS is meant to be the
carrot, note that at $180 the carport isn't a bonus you miss, it's a week you lose — and the player
finds out on the night the game also moves them to an unfamiliar yard. Worth being deliberate about.
[B] 2026-07-20 — ✅ **A — `rigging.setWorld(world)` has landed, plus the `session.setBudget(n)` you asked
for. Delete both fakes.** Your guarded `rigging.setWorld?.(world)` in `loadSiteInto` now fires, and
`_startBudget = week.bank; reset();` becomes `setBudget(week.bank)` — your "one private touch" into
my module can go.
`setWorld` rebuilds the markers rather than repositioning them (the anchor SET changes across sites —
12 ids vs 10, no correspondence to preserve), disposes the old ones (each owns a material and a
canvas-texture label; five site switches a week leaks otherwise), repoints the session via a new
`setAnchors`, and drops the stale picks — `p4` on the backyard is not `p4` on the corner block.
`setBudget` resets deliberately: a new bank only arrives at a night boundary, and carrying last
night's picks into a re-banked shop would leave four corners rigged that this wallet never paid for.
Your call order (`loadSiteInto` → re-bank) is safe with both.
**Verified in the browser, not just asserted**: switched the running UI backyard → corner block and
read the live scene graph — pick targets went from the 12 backyard ids to exactly the 10 corner-block
ids, panel followed, stale `h1` refused ("no such anchor"), `cb1` rigs. **The mouse-rig on site_02
lights up.** Two new asserts in `rigging.selftest.js` cover the session half headless.
[B] 2026-07-20 — 🐛 **`dev_rigging.html` had been DEAD since SPRINT10 and nothing noticed — fixed.** It
still called `createWorld(scene, {wind})`, and SPRINT10 made the site mandatory, so it threw on boot.
Two sprints of "the picking UI can't be asserted headless, so use the harness" with no harness. This
is the cost of a dev page having no selftest to go red, and it's my file, so: fixed, dressed, and
given the site switch it should have had — **N switches yard**, so the mouse-rig on both yards is now
checkable by hand in ~5 seconds. `window.__laneB.switchSite(i)` / `.pickTargetIds()` for driving it.
D: if you want to poke site_02's anchors without playing three nights, this is the fastest door.
(Also `rigging.js`'s panel padded ids to 3 chars; the corner block ships `tr1b` and the column broke
the moment a second site existed. padEnd(4).)

View File

@ -75,12 +75,22 @@ async function run() {
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`); const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`); const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
const vlist = site.wind?.venturi ?? [];
const vtxt = vlist.length
? vlist.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
: 'none';
el('sub').textContent = el('sub').textContent =
`${site.name || siteName} — ${anchors.length} dressed anchors ${dressed ? '(GLBs loaded ✓)' : '(⚠ dress() FAILED — graybox positions, not what ships)'}\n` + `${site.name || siteName} — ${anchors.length} dressed anchors ${dressed ? '(GLBs loaded ✓)' : '(⚠ dress() FAILED — graybox positions, not what ships)'}\n` +
`storm: ${stormName} (${stormDef.duration}s, downdraft ${stormDef.gusts?.downdraftOfTotal ?? '—'})\n` + `storm: ${stormName} (${stormDef.duration}s, downdraft ${stormDef.gusts?.downdraftOfTotal ?? '—'})\n` +
`venturi: ${vtxt}\n` +
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`; `shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef }); // The venturi is the SITE's, not the storm's — main.js:424 sets it on the wind
// at every site load, so the audit must too or the corner block flies with its
// funnel switched off (an easier yard than ships).
const venturi = site.wind?.venturi ?? [];
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi });
// render rows // render rows
const tbl = document.createElement('table'); const tbl = document.createElement('table');
@ -119,7 +129,7 @@ async function run() {
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`; `, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`;
} }
// a machine-readable line, so this page can also be driven headless-in-browser // a machine-readable line, so this page can also be driven headless-in-browser
window.__audit = { site: siteName, storm: stormName, dressed, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw })) }; window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) };
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`; document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`;
} }

View File

@ -65,6 +65,7 @@ const BACKYARD_01 = {
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)', name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
dumped: true, dumped: true,
bed: { x: 1, z: 2, w: 6, d: 4 }, bed: { x: 1, z: 2, w: 6, d: 4 },
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
anchors: [ anchors: [
// dress()-only: adopted from E's GLBs. Unverifiable headless. // dress()-only: adopted from E's GLBs. Unverifiable headless.
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95], ['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
@ -161,7 +162,9 @@ async function loadSite(path) {
id: a.id, type: a.type || 'post', id: a.id, type: a.type || 'post',
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z }, pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
})); }));
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors }; // A resolved export still owes us the site's funnel — the venturi is site data,
// not storm data, and a sweep without it flies an easier yard than ships.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] };
} }
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos })); const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
@ -187,10 +190,12 @@ async function main() {
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`); console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
} }
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`); console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
const venturi = site.venturi ?? [];
console.log(`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ') : 'none'}`);
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`); console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
// The sweep itself is shared with the browser front-end — see sweep.js. // The sweep itself is shared with the browser front-end — see sweep.js.
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef }); const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi });
if (verdict.code === 'no-cover') { if (verdict.code === 'no-cover') {
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`); console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);

View File

@ -8,8 +8,9 @@
* to catch reimplemented-formula drift must not carry two copies of its own math. * to catch reimplemented-formula drift must not carry two copies of its own math.
* *
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}), * Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
* the bed rect, and the storm + calm-day defs. It flies the same settle+storm the * the bed rect, the storm + calm-day defs, and the site's venturi list. It flies
* game flies and returns ranked rows + a verdict. No I/O, no process, no DOM. * the same settle+storm the game flies and returns ranked rows + a verdict.
* No I/O, no process, no DOM.
*/ */
import { SailRig, orderRing } from '../../web/world/js/sail.js'; import { SailRig, orderRing } from '../../web/world/js/sail.js';
@ -44,9 +45,12 @@ export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || nul
* @param {object} o.bed garden bed rect { x, z, w, d } * @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly * @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle) * @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi).
* Omitted = no funnel, which is backyard_01's truth
* and a LIE on the corner block. See below.
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }} * @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
*/ */
export function auditSweep({ anchors, bed, stormDef, calmDef }) { export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
// 1. every quad, in the rigging band, that shades the bed // 1. every quad, in the rigging band, that shades the bed
const cands = []; const cands = [];
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++) for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
@ -71,10 +75,21 @@ export function auditSweep({ anchors, bed, stormDef, calmDef }) {
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40). // wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding // · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
// the settle transient into the storm peak. // the settle transient into the storm peak.
// · setVenturi — the SITE's funnel, and the bug this clause exists for.
// A venturi lives in the site JSON, not the storm, so a sweep built only
// from stormDef silently drops it: main.js:424 calls setVenturi on the
// wind at every site load, and this tool did not. On the corner block —
// whose entire weather personality IS the funnel — that under-reports
// every corner load and hands back an easier yard than the one that
// ships. A false PASS, which is the SPRINT6 trap wearing the other face:
// there the tool called a good site unriggable, here it would call a
// mean site cheap. Both are the tool lying about a yard it can't see.
const trees = anchors.filter((a) => a.type === 'tree'); const trees = anchors.filter((a) => a.type === 'tree');
const wind = createWind(stormDef); const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(trees); wind.setSheltersFromTrees(trees);
const calmWind = createWind(calmDef); const calmWind = createWind(calmDef);
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
calmWind.setSheltersFromTrees(trees); calmWind.setSheltersFromTrees(trees);
const rows = []; const rows = [];

View File

@ -0,0 +1,90 @@
/**
* sweep.selftest.js the audit tool audits itself. [Lane B, SPRINT11]
*
* Same shape as sail.selftest.js / rigging.selftest.js: [name, fn] pairs, so one
* set of asserts runs under both Lane A's selftest.html (via js/tests/b.test.js)
* and plain node.
*
* WHY THIS FILE EXISTS, in one sentence: site_audit shipped SPRINT9 and SPRINT10
* with no test of its own, and in SPRINT11 it turned out to have been flying the
* corner block with the funnel switched OFF.
*
* The venturi lives in the SITE json; the sweep built its wind from the STORM def
* alone and never called setVenturi, which main.js:424 does at every site load.
* So the tool whose entire job is "catch a site that lies about its difficulty"
* was itself lying about the difficulty of the only site that has a venturi
* under-reporting every corner load on the yard whose whole personality is the
* gap. It reported PASS with $20 lines. That is the SPRINT6 p1=7.4 kN failure
* inverted: there the tool called a fine site unriggable, here it called a mean
* site cheap. A tool built to catch drift must not be the thing that drifts.
*
* The assert below is written to FAIL if the setVenturi calls are removed from
* sweep.js: with the funnel dropped, the funnelled and unfunnelled sweeps
* collapse to the same peak loads and the strict inequality goes red.
*/
import { auditSweep } from './sweep.js';
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/**
* A deliberately synthetic yard, not site_02: four anchors around a bed, sized to
* sit inside AUDIT.BAND so exactly one quad sweeps. Synthetic because this test
* pins the SWEEP's plumbing, not the corner block's balance site_02's numbers
* are C's to tune and would make this assert fail every time they moved.
*/
const ANCHORS = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const BED = { x: 0, z: 0, w: 4, d: 4 };
/** A storm blowing dead along +Z, and a funnel whose axis matches it. */
const STORM = {
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null };
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
const peaksOf = (venturi) => {
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi });
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
return rows[0].tiers.map((c) => c.peak);
};
test('sweep honours the SITE venturi, not just the storm', () => {
const bare = peaksOf([]);
const funnelled = peaksOf(FUNNEL);
assert(bare.length === funnelled.length, 'the two sweeps disagree about corner count');
// Every corner must pull HARDER through the funnel. Strictly — an equal read
// is the exact bug: it means setVenturi never reached the wind.
for (let i = 0; i < bare.length; i++) {
assert(funnelled[i] > bare[i],
`corner ${i}: funnelled peak ${(funnelled[i] / 1000).toFixed(2)} kN is not above bare ` +
`${(bare[i] / 1000).toFixed(2)} kN — the site's venturi is not reaching the sweep's wind ` +
`(sweep.js must call wind.setVenturi, the way main.js does at every site load)`);
}
});
test('no venturi is a no-op — a site without a funnel is untouched', () => {
// The other half of the contract: backyard_01 ships "venturi": [], and this
// fix must not have moved a single number on it. Default arg == explicit [].
const omitted = peaksOf(undefined);
const empty = peaksOf([]);
for (let i = 0; i < empty.length; i++) {
assert(Math.abs(omitted[i] - empty[i]) < 1e-9,
`corner ${i}: omitting venturi read ${omitted[i]} but [] read ${empty[i]} — a funnel-less site must be byte-identical`);
}
});
export const SWEEP_TESTS = TESTS;

View File

@ -21,7 +21,7 @@
<canvas id="c"></canvas> <canvas id="c"></canvas>
<div id="dev"></div> <div id="dev"></div>
<div id="hint">Lane B harness — the prep phase only. <div id="hint">Lane B harness — the prep phase only.
ENTER commits the rig and starts a storm. R resets to prep.</div> ENTER commits the rig and starts a storm. R resets to prep. N switches yard.</div>
<script type="module"> <script type="module">
/** /**
@ -34,7 +34,7 @@ ENTER commits the rig and starts a storm. R resets to prep.</div>
* being verified is the thing that ships. Retire it once index.html hosts prep. * being verified is the thing that ships. Retire it once index.html hosts prep.
*/ */
import * as THREE from 'three'; import * as THREE from 'three';
import { createWorld } from './js/world.js'; import { createWorld, loadSite } from './js/world.js';
import { createCameraRig } from './js/camera.js'; import { createCameraRig } from './js/camera.js';
import { createWind, loadStorm } from './js/weather.js'; import { createWind, loadStorm } from './js/weather.js';
import { SailRig, createSailView } from './js/sail.js'; import { SailRig, createSailView } from './js/sail.js';
@ -63,12 +63,33 @@ const windProxy = {
gustTelegraph: (t) => wind.gustTelegraph(t), gustTelegraph: (t) => wind.gustTelegraph(t),
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [], eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); }, 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); },
}; };
const world = createWorld(scene, { wind: windProxy }); /**
* 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); const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids); cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt); cameraRig.setGround(world.heightAt);
windProxy.setVenturi(siteDef.wind?.venturi ?? []);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree')); windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
const rig = new SailRig({ anchors: world.anchors }); const rig = new SailRig({ anchors: world.anchors });
@ -95,18 +116,60 @@ const ui = await createRiggingUI({
let phase = 'prep'; let phase = 'prep';
ui.setActive(true); 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 // harness-only handle, so a debugger (or an agent driving this page) can pick
// anchors without hunting for pixels. Never imported by the game. // anchors without hunting for pixels. Never imported by the game.
window.__laneB = { window.__laneB = {
ui, rig, world, cameraRig, scene, ui, rig, world, cameraRig, scene,
get phase() { return phase; }, 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 */ /** rig by id, exactly as if you'd clicked it */
pick: (id) => ui.session.rig(id), pick: (id) => ui.session.rig(id),
cycle: (id) => ui.session.cycleHardware(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) => { addEventListener('keydown', (e) => {
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); } 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') { if (e.key.toLowerCase() === 'r') {
phase = 'prep'; phase = 'prep';
wind = calmWind; wind = calmWind;

View File

@ -83,6 +83,37 @@ export class RiggingSession {
return this; return this;
} }
/**
* Re-bank the shop for a new night. [Lane A's ask THREADS, SPRINT10]
*
* A had to poke `_startBudget` and then call `reset()` from main.js their
* self-described "one private touch" into this module, done loudly in a
* comment rather than by forking the file. This is that pair, named.
*
* It RESETS, deliberately. A new bank only ever arrives at a night boundary
* (main.js sets it at the forecast), and carrying last night's picks into a
* re-banked shop would leave four corners rigged that this wallet never paid
* for `budget` would say $80 with $20 of hardware already hanging in the
* yard. "New budget" and "fresh prep phase" are the same event here; splitting
* them would only move the bug into the caller.
*/
setBudget(n) {
this._startBudget = n;
return this.reset();
}
/**
* Re-point the session at a new yard's anchors. [SPRINT11 the site switch]
*
* Picks die with the old site: they are anchor IDs, and `p4` on the backyard
* is not `p4` on the corner block. Keeping them would rig a quad out of
* anchors that no longer exist.
*/
setAnchors(anchors) {
this.anchors = anchors;
return this.reset();
}
get spent() { return START_BUDGET - this.budget; } get spent() { return START_BUDGET - this.budget; }
get canStart() { return this.picks.length === MAX_CORNERS; } get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); } isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
@ -251,7 +282,7 @@ export async function createRiggingUI({
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6); const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
const pickMat = new THREE.MeshBasicMaterial({ visible: false }); const pickMat = new THREE.MeshBasicMaterial({ visible: false });
const markers = world.anchors.map((a) => { const makeMarker = (a) => {
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 }); const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
const ring = new THREE.Mesh(ringGeo, mat); const ring = new THREE.Mesh(ringGeo, mat);
const dot = new THREE.Mesh(dotGeo, mat); const dot = new THREE.Mesh(dotGeo, mat);
@ -261,8 +292,19 @@ export async function createRiggingUI({
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase())); holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
group.add(holder); group.add(holder);
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] }; return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
}); };
const pickTargets = markers.map((m) => m.hit);
/** One marker's GPU-side leftovers. The shared geometries are NOT ours to free. */
const disposeMarker = (m) => {
group.remove(m.holder);
m.mat.dispose();
m.label.material.map?.dispose(); // each label is its own canvas texture
m.label.material.dispose();
};
// `let`, not `const`: setWorld rebuilds both on a site change.
let markers = world.anchors.map(makeMarker);
let pickTargets = markers.map((m) => m.hit);
// --- quad preview ------------------------------------------------------ // --- quad preview ------------------------------------------------------
// A closed loop through the ring-ordered picks: this is the shape you are // A closed loop through the ring-ordered picks: this is the shape you are
@ -350,11 +392,13 @@ export async function createRiggingUI({
function refresh() { function refresh() {
if (!el) return; if (!el) return;
const s = session.summary; const s = session.summary;
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
// block ships `tr1b` and the column broke the moment a second site existed.
const rows = world.anchors.map((a) => { const rows = world.anchors.map((a) => {
const pick = session.pickOf(a.id); const pick = session.pickOf(a.id);
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)}`; if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)}`;
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : ''; const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`; return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
}); });
const area = quadArea(); const area = quadArea();
el.textContent = [ el.textContent = [
@ -375,6 +419,38 @@ export async function createRiggingUI({
get canStart() { return session.canStart; }, get canStart() { return session.canStart; },
get active() { return active; }, get active() { return active; },
/**
* Re-point the whole UI at a new yard. [Lane A's ask THREADS, SPRINT10]
*
* The site switch's last piece. `createRiggingUI` built its markers and its
* session from `world.anchors` at construction and had no way to re-point
* them, so on a site change the corner block's markers didn't exist and the
* panel still listed the backyard. A re-points what they can reach from
* main.js (`rig.anchors`, `session.anchors`) which is why night 3 has been
* riggable from code and from the audit but not from the MOUSE. This is the
* mouse.
*
* A calls it inside `loadSiteInto`, guarded (`rigging.setWorld?.(world)`),
* so it lights up the day it lands with no change on their side.
*
* Markers are rebuilt, not repositioned: the anchor SET changes across sites
* (12 in the backyard, 10 on the corner block, different ids), so there is no
* per-marker correspondence to preserve. Each marker owns a material and a
* canvas-texture label, so the old ones are disposed rather than dropped
* five site switches a week, every week, is a real leak otherwise.
*/
setWorld(w) {
world = w;
for (const m of markers) disposeMarker(m);
markers = world.anchors.map(makeMarker);
pickTargets = markers.map((m) => m.hit);
hovered = null; // a stale id would highlight nothing, forever
session.setAnchors(world.anchors); // picks are old-site anchor ids — they die here
preview.visible = false;
refresh();
return ui;
},
/** Lane A: call on phaseChange — markers and clicks are prep-only. */ /** Lane A: call on phaseChange — markers and clicks are prep-only. */
setActive(on) { setActive(on) {
active = !!on; active = !!on;
@ -439,9 +515,9 @@ export async function createRiggingUI({
domElement.removeEventListener('pointermove', onPointerMove); domElement.removeEventListener('pointermove', onPointerMove);
removeEventListener('keydown', onKeyDown); removeEventListener('keydown', onKeyDown);
scene.remove(group); scene.remove(group);
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose(); ringGeo.dispose(); dotGeo.dispose(); pickGeo.dispose(); pickMat.dispose(); previewGeo.dispose();
preview.material.dispose(); preview.material.dispose();
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); } for (const m of markers) disposeMarker(m); // same teardown setWorld uses
el?.remove(); el?.remove();
}, },
}; };

View File

@ -114,6 +114,48 @@ test('you cannot afford good hardware on all four corners', () => {
return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`; return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`;
}); });
// ---- the site switch (SPRINT11 — Lane A's setBudget/setWorld asks) ----------
test('setBudget re-banks the shop and clears the night before it', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20 of carabiners hanging
assert(s.budget === START_BUDGET - 20, `budget $${s.budget}`);
s.setBudget(150); // a new night, a fatter wallet
assert(s.budget === 150, `re-banked budget is $${s.budget}, expected $150`);
// The bug this exists to stop: a re-banked wallet still holding last night's
// rig would read $150 with four corners already up — hardware nobody paid for.
assert(s.picks.length === 0, `${s.picks.length} picks survived the re-bank — this wallet never paid for them`);
// and the new bank has to STICK through a reset, or "play again" hands back $80
s.rig('h1'); s.reset();
assert(s.budget === 150, `reset fell back to $${s.budget} — setBudget must move the START budget, not just the balance`);
return `re-banked to $150, prep phase clean, sticks through reset`;
});
test('setAnchors moves the session to a new yard and drops the old picks', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
// the corner block: a different anchor SET, and no h1/p1 anywhere in it
const CORNER_BLOCK = [
{ id: 'q1', type: 'post', pos: { x: -3.95, y: 3.94, z: -2.82 } },
{ id: 'q3', type: 'post', pos: { x: 2.25, y: 3.99, z: 4.5 } },
{ id: 'cb1', type: 'post', pos: { x: -8.41, y: 2.29, z: -3 } },
{ id: 'cp2', type: 'post', pos: { x: -8.41, y: 1.68, z: -5.61 } },
].map((a) => ({ ...a, sway: () => a.pos }));
s.setAnchors(CORNER_BLOCK);
// `p4` on the backyard is not `p4` on the corner block, and `h1` is nowhere:
// picks are anchor IDs, so carrying them across a site rigs a quad from anchors
// that do not exist.
assert(s.picks.length === 0, `${s.picks.length} backyard picks followed the session to the corner block`);
assert(!s.rig('h1').ok, 'h1 is a backyard anchor and must not rig on the corner block');
assert(s.rig('cb1').ok, 'cb1 is a corner-block anchor and should rig');
return 'session followed the site; stale picks did not';
});
test('picks come back ring-ordered however you click them', () => { test('picks come back ring-ordered however you click them', () => {
const s = session(); const s = session();
// deliberately crossing order: two diagonals first // deliberately crossing order: two diagonals first

View File

@ -26,9 +26,13 @@
import { SAIL_TESTS } from '../sail.selftest.js'; import { SAIL_TESTS } from '../sail.selftest.js';
import { RIGGING_TESTS } from '../rigging.selftest.js'; import { RIGGING_TESTS } from '../rigging.selftest.js';
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
/** @param {import('../testkit.js').Suite} t */ /** @param {import('../testkit.js').Suite} t */
export default function run(t) { export default function run(t) {
for (const [name, fn] of SAIL_TESTS) t.test(name, fn); for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn); for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
// site_audit's own sweep. SPRINT11: the tool was flying site_02 with the
// venturi switched off — the auditor needed an auditor. See sweep.selftest.js.
for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn);
} }