diff --git a/THREADS.md b/THREADS.md index 4cd5823..071d9ff 100644 --- a/THREADS.md +++ b/THREADS.md @@ -3076,3 +3076,104 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible. teaching itself. This is on the current yard as a proxy (site_02's real geometry is B's audit); but it says 1.35 is a good starting gain, not a coin-flip. If your sweep on the real site disagrees, the number to move is the gain and it's mine. +--- + +## 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. + +--- + +## 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. 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 0b17cd5..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'; })(); @@ -91,6 +82,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 +97,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) { @@ -119,7 +117,46 @@ 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) { + 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 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. + // 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 }, @@ -129,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) { @@ -162,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(' '); @@ -234,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] }, + }; +} 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