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>
293 lines
18 KiB
JavaScript
293 lines
18 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 — 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); });
|