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:
parent
b38311021c
commit
4095df8b10
126
THREADS.md
126
THREADS.md
@ -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;
|
||||
· A decides the carport collateral number and the Anchor.type enum widening D flagged;
|
||||
· 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).)
|
||||
|
||||
@ -75,12 +75,22 @@ async function run() {
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.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 =
|
||||
`${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` +
|
||||
`venturi: ${vtxt}\n` +
|
||||
`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
|
||||
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.`;
|
||||
}
|
||||
// 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'}`;
|
||||
}
|
||||
|
||||
|
||||
@ -65,6 +65,7 @@ const BACKYARD_01 = {
|
||||
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
|
||||
dumped: true,
|
||||
bed: { x: 1, z: 2, w: 6, d: 4 },
|
||||
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
|
||||
anchors: [
|
||||
// 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],
|
||||
@ -161,7 +162,9 @@ async function loadSite(path) {
|
||||
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 },
|
||||
}));
|
||||
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 }));
|
||||
@ -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(`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`);
|
||||
|
||||
// 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') {
|
||||
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
|
||||
|
||||
@ -8,8 +8,9 @@
|
||||
* 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}),
|
||||
* the bed rect, and the storm + calm-day defs. It flies the same settle+storm the
|
||||
* game flies and returns ranked rows + a verdict. No I/O, no process, no DOM.
|
||||
* the bed rect, the storm + calm-day defs, and the site's venturi list. It flies
|
||||
* 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';
|
||||
@ -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.stormDef the storm JSON to fly
|
||||
* @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 } }}
|
||||
*/
|
||||
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
|
||||
const cands = [];
|
||||
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).
|
||||
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
|
||||
// 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 wind = createWind(stormDef);
|
||||
wind.setVenturi(venturi);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
|
||||
90
tools/site_audit/sweep.selftest.js
Normal file
90
tools/site_audit/sweep.selftest.js
Normal 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;
|
||||
@ -21,7 +21,7 @@
|
||||
<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.</div>
|
||||
ENTER commits the rig and starts a storm. R resets to prep. N switches yard.</div>
|
||||
|
||||
<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.
|
||||
*/
|
||||
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 { createWind, loadStorm } from './js/weather.js';
|
||||
import { SailRig, createSailView } from './js/sail.js';
|
||||
@ -63,12 +63,33 @@ const windProxy = {
|
||||
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); },
|
||||
};
|
||||
|
||||
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);
|
||||
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 });
|
||||
@ -95,18 +116,60 @@ const ui = await createRiggingUI({
|
||||
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;
|
||||
|
||||
@ -83,6 +83,37 @@ export class RiggingSession {
|
||||
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 canStart() { return this.picks.length === MAX_CORNERS; }
|
||||
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 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 ring = new THREE.Mesh(ringGeo, 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()));
|
||||
group.add(holder);
|
||||
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 ------------------------------------------------------
|
||||
// A closed loop through the ring-ordered picks: this is the shape you are
|
||||
@ -350,11 +392,13 @@ export async function createRiggingUI({
|
||||
function refresh() {
|
||||
if (!el) return;
|
||||
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 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' : '';
|
||||
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();
|
||||
el.textContent = [
|
||||
@ -375,6 +419,38 @@ export async function createRiggingUI({
|
||||
get canStart() { return session.canStart; },
|
||||
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. */
|
||||
setActive(on) {
|
||||
active = !!on;
|
||||
@ -439,9 +515,9 @@ export async function createRiggingUI({
|
||||
domElement.removeEventListener('pointermove', onPointerMove);
|
||||
removeEventListener('keydown', onKeyDown);
|
||||
scene.remove(group);
|
||||
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
|
||||
ringGeo.dispose(); dotGeo.dispose(); pickGeo.dispose(); pickMat.dispose(); previewGeo.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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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`;
|
||||
});
|
||||
|
||||
// ---- 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', () => {
|
||||
const s = session();
|
||||
// deliberately crossing order: two diagonals first
|
||||
|
||||
@ -26,9 +26,13 @@
|
||||
|
||||
import { SAIL_TESTS } from '../sail.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 */
|
||||
export default function run(t) {
|
||||
for (const [name, fn] of SAIL_TESTS) t.test(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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user