HardYards/tools/site_audit/audit.mjs
type-two e1e14018bf Lane B S13: THE AUDIT REWRITE — the audit predicts the garden, on C's corrected picture
The scoring quantity is the FLOWN garden state now, not static cover%.
New gardenfly.js: windForSite (C's shared builder — venturi + shelters, one
copy in the repo) → real attach (throws on D's skipped-attach trap) → skyfx
exposure → garden.js, per candidate line; cover% demoted to a labelled
geometry diagnostic. audit.html flies every affordable line + bare bed and
judges the site's pinned separation block on the block's own storm; audit.mjs
(node) keeps fast winnability, gains p5 in its verified dump, and points at
the browser for garden truth.

On C's correction, three structural adoptions:
- windForSite everywhere (three harnesses independently mis-built site wind;
  no fourth copy);
- LIVE anchors via the re-pointable wind proxy — the audit's own frozen-sway
  remap was C's landmine 2 wearing my file's name (audit.html:69);
- the MARGIN rule (AUDIT.MARGIN = 0.15, one copy): every row prices twice —
  $hw holds vs $cleanHw with >=15% headroom — and only clean lines are
  winners; verdict code 'marginal-only' when the budget can only buy the
  knife edge (D's 39.8-TATTERED wild night, C's dead 91.9 headline).

Also game-true flight: the phantom 12s calm settle is GONE from the sweep
(commit->attach->storm is one keypress; the settle skewed every storm sample
12 s off the authored curve — SailRig samples wind at its INTERNAL clock).
That skew is not academic: it is a.test's separation-flight harness too, and
gardenfly.selftest now pins BOTH chains (game-true 63.8 tattered vs skewed
68.4 'full' on the pinned p5 line — the disagreement is flagged to A in
THREADS, not overruled here). The pinned recipe also sweeps regardless of the
18-45 band (it is 45.9 m² — the band predates p5; flagged to A).

Selftest 356/0/0 on the scratch merge (b+a+c@45bdc2d). Mutation-checked:
MARGIN=0 reddens the margin test (node); dropping siteDef from gardenfly's
windForSite call reddens the venturi test (browser, exact test named).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:50:15 +10:00

293 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 — and SPRINT13 made the garden the
* scoring quantity (the audit's old static cover% predicted the wild night's
* garden BACKWARDS, r = 0.42). skyfx needs `document`, so garden truth lives
* in the BROWSER front-end: audit.html flies every affordable line through
* gardenfly.js (real attach → skyfx exposure → garden.js) and judges the
* site's pinned `separation` block. This node tool remains the fast
* winnability check — peaks, pricing, the margin rule — and it TELLS you
* where the garden verdict is rather than pretending it has one.
*
* Known blindness beyond dressing (stated, not hidden): node hands the sweep
* STATIC anchors, so tree sway — real dynamic load on any tr1/t1/t2 line,
* C's landmine 2 — is frozen here. Post lines are exact; tree lines read
* optimistic. The browser front-end flies world.anchors live.
*/
import { readFile } from 'node:fs/promises';
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'; })();
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 },
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
anchors: [
// dress()-only: adopted from E's GLBs. Unverifiable headless. The 6th column
// is ratingHint, dumped from the live dressed world.anchors (SPRINT12) —
// the fascia's 0.35 and the branches' 1.0/0.88/0.76 are E's baked numbers,
// and the sweep prices hardware against rating × hint now that sail.js
// fails on it. Hand-kept like the positions; the browser front-end is the
// authority if these ever drift.
//
// FULL PRECISION, not 2-decimal — measured, the hard way (SPRINT12): the
// old 2-dp dump moved t2 by ~5 mm, which sat it differently against t1's
// shelter cone and swung its ice-night peak 4.8 → 9.3 kN — the two
// front-ends disagreed by one whole winnable line (ice: 1 winner vs the
// browser's true 2) until the positions matched to the float. Near a
// shelter edge the sweep is mm-sensitive; round nothing.
['h1', 'house', -3, 2.4797002522727953, -9.949999988079071, 0.35],
['h2', 'house', 0, 2.4797002522727953, -9.949999988079071, 0.35],
['h3', 'house', 3, 2.4797002522727953, -9.949999988079071, 0.35],
['t1', 'tree', -9.960037052631378, 3.4546064703375015, 0.5399932861328125, 1.0],
['t2', 'tree', 7.833247900009155, 2.9268529771469947, -0.8460308313369751, 1.0],
['t1b', 'tree', -9.941157281398773, 3.9636089174657974, 2.7770196199417114, 0.88],
['t1c', 'tree', -10.381556510925293, 5.045027431717506, 2.97845321893692, 0.76],
['t2b', 'tree', 8.141596481204033, 3.698920047154319, -0.9573200941085815, 0.88],
// pure world.js — cross-checked against live code on every run (0.01 m
// tolerance). Posts are site-JSON anchors: DEFAULT_RATING_HINT = 1.
['p1', 'post', -4.852518667660811, 3.9450703766405963, 5.930856149363214, 1.0],
['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0],
['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0],
['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0],
// SPRINT13: p5, the clothesline post — A's gate-1 anchor (the ruling that
// gave the backyard a FULL-capable wild-night line). h 2.6 in the JSON,
// raked like every post; verified live like every post.
['p5', 'post', 5.840064309022781, 2.591333620780842, -2.1236597487355566, 1.0],
].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
};
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']);
/**
* 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.
*
* SPRINT10: world.js stopped being code — createWorld now REQUIRES a site and
* throws without one. It reads the site from data/sites/backyard_01.json, which
* is exactly the extraction A shipped, so this cross-check now compares the dump
* against the DATA's posts (raked in createWorld, no dress needed). loadSite()
* itself fetch()es and can't run in node, so read the JSON and hand it over raw.
*/
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 siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
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) {
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 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.
// 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',
// hint 1 when the export omits it — but a dressed export SHOULD carry it,
// or a fascia/carport anchor audits as honest steel (see sweep.js tierFor).
ratingHint: a.ratingHint ?? 1,
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
}));
// A resolved export still owes us the site's funnel — the venturi is site data,
// not storm data, and a sweep without it flies an easier yard than ships.
// The separation block rides along so the tool can at least PRINT the pin.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors,
venturi: j.wind?.venturi ?? [], separation: j.separation ?? null };
}
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
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.)`);
// the pinned separation block is site JSON — read it so the dump path can print the pin
const j = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
site.separation = j.separation ?? null;
}
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
const venturi = site.venturi ?? [];
console.log(`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ') : 'none'}`);
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
// The sweep itself is shared with the browser front-end — see sweep.js.
// siteDef carries the venturi AND the pinned separation recipe (the pin
// sweeps regardless of the band — see sweep.js).
const { cands, rows, winners, marginalWinners, verdict } =
auditSweep({ anchors, bed: site.bed, stormDef: def,
siteDef: { wind: { venturi }, separation: site.separation ?? null } });
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);
}
console.log(`${cands.length} quad(s) in band shading the bed (cover% is the STATIC vertical projection — a`);
console.log(`geometry diagnostic; the garden verdict is flown in audit.html via gardenfly.js):\n`);
for (const r of rows) {
const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
const mark = r.unholdable.length ? '✗ unholdable'
: !r.affordable ? `$${r.hw} > $${START_BUDGET}`
: !r.clean ? `$${r.hw} MARGINAL(${r.marginal.map((c) => c.id).join(',')})`
: `$${r.cleanHw} clean`;
console.log(` ${((r.pinned ? '📌' : '') + r.ids.join(',')).padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(22)} ${cs}`);
}
if (site.separation) {
console.log(`\nseparation target pinned (${site.separation.stormKey}): ` +
site.separation.line.map((l) => `${l.anchor} ${l.hw}`).join(' + ') +
` — held >${site.separation.heldMustExceed} & WIN, bare <${site.separation.bareMustLoseBelow}.` +
`\n Judged in the BROWSER front-end (garden needs skyfx): tools/site_audit/audit.html?site=...`);
}
console.log('');
if (verdict.code === 'marginal-only') {
const b = verdict.best;
console.log(`✗ MARGINAL ONLY — every affordable line carries a corner inside the ` +
`${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (C's residual rule). Best: ${b.ids.join(',')} at $${b.hw}` +
` (${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')};` +
` clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).`);
console.log(` On this bench it holds; in the real game it is the 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.\n`);
process.exit(1);
}
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 = verdict.best;
const wTotal = w.cleanHw + AUDIT.SPARE_COST;
console.log(`✓ PASS — ${winners.length} clean affordable line(s)` +
`${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}. ` +
`Best clean: ${w.ids.join(',')}$${w.cleanHw} hardware` +
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% static cover. Garden verdict: audit.html.\n`);
}
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });