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