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>
283 lines
16 KiB
HTML
283 lines
16 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.
|
||
-->
|
||
<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 * 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';
|
||
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() {
|
||
// 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();
|
||
let currentWind = {
|
||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||
gustTelegraph: () => null, eventsBetween: () => [],
|
||
};
|
||
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, 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 vlist = site.wind?.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(' · ')}`;
|
||
|
||
// 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 });
|
||
|
||
// ── 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.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: 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) => {
|
||
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>
|