The page keeps its rendering and loses its logic. A third front-end (A's editor) was about to make sweep-fly-pick-judge exist twice, which is the exact failure sweep.js was extracted to prevent, one level up. Behaviour-preserving, and measured that way rather than reasoned: both shipped sites were pinned BEFORE the refactor and reproduce to the digit after — backyard_01/wildnight 16 cands, 3 clean, 2 marginal, bare 35.7 tattered, best p1,p2,p3,p4 60.5, sep MEETS held 63.8; site_02/earlybuster 66 cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3 full. Caught in the act by that check: __audit.cands went undefined because scoreSite returns the COUNT where the old local was the LIST. A refactor that only got read would have shipped it.
238 lines
14 KiB
HTML
238 lines
14 KiB
HTML
<!doctype html>
|
||
<!--
|
||
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool;
|
||
SPRINT13 gate 1.2 — the audit predicts the GARDEN now]
|
||
|
||
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only
|
||
sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
|
||
than lie about a yard it can't resolve. This page is the honest audit. It builds
|
||
the site exactly the way the game does — createWorld(await loadSite(name)) then
|
||
dress() — and reads world.anchors, the single source of DRESSED positions:
|
||
house fascia and tree branch anchors baked into E's GLBs, posts raked 8°, all of
|
||
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to
|
||
audit a site with a carport (site_02) before it ships.
|
||
|
||
SPRINT13: winnability was never the whole question — the audit's static
|
||
"cover%" predicted the wild night's garden BACKWARDS (r = −0.42) and told
|
||
three people a $20 rig was fine. Every affordable line is now FLOWN through
|
||
the real scoring chain (gardenfly.js: attach → settle → skyfx exposure →
|
||
garden.js) with the SITE's venturi live, and the verdict reports the garden
|
||
state the sim would invoice — FULL / tattered / dead — plus the site's
|
||
pinned `separation` target (A's gate-1.4 block in the site JSON), judged on
|
||
the block's own storm. cover% stays as a geometry diagnostic only.
|
||
|
||
tools/site_audit/audit.html?site=backyard_01
|
||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||
|
||
Served from the repo root (server.py), so the relative imports resolve.
|
||
|
||
SPRINT14: this page's RUN LOGIC moved to scorecard.js and it kept only its
|
||
rendering. A third front-end arrived (A's editor, gate 2.1, where the yard
|
||
being scored has never been a file), and the sweep-fly-pick-judge sequence
|
||
this page had grown was about to exist in two places — which is the exact
|
||
failure sweep.js was extracted to prevent, one level up. `scoreSite()` takes
|
||
a site OBJECT; fetching one is this page's business and nobody else's.
|
||
|
||
Verified by measurement, not by reading: the refactor was pinned against
|
||
both shipped sites BEFORE it happened and reproduced them to the digit —
|
||
backyard_01/wildnight (16 cands, 3 clean, 2 marginal, bare 35.7 tattered,
|
||
best p1,p2,p3,p4 60.5, sep MEETS held 63.8) and site_02/earlybuster (66
|
||
cands, 20 clean, 1 marginal, bare 82.6 full, best tr1,tr1b,q1,q4 91.3).
|
||
-->
|
||
<meta charset="utf-8">
|
||
<title>site_audit</title>
|
||
<style>
|
||
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
|
||
table { border-collapse:collapse; margin:8px 0; }
|
||
td { padding:1px 10px 1px 0; white-space:nowrap; }
|
||
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
|
||
.verdict { font-size:14px; margin-top:14px; padding:10px 12px; border-radius:6px; white-space:pre-wrap; }
|
||
.verdict.pass { background:#132; color:#8e8; } .verdict.fail { background:#311; color:#f99; }
|
||
.corner { color:#9ab; } .none { color:#e66; font-weight:bold; }
|
||
</style>
|
||
<h1>site_audit</h1>
|
||
<div class="sub" id="sub">loading…</div>
|
||
<div id="out"></div>
|
||
<div id="verdict"></div>
|
||
|
||
<script type="importmap">
|
||
{ "imports": {
|
||
"three": "../../web/world/vendor/three.module.js",
|
||
"three/addons/": "../../web/world/vendor/addons/"
|
||
} }
|
||
</script>
|
||
<script type="module">
|
||
import { loadSite } from '../../web/world/js/world.js';
|
||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||
import { AUDIT } from './sweep.js';
|
||
import { scoreSite, FLY_CAP } from './scorecard.js';
|
||
|
||
const q = new URLSearchParams(location.search);
|
||
const siteName = q.get('site') || 'backyard_01';
|
||
const stormName = q.get('storm') || 'storm_02_wildnight';
|
||
const el = (id) => document.getElementById(id);
|
||
|
||
const loadJSON = async (path) => (await fetch(path)).json();
|
||
|
||
async function run() {
|
||
// Fetching the site is this page's job; scoring it is scorecard.js's. The
|
||
// whole "build a dressed world on a re-pointable wind proxy so live tree-sway
|
||
// closures sample the storm being flown" dance lives in buildScoringWorld()
|
||
// now — including the reason it must (a static `sway: () => pos` remap froze
|
||
// the gum tree and under-read every tree corner: C's landmine 2).
|
||
const site = await loadSite(siteName);
|
||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||
|
||
// The separation block names its own storm; load it if it differs.
|
||
const sepKey = site.separation?.stormKey ?? null;
|
||
const sepStormDef = sepKey
|
||
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
|
||
: null;
|
||
|
||
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
|
||
const {
|
||
dressed, cands, rows, verdict, winners, marginalWinners,
|
||
flown, skipped, bare, separation: sep, sepStormName,
|
||
} = score;
|
||
const anchors = { length: score.anchorCount };
|
||
const bestGarden = score.bestGarden ? { key: score.bestGarden.ids, g: score.bestGarden } : null;
|
||
const bestMarginal = score.bestMarginal ? { key: score.bestMarginal.ids, g: score.bestMarginal } : null;
|
||
|
||
const vlist = score.venturi;
|
||
const vtxt = vlist.length
|
||
? vlist.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
|
||
: 'none';
|
||
|
||
el('sub').textContent =
|
||
`${site.name || siteName} — ${anchors.length} dressed anchors ${dressed ? '(GLBs loaded ✓)' : '(⚠ dress() FAILED — graybox positions, not what ships)'}\n` +
|
||
`storm: ${stormName} (${stormDef.duration}s, downdraft ${stormDef.gusts?.downdraftOfTotal ?? '—'})\n` +
|
||
`venturi: ${vtxt}\n` +
|
||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||
|
||
// `rowBy` is the only helper the RENDER still needs (the picking it used to
|
||
// serve moved into scoreSite).
|
||
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
|
||
|
||
// 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.pinned ? '📌 ' : ''}${r.ids.join(',')}`;
|
||
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
|
||
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.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 : '<span class="none">NONE</span>'}`).join(' ');
|
||
}
|
||
el('out').appendChild(tbl);
|
||
|
||
// 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.` +
|
||
gardenLine() + sepLine();
|
||
} else {
|
||
const w = verdict.best;
|
||
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, verdict, // scoreSite already returns the COUNT, not the list
|
||
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) => {
|
||
el('verdict').className = 'verdict fail';
|
||
el('verdict').textContent = `site_audit crashed: ${e.message}\n${e.stack || ''}`;
|
||
window.__audit = { error: e.message };
|
||
document.title = 'site_audit — ERROR';
|
||
});
|
||
</script>
|