site_audit answers "is this site winnable?" before it ships — the check that
was missing when SPRINT6 shipped a yard whose bed-covering quad pulled 7.4 kN
against a shop that holds 6.5. It enumerates in-band quads, flies the real
storm headless, maps each corner to the cheapest tier that holds it, and
verdicts against the $80 budget. On backyard_01 it independently reproduces
THE LINE: p1,p2,p3,p4 at $65 + $15 spare, matching balance.test to within 2%.
It also caught three of my own bugs, which is the point of building it:
· it settled on STORM wind frozen at t=0 — the exact anti-pattern
balance.test documents at 1.94 kN vs 0.40 kN. Now settles on the calm
day on a running clock, the way main.js does.
· peakLoad is peak-since-ATTACH, so the settle transient was being
reported as a storm peak. sail.js gains resetPeaks().
· its p4 was typed from world.js's postSpecs, missing that posts are RAKED
8° away from centre — 0.56 m off. The audit now re-derives all four posts
from live world.js on every run and hard-fails on drift.
That last check is deliberately narrow. Node CANNOT dress: dress() needs
GLTFLoader (bare 'three') and fetch()es .glb over file://. createWorld() still
SUCCEEDS in node — it just returns the GRAYBOX yard, house at x=±5, no branch
anchors. So a headless tool that "reads world.js for the real yard" gets the
fictional x=±5 house that cost two sprints. Reading live code is not reading
truth. Verified in-browser: the dump matches the dressed yard on all 12
anchors. Posts are the only thing dress() never touches, so they're the only
thing worth cross-checking — and that check is what caught p4.
C IS RIGHT ABOUT p1 AND I WAS WRONG, for the third time. My comment claimed
C's 1.08 kN didn't reproduce, that p1 peaked at 1.27 and the line won on TIME
rather than headroom. Re-measured on the dressed yard, same flight:
shade cloth p1 0.98 kN 0/4 lost membrane p1 1.23 kN p1 LETS GO
p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Where 1.27
came from: a loadout where a corner BROKE and dumped its share onto p1 (the
same quad with hardware the budget can't buy reads 2.48). Any p1 number
gathered without checking `lost` measures a cascade, not a fabric.
So the fabric is the decision, not a skin: 0.98 vs 1.23 against a 1.20 rating
is the difference between the $5 carabiner holding the wild night and failing
it. New assert `fabric decides p1` locks that in, so nobody has to trust the
paragraph.
selftest.html rendered ['A'..'E'] while importing BAL too, so the balance
suite ran, counted toward the summary, and had every row silently dropped —
including, had one gone red, the row saying which assert failed. The merge
gate was invisible in its own report. My bug: I added the import, not the
renderer. Now derived from the report.
Selftest: 276 passed, 0 failed, 0 skipped (LANE BAL now visible).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
14 KiB
JavaScript
253 lines
14 KiB
JavaScript
#!/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); });
|