A's committed site schema (SPRINT10 world.js loadSite/createWorld) is
dress-source, and my loadSite assumed resolved positions. Run head-on against
A's real data/sites/backyard_01.json the tool reported:
✗ FAIL — no quad in the band shades the bed at all. The site cannot be rigged.
That is a lie, and the dangerous kind: the site is fine; the TOOL couldn't read
it. It is the SPRINT6 unwinnable-site trap in reverse — a false negative that,
in a "every site runs the audit before it ships" workflow, condemns a good site.
Two things make the schema unreadable headless, both real:
· posts are pre-rake. JSON carries {id,x,z,h}; world.js:361 leans each post 8°
off centre. p4's spec (-3.2,-1.2) dresses to (-3.72,-1.40) — the exact 0.56 m
error this tool's own snapshot shipped in SPRINT9. Reading x/z raw re-makes it.
· house/tree anchors have no coordinates — only `node`, a GLB empty's name.
Their position exists only after dress() reads matrixWorld, and dress() needs
GLTFLoader + fetch, neither of which runs in node. Branch anchors are exactly
where the dangerous quads live (t2b at 10 kN), so dropping them silently is
the worst possible failure.
The correct source of dressed positions is createWorld(site).anchors, which is
browser-only. That path lands once A's data-driven createWorld is in main; until
then loadSite REFUSES dress-source JSON with the reason and the plan, and exits 2
(distinct from the winnability FAIL's exit 1). The built-in DRESSED snapshot and
a resolved { anchors:[{id,type,pos}] } export both still audit. Raised to A in
THREADS: audit in-browser off createWorld(site).anchors, or export resolved anchors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
290 lines
16 KiB
JavaScript
290 lines
16 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'));
|
|
|
|
// 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) {
|
|
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.)`);
|
|
}
|
|
|
|
// 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 },
|
|
}));
|
|
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); });
|