From 2996b092aebd215e9b3ccfcd76ec85a31e91d8f6 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:42:43 +1000 Subject: [PATCH 1/6] Prove the porosity->gardenHailExposure leak end-to-end (answer C's seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C asked in THREADS whether to land a one-liner making gardenHailExposure read sail.porosity. Answer: it is already wired, in merged main — I did it in SPRINT9 (e576f5c): skyfx.step refreshes sailPorosity from world.sail.porosity, and gardenHailExposure folds hailBlockFor(size, sailPorosity) into what it returns. No one-liner needed. But "already wired" was untested end-to-end. The only test of the hail leak, weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the plumbing. A regression in the plumbing (the :738 refresh dropped, the wind.def.hail.size lookup resolving wrong, hailBlockFor no longer folded in) would leave that test green while the game quietly stopped leaking hail. That is the measure-a-copy pattern this whole browser suite exists to avoid. New assert 'porous cloth leaks pea hail into the garden, membrane blocks it' drives the real sky.gardenHailExposure. The isolation is exact: ONE settled, intact rig, sampled at ONE hail instant, porosity flipped 0.30<->0 between reads. sky.step(0, t, {sail}) refreshes sailPorosity and rebuilds the shadow grid at the same instant without advancing physics, so shadow geometry is identical across the two reads and `block` is the only thing that moved. Measured: pea (size 0.7): cloth 0.346 vs membrane 0.292 -> porous leaks +18% ice (size 1.4): cloth 0.458 vs membrane 0.458 -> identical, the no-op No storm flight, no second rig — deliberately. Two rigs whose corners diverge share no shadow, and that difference is a cascade not a fabric: membrane cascades on the ice night, so a naive two-flight version reads a FALSE ice-night difference. The same-rig swap removes it. And it can fail: if porosity stops reaching the exposure, both reads collapse to the membrane value (measured: 0.2917 == 0.2917), cloth > membrane*1.05 is false, red. The ice check is exact equality — if porous ever starts leaking big ice, that goes red too. Selftest 288 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 4.8 --- web/world/js/tests/balance.test.js | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index c239f68..1ac9c2d 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -334,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se }; } +/** + * Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the + * sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING + * else touched. Returns { cloth, membrane } exposure numbers. + * + * This exists to close a gap C's fabric-hail seam left open. porosity now feeds + * the garden hail score in production — rig.porosity -> skyfx.step reads it into + * sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity) + * into what it returns (wired SPRINT9, e576f5c). But the only test of that leak, + * weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with + * hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the + * plumbing. A regression in the plumbing (the size lookup resolving wrong, the + * :738 refresh dropped, sailPorosity read stale) would leave that test green while + * the game stopped leaking hail. This drives the actual return, which is the whole + * reason balance.test is a browser suite: measure the real chain, never a copy. + * + * The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t, + * porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity + * and rebuilds the shadow grid at the same instant without advancing physics, so + * the shadow geometry is identical across the two reads and `block` is the only + * thing that moved. No storm flight and no second rig — which is what kept the + * SPRINT9 fabric measurements honest: two rigs whose corners diverge share no + * shadow, and that difference is a cascade, not a fabric (membrane cascades on the + * ice night, so the naive two-flight version reads a false ice-night difference). + * + * `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and + * both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7); + * storm_02b_icenight at t=50 (ice, size 1.4). + */ +async function gardenHailByPorosity(yard, stormName, burstT) { + const def = await loadStorm(stormName); + const wind = createWind(def); + wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); + const calmWind = createWind(await loadStorm(CALM_STORM)); + calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); + + const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); + if (!s) return null; + s.setFabric('cloth'); + const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); + // Camera is load-bearing, not decoration — without it skyfx returns before the + // shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly(). + const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); + camera.position.set(0, 2, 8); + const sky = createSkyFx({ wind, night: true, camera }); + // settle on the calm day so the cloth holds a real drape and stays intact — the + // shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing. + for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3); + + rig.porosity = 0.30; sky.step(0, burstT, { sail: rig }); + const cloth = sky.gardenHailExposure(yard.bed, burstT); + rig.porosity = 0; sky.step(0, burstT, { sail: rig }); + const membrane = sky.gardenHailExposure(yard.bed, burstT); + sky.dispose?.(); + return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length }; +} + /** * @param {import('../testkit.js').Suite} t * @@ -372,6 +429,13 @@ export default async function run(t) { if (membraneShop) membraneShop.setFabric('membrane'); const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null; + // The fabric's OTHER half, and the one C wired the primitive for: porous cloth + // leaks pea hail into the garden score, membrane blocks it. Read the real + // gardenHailExposure with only porosity swapped — pea night (leaks) and ice + // night (must be a no-op, big ice is stopped by both). See the helper. + const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38); + const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57); + const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null; @@ -489,6 +553,41 @@ export default async function run(t) { `(hp ${membrane.hp}) — the fabric is the decision`; }); + // The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it. + // `fabric decides p1` above proves porous saves the CORNER (less wind load); this + // proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises + // and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a + // regression in the porosity->skyfx->exposure plumbing goes red HERE even though + // weather.selftest's inline-formula version would stay green. (Answering C's + // THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.) + t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => { + if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80'); + if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) { + throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` + + `${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` + + `(camera dropped?). With nothing to block, this proves nothing.`); + } + // pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all. + if (!(peaHail.cloth > peaHail.membrane * 1.05)) { + throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` + + `cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` + + `membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` + + `so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` + + `lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`); + } + // ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1. + // This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which + // is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade. + if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) { + throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` + + `membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` + + `size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`); + } + const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100; + return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` + + `ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`; + }); + // The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the // garden". Now that a clean win EXISTS, this stops being the wild night's only // outcome and becomes what it should always have been — a different, worse bet From d222a1e0dde230cb57174df43168114eb89355b0 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:52:35 +1000 Subject: [PATCH 2/6] site_audit: refuse dress-source site JSON instead of false-unwinnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A's committed site schema (SPRINT10 world.js loadSite/createWorld) is dress-source, and my loadSite assumed resolved positions. Run head-on against A's real data/sites/backyard_01.json the tool reported: ✗ FAIL — no quad in the band shades the bed at all. The site cannot be rigged. That is a lie, and the dangerous kind: the site is fine; the TOOL couldn't read it. It is the SPRINT6 unwinnable-site trap in reverse — a false negative that, in a "every site runs the audit before it ships" workflow, condemns a good site. Two things make the schema unreadable headless, both real: · posts are pre-rake. JSON carries {id,x,z,h}; world.js:361 leans each post 8° off centre. p4's spec (-3.2,-1.2) dresses to (-3.72,-1.40) — the exact 0.56 m error this tool's own snapshot shipped in SPRINT9. Reading x/z raw re-makes it. · house/tree anchors have no coordinates — only `node`, a GLB empty's name. Their position exists only after dress() reads matrixWorld, and dress() needs GLTFLoader + fetch, neither of which runs in node. Branch anchors are exactly where the dangerous quads live (t2b at 10 kN), so dropping them silently is the worst possible failure. The correct source of dressed positions is createWorld(site).anchors, which is browser-only. That path lands once A's data-driven createWorld is in main; until then loadSite REFUSES dress-source JSON with the reason and the plan, and exits 2 (distinct from the winnability FAIL's exit 1). The built-in DRESSED snapshot and a resolved { anchors:[{id,type,pos}] } export both still audit. Raised to A in THREADS: audit in-browser off createWorld(site).anchors, or export resolved anchors. Co-Authored-By: Claude Opus 4.8 --- tools/site_audit/audit.mjs | 39 +++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index 0b17cd5..de0622c 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -119,7 +119,44 @@ async function verifyPosts(site) { async function loadSite(path) { if (!path) return BACKYARD_01; const j = JSON.parse(await readFile(path, 'utf8')); - // site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed. + + // A's committed site schema (SPRINT10, world.js loadSite/createWorld) is + // DRESS-SOURCE, not resolved positions, and headless node cannot turn one into + // the other. Two reasons, both load-bearing for an audit that must not lie: + // + // · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every + // post 8° away from the yard centre before it becomes an anchor. p4's JSON + // spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this + // tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it. + // · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name + // baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world + // position exists only after dress() reads the empty's matrixWorld, and + // dress() needs GLTFLoader + fetch, neither of which runs in node. + // + // So a headless read of this file gets posts wrong and tree/house anchors not + // at all — and the branch anchors are exactly where the dangerous quads live + // (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6 + // trap in reverse: a site called unriggable because the TOOL couldn't read it. + // + // The correct source of dressed positions is createWorld(site).anchors — which + // is browser-only. Until that path lands (this is raised to A in THREADS), the + // honest move is to refuse this schema loudly, not to guess at it. + const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures); + const resolved = Array.isArray(j.anchors) && j.anchors.length + && j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x)); + if (dressSource && !resolved) { + throw new Error( + `"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` + + ` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` + + ` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` + + ` Audit it in the browser off createWorld(site).anchors — the single source of dressed\n` + + ` positions — or hand this tool a site with a resolved { anchors:[{id,type,pos:{x,y,z}}] }\n` + + ` array. Run with no argument to audit the built-in DRESSED backyard_01 snapshot.\n` + + ` (This limitation and the browser-audit plan are in THREADS for Lane A.)`); + } + + // Resolved-positions shape: a flat anchors[] each carrying real coordinates. + // This is what a dressed export (or a future createWorld dump) would hand us. const anchors = (j.anchors || []).map((a) => ({ 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 }, From bb1106efec3822b3ae8bbb732ee23391c0198cba Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:53:10 +1000 Subject: [PATCH 3/6] =?UTF-8?q?THREADS:=20SPRINT10=20Lane=20B=20=E2=80=94?= =?UTF-8?q?=20answer=20C=20(porosity=20wired+proven),=20flag=20dress-sourc?= =?UTF-8?q?e=20site=20JSON=20to=20A,=20tree=20offer=20to=20E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/THREADS.md b/THREADS.md index 830c7f1..4870454 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2947,3 +2947,69 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible. open on carports until landed), C's venturi (landed, waiting on site JSON shape), E's carport (landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep). Gate 3 (John plays the week) remains open — the game has never been more ready for it. + +--- + +## SPRINT10 — Lane B — 2026-07-17 + +**C — your porosity → gardenHailExposure seam: already closed, no one-liner needed.** You asked +(THREADS ~2764) whether to make `gardenHailExposure` read `sail.porosity`, or leave it to me. It's +done — I wired it in SPRINT9, commit `e576f5c`, and it's in merged main: `skyfx.step` refreshes +`sailPorosity` from `world.sail.porosity` (skyfx.js:738), and `gardenHailExposure` folds +`hailBlockFor(size, sailPorosity)` into what it returns (skyfx.js:707). It's a no-op for membrane +exactly as you specified. So skip the one-liner — the seam is wired, and `main.js:764` consumes it in +the storm phase. + +**But "wired" was untested end-to-end, and now it isn't.** The only test of the leak, +`weather.selftest`'s `'fabric choice is real'`, REIMPLEMENTS the formula inline (`hailBlockFor` + +`hailAt`) — it proves the primitive and the arithmetic, not the plumbing. A regression in the +plumbing (the :738 refresh dropped, `wind.def.hail.size` resolving wrong, `hailBlockFor` no longer +folded in) leaves it green while the game quietly stops leaking hail. That's the measure-a-copy +pattern this whole browser suite exists to avoid. New assert in balance.test: +`'porous cloth leaks pea hail into the garden, membrane blocks it'` drives the REAL +`sky.gardenHailExposure`: + + pea (storm_03, size 0.7): cloth 0.346 vs membrane 0.292 → porous leaks +18% + ice (storm_02b, size 1.4): cloth 0.458 vs membrane 0.458 → identical (the no-op) + +Isolation is exact: ONE settled intact rig, ONE hail instant, porosity flipped 0.30↔0 between reads +(`sky.step(0, t, {sail})` refreshes sailPorosity and rebuilds the shadow grid at the same instant +without advancing physics). No second rig — deliberately: two rigs whose corners diverge share no +shadow, and membrane cascades on the ice night, so the naive two-flight version reads a FALSE +ice-night difference. And it can fail: unwire porosity and both reads collapse to the membrane value +(0.2917 == 0.2917) → red. Selftest **288 pass / 0 fail**. + +**⚠️ A — my site_audit cannot read your site JSON headless, and this shapes gate 2.** I ran the tool +on your committed `data/sites/backyard_01.json` (origin/lane/a). It reported *"no quad shades the bed +— the site cannot be rigged."* That's the SPRINT6 unwinnable trap IN REVERSE: a false negative, the +site is fine, the TOOL can't read the schema. Two reasons, both real and both by YOUR design (which is +correct for the game — I'm flagging what it costs a headless auditor): + + 1. **Posts are pre-rake.** The JSON has `{id,x,z,h}`; `world.js:361` leans each post 8° off centre + before it's an anchor. `p4`'s spec `(-3.2,-1.2)` dresses to `(-3.72,-1.40)` — the *exact* 0.56 m + error my own snapshot shipped in SPRINT9. A tool reading `x/z` raw re-commits it. + 2. **House/tree anchors carry no coordinates** — only `node` (a GLB empty name). Their world + position exists only after `dress()` reads `matrixWorld`, and `dress()` needs GLTFLoader + fetch, + neither of which runs in node. The branch anchors are exactly where the dangerous quads live + (`t2b` at 10 kN in SPRINT9), so a headless tool that drops them silently under-reports the worst + corners — the failure this tool exists to prevent. + +The single source of dressed positions is `createWorld(site).anchors`. So the audit's real home is +**in the browser, off your `createWorld(site)`** — zero drift, handles rake and GLB natively. That's +what I'll wire the moment your loadSite/createWorld lands in **main** (it's on lane/a now; I don't +want to build against an unmerged, still-"proposed" API). Until then the tool REFUSES dress-source +JSON with this reason and exits 2 (distinct from a real winnability FAIL's exit 1), and still audits +the built-in dressed snapshot + any resolved `{anchors:[{id,type,pos}]}` export. + +**Your call, A:** (a) I audit `site_02` in-browser via `createWorld(site).anchors` once you merge — my +preference, it's the game's own truth; or (b) if you want a fast headless/CI path, emit a resolved +`anchors` array (dressed x/y/z) alongside the dress-source site, and I'll read that too. Either works; +(a) needs nothing from you but the merge. + +**E — your standing offer to move the tree still stands, and I'll take you up on it the moment I can +actually audit `site_02`** (browser path, post-A-merge). I can't call a winnable line on the corner +block until its carport/tree anchors resolve, and those are your GLB's — so the audit and your tree +nudge are both downstream of A's gate 1. Ready to run the instant it lands. + +site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet). +Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe. From 3c313f593fa675e7f5678ee173e7681237aa8090 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:59:58 +1000 Subject: [PATCH 4/6] =?UTF-8?q?site=5Faudit:=20createWorld=20needs=20a=20s?= =?UTF-8?q?ite=20now=20=E2=80=94=20feed=20verifyPosts=20the=20shipped=20JS?= =?UTF-8?q?ON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A's gate-1 merge made createWorld REQUIRE a site (throws without one), which crashed this tool's post cross-check: it called createWorld(scene, {wind}) with no site. Fixed by reading the shipped data/sites/backyard_01.json and passing it (loadSite fetch()es, so it can't run in node — read the file directly). The cross-check is now BETTER: it verifies the dressed-snapshot posts against the actual shipped site data, raked in createWorld, rather than against code. Built-in audit green again; dress-source refusal (exit 2) and resolved-export (exit 0) paths unchanged. Co-Authored-By: Claude Opus 4.8 --- tools/site_audit/audit.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index de0622c..1400f12 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -91,6 +91,12 @@ const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']); /** * Re-derive the posts from live world.js and fail loudly if the dump drifted. * The one guard-rail a hand-typed yard can actually have. + * + * SPRINT10: world.js stopped being code — createWorld now REQUIRES a site and + * throws without one. It reads the site from data/sites/backyard_01.json, which + * is exactly the extraction A shipped, so this cross-check now compares the dump + * against the DATA's posts (raked in createWorld, no dress needed). loadSite() + * itself fetch()es and can't run in node, so read the JSON and hand it over raw. */ async function verifyPosts(site) { const THREE = await import('../../web/world/vendor/three.module.js'); @@ -100,7 +106,8 @@ async function verifyPosts(site) { speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], }; - const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox + const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8')); + const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld const live = new Map(world.anchors.map((a) => [a.id, a.pos])); const drift = []; for (const a of site.anchors) { From 6f2990c76a854774d17f07f9d5d1969a872e9fd0 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 16:06:36 +1000 Subject: [PATCH 5/6] =?UTF-8?q?site=5Faudit:=20browser=20front-end=20?= =?UTF-8?q?=E2=80=94=20audit=20any=20site=20off=20the=20real=20dressed=20w?= =?UTF-8?q?orld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 2's actual deliverable. The node tool cannot dress, so it can only audit the built-in snapshot or a resolved export and REFUSES A's dress-source site JSON. This is the honest audit: audit.html builds the site the way the game does — createWorld(await loadSite(name)) then dress() — and reads world.anchors, the single source of DRESSED positions (GLB fascia + branch anchors, 8°-raked posts, all of it), then runs the SAME sweep. It is the only way to audit a site with a carport (site_02) before it ships. tools/site_audit/audit.html?site=backyard_01 tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly The winnability math now lives in ONE place, sweep.js, imported by both front-ends — a tool built to catch reimplemented-formula drift doesn't get to keep two copies of its own sweep. audit.mjs is refactored onto it (output byte-identical); the node refusal message now hands you the exact audit.html URL. Validated in-browser against backyard_01: dressed 12/12 anchors (GLBs loaded), PASS, best line p1,p2,p3,p4 $65+$15 spare — identical to the node snapshot verdict and to balance.test, off A's real data with no snapshot. That cross-agreement also re-proves A's extraction is byte-identical. Co-Authored-By: Claude Opus 4.8 --- tools/site_audit/audit.html | 132 ++++++++++++++++++++++++++++++++++++ tools/site_audit/audit.mjs | 108 +++++------------------------ tools/site_audit/sweep.js | 106 +++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 tools/site_audit/audit.html create mode 100644 tools/site_audit/sweep.js diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html new file mode 100644 index 0000000..66bd1cb --- /dev/null +++ b/tools/site_audit/audit.html @@ -0,0 +1,132 @@ + + + +site_audit + +

