HardYards/tools/site_audit/audit.html
type-two 4095df8b10 Lane B S11: audit the venturi, land setWorld/setBudget, revive the harness
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).
2026-07-17 17:42:54 +10:00

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)}`;
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>