site_audit's headless false-negative is fixed by the browser front-end (A's loadSite/createWorld landed in main — that was all it waited on), and fixing it surfaced a worse bug underneath: the sweep built its wind from the STORM def alone and never called setVenturi, which main.js:424 does at every site load. The venturi is SITE data, so every site_02 audit ever run — including the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable, here it called a mean site cheap. - sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle); both front-ends pass the site's funnel and print it, so a funnelled run is legible as one. - sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor. Funnel must strictly raise every corner; a funnel-less site must be byte-identical. Verified it fails when the setVenturi calls are deleted. - rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks. Markers are rebuilt (the anchor set changes across sites) and disposed; stale picks die with the old yard. Deletes A's "one private touch" into this module. - dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which now throws) and nothing noticed, because a dev page has no selftest to go red. Fixed, dressed, and given the site switch it should have had (N = next yard). Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need dropping and E's tree stays put. It's a reach problem, not a gain problem: the throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over the carport at +50% and reaches the garden at +0.0%. selftest 300/0/0 (296 + 4 new).
143 lines
7.1 KiB
HTML
143 lines
7.1 KiB
HTML
<!doctype html>
|
|
<!--
|
|
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool]
|
|
|
|
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.
|
|
|
|
tools/site_audit/audit.html?site=backyard_01
|
|
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
|
|
|
|
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';
|
|
|
|
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.
|
|
const site = await loadSite(siteName);
|
|
const scene = new THREE.Scene();
|
|
const calmStub = {
|
|
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
|
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
|
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
|
};
|
|
const world = createWorld(scene, { wind: calmStub, 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.
|
|
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, pos, sway: () => pos };
|
|
});
|
|
|
|
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
|
|
? 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 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 });
|
|
|
|
// render rows
|
|
const tbl = document.createElement('table');
|
|
for (const r of rows) {
|
|
const tr = tbl.insertRow();
|
|
tr.insertCell().textContent = r.ids.join(',');
|
|
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
|
|
tr.insertCell().textContent = `cover ${(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'; }
|
|
const cs = tr.insertCell(); cs.className = 'corner';
|
|
cs.innerHTML = r.tiers.map((c) =>
|
|
`${c.id} ${(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');
|
|
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.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.`;
|
|
} 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.`;
|
|
}
|
|
// 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'}`;
|
|
}
|
|
|
|
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>
|