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 @@