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] }, + }; +}