diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html index 2ac46fe..db84ea1 100644 --- a/tools/site_audit/audit.html +++ b/tools/site_audit/audit.html @@ -1,6 +1,7 @@ @@ -45,6 +55,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; import { AUDIT, auditSweep } from './sweep.js'; +import { flyGarden, flySeparation } from './gardenfly.js'; const q = new URLSearchParams(location.search); const siteName = q.get('site') || 'backyard_01'; @@ -54,29 +65,39 @@ const el = (id) => document.getElementById(id); const loadJSON = async (path) => (await fetch(path)).json(); async function run() { - // Build the site the way the game does — data in, dressed yard out. + // Build the site the way the game does — data in, dressed yard out — on a + // RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly + // world.anchors THEMSELVES. The old version here remapped every anchor to a + // static `sway: () => pos`, which froze the gum tree — the same landmine + // that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig + // eats the tree's sway as dynamic load). Backyard posts never noticed; + // every tr1/t1/t2 line was optimistic. const site = await loadSite(siteName); const scene = new THREE.Scene(); - const calmStub = { + let currentWind = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, - gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], + gustTelegraph: () => null, eventsBetween: () => [], }; - const world = createWorld(scene, { wind: calmStub, site }); + const windProxy = { + sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => currentWind.speedAt(p, t), + rainAt: (t) => currentWind.rainAt(t), + rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0), + gustTelegraph: (t) => currentWind.gustTelegraph(t), + eventsBetween: (a, b) => currentWind.eventsBetween(a, b), + setSheltersFromTrees() {}, + }; + const use = (w) => { currentWind = w; }; + const world = createWorld(scene, { wind: windProxy, site }); let dressed = false; if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } } - // world.anchors are the REAL dressed positions — freeze sway like balance.test. - // ratingHint rides along (SPRINT12): the sweep prices hardware against - // rating × hint, and dropping it here would audit a yard where the fascia - // and the carport beam are honest steel — the exact lie the wiring killed. - const anchors = world.anchors.map((a) => { - const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; - return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos }; - }); + // world.anchors, LIVE — sway closures and ratingHint intact. The sweep and + // every garden flight below re-point the proxy at their own wind via `use`. + const anchors = world.anchors; const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`); - const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`); const vlist = site.wind?.venturi ?? []; const vtxt = vlist.length @@ -89,23 +110,86 @@ async function run() { `venturi: ${vtxt}\n` + `shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`; - // The venturi is the SITE's, not the storm's — main.js:424 sets it on the wind - // at every site load, so the audit must too or the corner block flies with its - // funnel switched off (an easier yard than ships). - const venturi = site.wind?.venturi ?? []; - const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi }); + // The venturi rides in on the SITE def — windForSite (C's shared builder, + // inside sweep.js and gardenfly.js) is the ONE door site wind comes through + // now. Three harnesses independently mis-built it; there is no fourth copy. + const bed = world.gardenBed; + const { cands, rows, verdict, winners, marginalWinners } = + auditSweep({ anchors, bed, stormDef, siteDef: site, use }); - // render rows + // ── SPRINT13: fly the garden for every line the budget can actually buy ── + // The scoring quantity is the FLOWN outcome (gardenfly.js — real attach, + // real skyfx exposure, real garden.js), not static cover%. Marginal lines + // fly too, deliberately: they are the trap the margin rule exists to name. + const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use }); + const FLY_CAP = 12; + const flown = new Map(); + for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) { + // clean winners fly the CLEAN tiers (what the audit recommends buying); + // marginal winners fly the knife-edge tiers (the trap, priced as sold). + const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId + flown.set(r.ids.join(','), flyGarden({ + anchors, bed, stormDef, siteDef: site, use, + ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)), + })); + } + const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP); + + // best GARDEN line the budget buys — the audit's answer to "what should I + // rig", which used to be "the cheapest line that holds" and is now "the line + // that saves the garden" — cheapest on ties, and never a marginal one (a + // marginal flight is C's 91.9-FULL illusion; it gets reported, not sold). + const rowBy = (key) => rows.find((w) => w.ids.join(',') === key); + const pickBest = (entries) => entries.reduce((best, [key, g]) => { + if (!best) return { key, g }; + if (g.hp > best.g.hp + 0.05) return { key, g }; + if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g }; + return best; + }, null); + const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean); + const bestGarden = pickBest(cleanFlown); + const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean)); + + // ── the site's pinned separation target (A's gate-1.4 ruling), judged on the + // block's OWN storm — the target is site data, not a per-run choice. + let sep = null, sepStormName = null; + if (site.separation) { + sepStormName = site.separation.stormKey; + const sepStorm = sepStormName === stormName ? stormDef + : await loadJSON(`../../web/world/data/storms/${sepStormName}.json`); + sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use }); + } + + // render rows — cover% is a DIAGNOSTIC column now (static overhead projection + // of the taut attach shape); the flown garden column is what scores. const tbl = document.createElement('table'); + const hd = tbl.insertRow(); + for (const h of ['line', 'm²', 'vert cover (static)', 'holds?', 'garden (flown)', 'corners: peak → tier']) { + const td = hd.insertCell(); td.textContent = h; td.className = 'corner'; + } for (const r of rows) { const tr = tbl.insertRow(); - tr.insertCell().textContent = r.ids.join(','); + tr.insertCell().textContent = `${r.pinned ? '📌 ' : ''}${r.ids.join(',')}`; tr.insertCell().textContent = `${r.area.toFixed(0)} m²`; - tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`; + tr.insertCell().textContent = `${(r.cover * 100).toFixed(0)}%`; const mk = tr.insertCell(); if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; } - else if (r.hw <= START_BUDGET) { mk.textContent = `✓ $${r.hw}`; mk.className = 'ok'; } - else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; } + else if (!r.affordable) { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; } + else if (!r.clean) { + mk.textContent = `⚠ $${r.hw} MARGINAL (${r.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}%`).join(' ')})` + + ` — clean ${r.cleanHw != null ? `$${r.cleanHw} > $${START_BUDGET}` : 'over the shop ceiling'}`; + mk.className = 'warn'; + } else { + mk.textContent = `✓ $${r.cleanHw} clean${r.hw < r.cleanHw ? ` (holds at $${r.hw})` : ''}`; + mk.className = 'ok'; + } + const gd = tr.insertCell(); + const g = flown.get(r.ids.join(',')); + if (g) { + gd.textContent = `${g.hp} ${g.state.toUpperCase()}${g.cornersLost ? ` (${g.cornersLost} lost)` : ''}` + + `${g.marginal.length ? ` ⚠ ${g.marginal.join(',')} inside margin` : ''}`; + gd.className = (g.marginal.length || g.state === 'tattered') ? 'warn' : g.state === 'full' ? 'ok' : 'bad'; + } else { gd.textContent = '—'; gd.className = 'corner'; } const cs = tr.insertCell(); cs.className = 'corner'; cs.innerHTML = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' '); @@ -114,26 +198,79 @@ async function run() { // verdict const v = el('verdict'); + const gardenLine = () => { + const bg = bestGarden ? rowBy(bestGarden.key) : null; + return `\n— the garden (flown, funnel ${vlist.length ? 'ON' : 'n/a'}): bare bed ${bare.hp} ${bare.state.toUpperCase()}` + + (bestGarden + ? ` · best clean buyable ${bg.ids.join(',')} ($${bg.cleanHw}) → ${bestGarden.g.hp} ${bestGarden.g.state.toUpperCase()}` + + `${bestGarden.g.cornersLost ? ` (${bestGarden.g.cornersLost} corner(s) lost)` : ''}` + + ` · the sail is worth ${(bestGarden.g.hp - bare.hp) >= 0 ? '+' : ''}${(bestGarden.g.hp - bare.hp).toFixed(1)} HP` + : ' · NO clean buyable line to fly') + + (bestMarginal && (!bestGarden || bestMarginal.g.hp > bestGarden.g.hp) + ? `\n ⚠ best MARGINAL line ${bestMarginal.key} reads ${bestMarginal.g.hp} ${bestMarginal.g.state.toUpperCase()} on paper — ` + + `inside the 15% break-in-game band, treat as ${bestMarginal.g.hp > bare.hp ? 'the trap, not the answer' : 'dead'}` + : '') + + (skipped ? `\n (${skipped} affordable line(s) beyond the ${FLY_CAP}-flight cap not flown)` : ''); + }; + const sepLine = () => { + if (!sep) return `\n— separation target: none pinned in the site JSON (A's gate-1.4 block) — nothing to judge against.`; + const s = site.separation; + return `\n— separation target (${sepStormName}): ` + + `held ${sep.held.hp} ${sep.held.state.toUpperCase()} ($${sep.held.cost}, ${sep.held.cornersLost}/4 lost` + + `) vs pinned >${s.heldMustExceed}` + + ` · bare ${sep.bare.hp} vs pinned <${s.bareMustLoseBelow}` + + ` → ${sep.ok ? 'MEETS the target' : `FAILS (${[!sep.heldOk && 'held not FULL', !sep.heldWin && 'held does not WIN', !sep.bareOk && 'bare does not lose'].filter(Boolean).join(', ')})`}` + + (sep.ok && !sep.heldClean + ? `\n ⚠ but the pinned line is KNIFE-EDGED: ${sep.held.marginal.map((id) => { + const p = sep.held.peaks.find((x) => x.id === id); + return `${id} ${(p.headroom * 100).toFixed(0)}% headroom (${p.peakN}/${p.effN} N)`; + }).join(', ')} — inside the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band. ` + + `Flagged to A/D in THREADS, not overruled here: the margin rule's cause is unfound and this yard's ` + + `bench has matched the UI to the decimal — but nobody has PLAYED this line yet.` + : ''); + }; if (verdict.code === 'no-cover') { v.className = 'verdict fail'; v.textContent = `✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.\n` + `The site cannot be rigged. It needs an anchor near the bed before it ships.`; + } else if (verdict.code === 'marginal-only') { + const b = verdict.best; + v.className = 'verdict fail'; + v.textContent = `✗ MARGINAL ONLY — the budget's ${marginalWinners.length} affordable line(s) all carry a corner inside ` + + `the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (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'}).\n` + + `On the bench it holds; in the real game it is D's 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.` + + gardenLine() + sepLine(); } else if (!verdict.ok) { const b = verdict.best; v.className = 'verdict fail'; v.textContent = `✗ FAIL — no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` + (b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.') + - `\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.`; + `\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.` + + gardenLine() + sepLine(); } else { const w = verdict.best; - v.className = 'verdict pass'; - v.textContent = `✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` + - `${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` + - `, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`; + const wTotal = w.cleanHw + AUDIT.SPARE_COST; + v.className = (sep && !sep.ok) ? 'verdict fail' : 'verdict pass'; + v.textContent = `${(sep && !sep.ok) ? '✗' : '✓'} winnability: ${winners.length} clean affordable line(s)` + + `${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}; cheapest clean ${w.ids.join(',')} — $${w.cleanHw} hardware` + + `${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}.` + + gardenLine() + sepLine(); } // a machine-readable line, so this page can also be driven headless-in-browser - window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) }; - document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`; + window.__audit = { + site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, + cands: cands.length, verdict, + winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })), + marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw, + marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })), + garden: flown.get(w.ids.join(',')) ?? null })), + bare, bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null, + bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null, + separation: sep ? { storm: sepStormName, ...sep } : null, + }; + document.title = `site_audit — ${verdict.ok ? 'PASS' : verdict.code.toUpperCase()}${sep ? ` · sep ${sep.ok ? 'MEETS' : 'FAILS'}` : ''}`; } run().catch((e) => { diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index a82a91b..a2c1308 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -16,11 +16,19 @@ * 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. + * 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'; @@ -94,11 +102,15 @@ const BACKYARD_01 = { ['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']); +const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']); /** * Re-derive the posts from live world.js and fail loudly if the dump drifted. @@ -187,7 +199,9 @@ async function loadSite(path) { })); // 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. - return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] }; + // 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 })); @@ -196,7 +210,6 @@ 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) { @@ -211,6 +224,9 @@ async function main() { 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 ?? []; @@ -218,7 +234,11 @@ async function main() { 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. - const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi }); + // 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.`); @@ -226,14 +246,33 @@ async function main() { process.exit(1); } - console.log(`${cands.length} quad(s) in band shading the bed:\n`); + 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.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 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` + @@ -242,9 +281,12 @@ async function main() { process.exit(1); } const w = verdict.best; - console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` + - `${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`); + 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); }); diff --git a/tools/site_audit/garden_probe.html b/tools/site_audit/garden_probe.html index 1f8590c..b6484b9 100644 --- a/tools/site_audit/garden_probe.html +++ b/tools/site_audit/garden_probe.html @@ -44,12 +44,9 @@