diff --git a/THREADS.md b/THREADS.md index 3de2be4..204691f 100644 --- a/THREADS.md +++ b/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).) diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html index 66bd1cb..b44974a 100644 --- a/tools/site_audit/audit.html +++ b/tools/site_audit/audit.html @@ -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'}`; } diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index 53a59f1..e83d84e 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -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.`); diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js index b0c7574..a799a37 100644 --- a/tools/site_audit/sweep.js +++ b/tools/site_audit/sweep.js @@ -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 = []; diff --git a/tools/site_audit/sweep.selftest.js b/tools/site_audit/sweep.selftest.js new file mode 100644 index 0000000..a61094e --- /dev/null +++ b/tools/site_audit/sweep.selftest.js @@ -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; diff --git a/web/world/dev_rigging.html b/web/world/dev_rigging.html index fe8f9e1..a331d67 100644 --- a/web/world/dev_rigging.html +++ b/web/world/dev_rigging.html @@ -21,7 +21,7 @@