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;
|
· 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).)
|
||||||
|
|||||||
@ -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'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.`);
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
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>
|
<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;
|
||||||
|
|||||||
@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user