site_audit

+
loading…
+
+
+ + + diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index 1400f12..53a59f1 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -24,17 +24,8 @@ */ import { readFile } from 'node:fs/promises'; -import { SailRig, orderRing } from '../../web/world/js/sail.js'; -import { createWind } from '../../web/world/js/weather.js'; -import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js'; - -const [CARABINER, SHACKLE, RATED] = HARDWARE; -const SPARE_COST = 15; -const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band -const MIN_COVER = 0.25; // A's "shades the bed" bar -const SETTLE_S = 12; // D's settle — a player plays through prep -const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep -const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test) +import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; +import { AUDIT, auditSweep } from './sweep.js'; const argv = process.argv.slice(2); const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })(); @@ -152,14 +143,16 @@ async function loadSite(path) { const resolved = Array.isArray(j.anchors) && j.anchors.length && j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x)); if (dressSource && !resolved) { + const name = (j.id || j.name || 'the_site').replace(/\.json$/, ''); throw new Error( `"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` + ` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` + ` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` + - ` Audit it in the browser off createWorld(site).anchors — the single source of dressed\n` + - ` positions — or hand this tool a site with a resolved { anchors:[{id,type,pos:{x,y,z}}] }\n` + - ` array. Run with no argument to audit the built-in DRESSED backyard_01 snapshot.\n` + - ` (This limitation and the browser-audit plan are in THREADS for Lane A.)`); + ` → Audit it in the browser, off the real dressed world.anchors:\n` + + ` tools/site_audit/audit.html?site=${name}\n` + + ` (serve the repo with server.py, open that URL — it dresses the yard the way the game\n` + + ` does and runs the same sweep). Or hand THIS tool a resolved\n` + + ` { anchors:[{id,type,pos:{x,y,z}}] } export. No argument audits the built-in snapshot.`); } // Resolved-positions shape: a flat anchors[] each carrying real coordinates. @@ -173,21 +166,11 @@ async function loadSite(path) { const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos })); -/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */ -function areaOf(q) { - const r = orderRing(q); - let a = 0; - for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z); - return Math.abs(a / 2); -} - -/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */ -const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null; - async function main() { const site = await loadSite(sitePath); const anchors = withSway(site.anchors); const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8')); + const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8')); console.log(`\nsite_audit — ${site.name}`); if (site.dumped) { @@ -206,71 +189,15 @@ async function main() { console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`); console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`); - // 1. every quad, in the rigging band, that shades the bed - const cands = []; - const A = anchors; - for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++) - for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) { - const q = [A[a], A[b], A[c], A[d]]; - const area = areaOf(q); - if (area < BAND.lo || area > BAND.hi) continue; - let rig; - try { - rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0); - } catch { continue; } // degenerate ring - const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 }); - if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover }); - } + // 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 }); - if (!cands.length) { - console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`); + 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(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`); process.exit(1); } - // 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check. - // - // This drives the wind the way main.js does, and every clause below is here - // because the first draft skipped it and MIS-MEASURED: - // - // · createWind (not the raw field) + setSheltersFromTrees — trees knock a - // hole downwind, and a quad hanging off tree anchors sits in it. main.js - // does this at boot. - // · the settle runs on the CALM day on a RUNNING clock, because that is what - // wind.use() hands the rig during prep. The first draft settled on STORM - // wind frozen at t=0 — the exact anti-pattern balance.test documents at - // 1.94 kN vs 0.40 kN of standing load at storm entry. - // · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and - // was quietly folding the attach transient + settle into the storm peak. - // - // A tool that vets sites is worthless if it doesn't fly the storm the game - // flies. It reported p1 at 1.1 kN with all three of those wrong. - const trees = site.anchors.filter((a) => a.type === 'tree'); - const wind = createWind(def); - wind.setSheltersFromTrees(trees); - const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8')); - const calmWind = createWind(calmDef); - calmWind.setSheltersFromTrees(trees); - - const rows = []; - for (const cnd of cands) { - // shade cloth: the fabric a competent player takes into a windy night - const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }) - .attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0); - for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) { - rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S); - } - rig.resetPeaks(); // ← the storm starts HERE - for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); - - const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad })); - const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) })); - const unholdable = tiers.filter((c) => !c.tier); - const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); - rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET }); - } - rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1)); - console.log(`${cands.length} quad(s) in band shading the bed:\n`); for (const r of rows) { const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' '); @@ -278,18 +205,17 @@ async function main() { console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`); } - const winners = rows.filter((r) => r.affordable); console.log(''); - if (!winners.length) { - const best = rows[0]; + if (!verdict.ok) { + const best = verdict.best; console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` + (best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.')); console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`); process.exit(1); } - const w = winners[0]; + const w = verdict.best; console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` + - `${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` + + `${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` + `, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`); } diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js new file mode 100644 index 0000000..b0c7574 --- /dev/null +++ b/tools/site_audit/sweep.js @@ -0,0 +1,106 @@ +/** + * sweep.js — the winnability sweep, shared by BOTH front-ends. [Lane B, SPRINT10] + * + * There is exactly one copy of "is this site winnable" and this is it. audit.mjs + * (node, fast, but blind to GLB-dressed anchors) and audit.html (browser, reads + * the fully dressed createWorld(site).anchors) both import auditSweep and only + * differ in how they GET the anchors and how they PRINT the result. A tool built + * 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. + */ + +import { SailRig, orderRing } from '../../web/world/js/sail.js'; +import { createWind } from '../../web/world/js/weather.js'; +import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js'; + +/** Audit knobs, in one place so both front-ends and any future site agree. */ +export const AUDIT = { + BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m² + MIN_COVER: 0.25, // A's "shades the bed" bar + SETTLE_S: 12, // D's settle — a player plays through prep + PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test) + SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer + CALM_STORM: 'storm_01_gentle', +}; + +/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */ +export function areaOf(q) { + const r = orderRing(q); + let a = 0; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z); + return Math.abs(a / 2); +} + +/** Cheapest hardware that holds a corner at `peakN` newtons, or null if the shop can't. */ +export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null; + +/** + * Sweep every bed-covering quad and price the cheapest holding line. + * @param {object} o + * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway } + * @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) + * @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }} + */ +export function auditSweep({ anchors, bed, stormDef, calmDef }) { + // 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++) + for (let c = b + 1; c < anchors.length; c++) for (let d = c + 1; d < anchors.length; d++) { + const q = [anchors[a], anchors[b], anchors[c], anchors[d]]; + const area = areaOf(q); + if (area < AUDIT.BAND.lo || area > AUDIT.BAND.hi) continue; + let rig; + try { + rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0); + } catch { continue; } // degenerate ring + const cover = rig.coverageOver(bed, { x: 0, y: 1, z: 0 }); + if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover }); + } + + if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } }; + + // 2. peak corner loads, settled the way the game settles, on the real storm. + // Every clause here is a bug the first draft shipped: + // · createWind + setSheltersFromTrees — trees knock a hole downwind. + // · settle on the CALM day on a RUNNING clock (main.js's prep), not storm + // 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. + const trees = anchors.filter((a) => a.type === 'tree'); + const wind = createWind(stormDef); + wind.setSheltersFromTrees(trees); + const calmWind = createWind(calmDef); + calmWind.setSheltersFromTrees(trees); + + const rows = []; + for (const cnd of cands) { + // shade cloth (porosity 0.30): the fabric a competent player takes into a windy night + const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }) + .attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0); + for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) { + rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S); + } + rig.resetPeaks(); // ← the storm starts HERE + for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); + + const tiers = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad, tier: tierFor(c.peakLoad) })); + const unholdable = tiers.filter((c) => !c.tier); + const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); + rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST, + affordable: !unholdable.length && hw <= START_BUDGET }); + } + rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1)); + + const winners = rows.filter((r) => r.affordable); + return { + cands, rows, winners, + verdict: winners.length + ? { ok: true, code: 'pass', best: winners[0] } + : { ok: false, code: 'unaffordable', best: rows[0] }, + }; +} From 9578cde8a8ca844455777a607921fc1d4ea32e2d Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 16:06:58 +1000 Subject: [PATCH 6/6] =?UTF-8?q?THREADS:=20SPRINT10=20Lane=20B=20update=20?= =?UTF-8?q?=E2=80=94=20browser=20audit=20built=20+=20validated,=20ready=20?= =?UTF-8?q?for=20site=5F02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/THREADS.md b/THREADS.md index 4870454..96af6b4 100644 --- a/THREADS.md +++ b/THREADS.md @@ -3013,3 +3013,39 @@ nudge are both downstream of A's gate 1. Ready to run the instant it lands. site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet). Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe. + +--- + +## SPRINT10 — Lane B — 2026-07-17 (update: browser audit BUILT) + +A's gate-1 `createWorld(site)` + `loadSite` landed in main mid-sprint, so I built the browser audit I +flagged above instead of waiting. **`tools/site_audit/audit.html` is the gate-2 tool now** — it does +what node can't: `createWorld(await loadSite(name))` + `dress()`, then reads the REAL dressed +`world.anchors` (GLB fascia + branch anchors, 8°-raked posts) and runs the sweep. + + tools/site_audit/audit.html?site=backyard_01 + tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly + +Validated against backyard_01: **dressed 12/12 anchors, PASS, best line p1,p2,p3,p4 $65 + $15 spare** +— identical to the node snapshot AND to balance.test, off your real data with no snapshot anywhere. +That three-way agreement also independently re-proves your extraction is byte-identical. + +The winnability math is now one file, `sweep.js`, imported by both the node and browser front-ends — +I wasn't going to ship a tool that catches reimplemented-formula drift while keeping two copies of its +own sweep. audit.mjs output is byte-identical after the refactor. + +**A — two knock-ons from your merge, both handled, one worth a glance:** + 1. `createWorld` now throws without a site. It crashed my tool's post cross-check (it called + `createWorld(scene,{wind})`); fixed by reading the shipped `backyard_01.json` and passing it. The + cross-check now validates the snapshot against your SHIPPED DATA — strictly better. + 2. Your `balance.test.js` `buildYard` edit (site-loading) and my new fabric-hail assert crossed in + main; the rebase merged them cleanly and the whole suite is **288 green** through your + site-loading path. Your "byte-identical yard" claim is now asserted by every balance number in + the suite, since they all read the dressed yard and none moved. + +**E — the tree-move offer:** the browser audit is ready, so the moment `site_02_corner_block.json` +lands I run `audit.html?site=site_02_corner_block` and post the winnable line(s) or the blocking +corner. If it's the latter, that's your cue. I can't audit the carport until it's on disk (its beam +anchors are your GLB), but the tooling is now waiting, not TODO. + +**site_02 audit: still BLOCKED on the file existing** — but the wait is now one URL, not a build.