diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs new file mode 100644 index 0000000..0b17cd5 --- /dev/null +++ b/tools/site_audit/audit.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +/** + * site_audit — is this site winnable, BEFORE it ships? [Lane B, SPRINT9] + * + * node tools/site_audit/audit.mjs # the shipped yard + * node tools/site_audit/audit.mjs web/world/data/sites/site_02.json + * node tools/site_audit/audit.mjs --storm storm_03_southerly + * + * WHY THIS EXISTS, in one number: p1 = 7.4 kN. + * + * In SPRINT6 the yard shipped with a bed-covering quad whose corner pulled + * 7.4 kN against a shop whose best hardware holds 6.5. No loadout could hold it + * at any price, so the wild night was unwinnable — and it took four lanes two + * sprints to find out, because nothing checked the site's GEOMETRY against the + * shop's PRICE LIST at authoring time. Geometry decides winnability. A site is a + * balance decision disguised as art, and it should be audited the minute it's + * drawn, not the sprint after it ships. + * + * What it does NOT do: score the garden. Winnability is decided before any of + * that — by whether a quad exists that (a) covers the bed and (b) has peak + * corner loads the budget can hold. The garden drain only decides how BADLY you + * lose a site that was already unwinnable. That's also why this runs in node: + * no skyfx, no document, no browser, ~20 s per site. + */ + +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) + +const argv = process.argv.slice(2); +const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })(); +const sitePath = argv.find((a) => !a.startsWith('--') && a !== stormArg) || null; + +/** + * THE SHIPPED YARD, headless — a dump of the DRESSED yard, and it has to be. + * + * Node cannot dress: dress() needs GLTFLoader (a bare 'three' specifier) and + * fetch()es .glb over file://, neither of which works outside a browser. And + * that matters far more than it sounds, because createWorld() still SUCCEEDS in + * node — it just hands back the GRAYBOX yard: + * + * graybox (node) dressed (browser, what the player plays) + * h1 x = -5.00 h1 x = -3.00 ← dress() adopts E's fascia + * t1 (-9.00, 2.00) t1 (-9.96, 0.54) ← dress() adopts branch_anchor_01 + * t1b/t1c/t2b: ABSENT t1b/t1c/t2b: exist ← dress() adds them + * + * A headless tool that "reads world.js for the real yard" therefore gets the + * house at x=±5 — the exact fictional yard that made the SPRINT6 harness report + * the wild night unwinnable, memorialised at the top of balance.test.js. Reading + * live code is not the same as reading truth. The dump below is the real yard; + * live world.js, in node, is not. + * + * So the deal is: dumped values for what only dress() knows, and a HARD + * CROSS-CHECK against live world.js for everything dress() never touches. + * dress() only adopts h1..h3 / t1 / t2 and adds the branch anchors — the four + * posts are pure world.js, so they are re-verified on every run (verifyPosts). + * That check exists because the first draft of this file typed p4 straight from + * world.js's `postSpecs` and missed that posts are RAKED 8° away from centre: + * it sat 0.56 m off, and the audit dutifully reported on a post that isn't there. + * + * This whole apparatus is why SPRINT9 gate 2 (sites as DATA) is worth it. The + * moment `data/sites/backyard_01.json` exists, pass it and none of this is used. + */ +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 }, + 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], + ['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85], + ['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96], + // pure world.js — cross-checked against live code on every run. + ['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56], + ['p4', 'post', -3.72, 4.00, -1.40], + ].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })), +}; + +/** Anchors dress() never touches — so live world.js IS authoritative for these. */ +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. + */ +async function verifyPosts(site) { + const THREE = await import('../../web/world/vendor/three.module.js'); + const { createWorld } = await import('../../web/world/js/world.js'); + const stub = { + sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), + speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, + gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], + }; + const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox + const live = new Map(world.anchors.map((a) => [a.id, a.pos])); + const drift = []; + for (const a of site.anchors) { + if (!VERIFIABLE.has(a.id)) continue; + const l = live.get(a.id); + if (!l) { drift.push(`${a.id}: gone from world.js entirely`); continue; } + const d = Math.hypot(a.pos.x - l.x, a.pos.y - l.y, a.pos.z - l.z); + if (d > 0.01) drift.push(`${a.id}: dump (${a.pos.x.toFixed(2)}, ${a.pos.y.toFixed(2)}, ${a.pos.z.toFixed(2)}) ` + + `vs world.js (${l.x.toFixed(2)}, ${l.y.toFixed(2)}, ${l.z.toFixed(2)}) — ${d.toFixed(2)} m out`); + } + const missing = [...live.keys()].filter((id) => !site.anchors.some((a) => a.id === id)); + if (missing.length) drift.push(`world.js has anchors this dump has never heard of: ${missing.join(', ')}`); + return drift; +} + +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. + 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 }, + })); + return { name: j.name || path, bed: j.gardenBed || j.bed, anchors }; +} + +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')); + + console.log(`\nsite_audit — ${site.name}`); + if (site.dumped) { + const drift = await verifyPosts(site); + if (drift.length) { + console.log('\n✗ FAIL — the built-in yard dump no longer matches world.js:\n'); + for (const d of drift) console.log(` ${d}`); + console.log('\n Every number this tool prints would be about a yard that does not exist.'); + console.log(' Fix the dump (posts are RAKED 8° — use the anchor pos, not postSpecs), or pass a site JSON.\n'); + process.exit(1); + } + console.log(` posts verified against live world.js ✓ ` + + `dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`); + console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`); + } + console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`); + console.log(`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 }); + } + + if (!cands.length) { + console.log(`✗ FAIL — no quad in the ${BAND.lo}-${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(' '); + const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`; + 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]; + 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]; + 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.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`); +} + +main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); }); diff --git a/web/world/js/sail.js b/web/world/js/sail.js index c76f640..b31b0f2 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -823,6 +823,19 @@ export class SailRig { } } + /** + * Zero the peak-load watermarks to the CURRENT load. `peakLoad` is otherwise + * peak-since-attach, which silently folds the attach transient and any settle + * into whatever you meant to measure — call this where measurement starts. + * + * Written for tools/site_audit, which was reporting a 12 s settle's transient + * as a storm peak until it called this. `load` itself is untouched. + */ + resetPeaks() { + for (const c of this.corners) c.peakLoad = c.load; + return this; + } + /** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */ _checkFailure(dt) { for (let k = 0; k < 4; k++) { diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 5e17af9..0089484 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -93,23 +93,32 @@ async function buildYard() { // cheapest thing that will hold p1. That last corner is the whole $10 gap // between a $90 rig and an $80 budget. // -// MEASURED HERE, and it does not match C's table — flagged in THREADS. C -// reported p1 dropping to 1.08 kN with shade cloth + downdraft 0.40, i.e. under -// a carabiner's 1.2 kN rating. This harness gets p1 peaking at: +// C IS RIGHT ABOUT p1, AND I WAS WRONG. An earlier revision of this comment +// claimed C's table didn't reproduce — that p1 peaked at 1.27 kN with cloth at +// dd 0.40, never crossing under a carabiner's 1.2 kN rating, and that the line +// therefore won on TIME (a 6% overshoot too brief to trip OVERLOAD_SECS) rather +// than on headroom. That was wrong, and the retraction is the useful part: // -// dd 0.45 membrane 1.45 kN dd 0.45 cloth 1.42 kN -// dd 0.40 membrane 1.41 kN dd 0.40 cloth 1.27 kN +// re-measured on the dressed yard, this exact flight, one variable — 2026-07-17 +// shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost +// membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO // -// so p1 never crosses UNDER 1.2 here. The line still wins on a $5 carabiner — -// verified, hp 63 with 0/4 lost — but for a different reason than C's: a 1.27 kN -// peak against a 1.2 kN rating is a 6% overshoot, and breaking needs 0.4 s -// SUSTAINED over rating (sail.js OVERLOAD_SECS). The carabiner is overloaded in -// flashes and never long enough to let go. +// p1 peaks at 0.98 kN against a 1.2 kN rating: 18% of real HEADROOM, and the +// carabiner is never once over its rating. C reported 1.08. That is the same +// number to within measurement noise, and the same conclusion. // -// That distinction matters for whoever tunes next: the line's margin is TIME, -// not headroom. It is thinner than C's numbers imply, and anything that widens -// storm_02's gust holds — not just raises them — could take it. The assert below -// is what will catch that. +// Where 1.27 came from: a loadout where a corner BROKE. Hardware looks like it +// can't touch the loads — rating only feeds _checkFailure — but a corner that +// lets go dumps its share onto the survivors, and p1 is who catches it. Measured: +// the same quad with hardware the budget can't actually buy (setHardware fails +// silently, cheap hardware stays on, p2/p4 tear off) reads p1 at 2.48 kN. Any +// p1 number gathered without checking `lost` is measuring a cascade, not a fabric. +// +// So the fabric is not cosmetic and this is the whole design win: 0.98 vs 1.23 +// against a 1.20 rating is the difference between the $5 carabiner holding the +// wild night and failing it. Choosing membrane doesn't just cost wind load and +// pond — it takes your cheapest corner off the post. `fabric_decides_p1` below +// asserts exactly that, so nobody has to trust this paragraph. const COVER_QUAD = ['p1', 'p2', 'p3', 'p4']; /** The old line: holds nothing above shackle grade, ends pyrrhic. The sacrifice-play control. */ const PYRRHIC_QUAD = ['t2', 'p3', 'p4', 't2b']; @@ -327,6 +336,13 @@ export default async function run(t) { if (pyrrhicShop) pyrrhicShop.setFabric('cloth'); const pyrrhic = pyrrhicShop ? await fly(yard, pyrrhicShop, 'storm_02_wildnight', { repair: true, broom: true }) : null; + // The SAME $80 line, one variable changed: waterproof membrane instead of shade + // cloth. This is the fabric decision's control — see the header. Both fabrics + // cost $0, so the shop cannot tell them apart; only the storm can. + const membraneShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); + if (membraneShop) membraneShop.setFabric('membrane'); + const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null; + const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null; @@ -389,6 +405,28 @@ export default async function run(t) { return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`; }); + // FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the + // forecast to be the price — so the only thing that can justify the choice is + // that it changes the night. Measured, same $80 line, one variable: + // + // shade cloth p1 0.98 kN -> holds (18% under a 1.2 kN carabiner) + // membrane p1 1.23 kN -> LETS GO (2.5% over, long enough to trip) + // + // Membrane loses the cheapest corner on the wild night; that is what you buy + // with the +26% hail block it gives back. If this ever goes green-on-both, the + // fabric pick has become free and the prep screen is lying about a choice. + t.test('balance: fabric decides p1 — membrane tears the cheap corner off', () => { + if (!line || !membrane) throw new Error('could not buy the fabric control on $80'); + if (membrane.lost <= line.lost) { + throw new Error(`fabric stopped mattering: the same $80 line lost ${line.lost}/4 on cloth and ` + + `${membrane.lost}/4 on membrane. Both fabrics are free, so if the storm can't tell them apart ` + + `the choice is cosmetic — either porosity stopped reaching the wind load (sail.js setFabric / ` + + `rigging.commit ordering) or storm_02 got soft enough that p1 survives full load.`); + } + return `same $80 line: cloth ${line.lost}/4 lost (hp ${line.hp}) · membrane ${membrane.lost}/4 lost ` + + `(hp ${membrane.hp}) — the fabric is the decision`; + }); + // 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 diff --git a/web/world/selftest.html b/web/world/selftest.html index 7a073d6..fa2a2df 100644 --- a/web/world/selftest.html +++ b/web/world/selftest.html @@ -79,7 +79,14 @@ summary.textContent = report.ok : `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`; const out = document.getElementById('out'); -for (const letter of ['A', 'B', 'C', 'D', 'E']) { +// Render every lane the report actually CONTAINS, rather than a hardcoded list. +// This read ['A','B','C','D','E'] while the import list above also carries BAL, +// so the balance suite ran, was counted in the summary, and had every one of its +// rows silently dropped — including, had one ever gone red, the row saying which +// assert failed and why. The merge gate was invisible in the merge gate's own +// report. My bug: I added the BAL import and not this. Deriving the list means +// the next joint suite can't hit it. — B +for (const letter of [...new Set(report.results.map((r) => r.lane))]) { const rows = report.results.filter((r) => r.lane === letter); if (!rows.length) continue; const h = document.createElement('div');