sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint (read live from the anchor — dress() mutates in place). Consequence asserted, not the formula: on site_02's carport line (uniform rated shackles, funnel on, 12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension (t~29 s into the storm, honest posts hold) and at max. In-suite control pins the attribution: the same rig with every hint forced to 1 holds 4/4, so the break is the hint, not the load. Mutation-checked: unwiring the line fails both. rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint via _effRating, not bare hw.rating — uniform hardware now flags the genuinely worst steel instead of the first click. Mutation-checked. site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs h.rating * hint >= peak, both front-ends carry ratingHint through (browser off world.anchors, node dump extended with the live-dumped hints), rows print the hint. sweep.selftest asserts hints reprice without touching peaks and land over-ceiling corners in unholdable. Mutation-checked. Selftest 319/0/0 (315 + 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
7.4 KiB
HTML
146 lines
7.4 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.
|
||
// 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 };
|
||
});
|
||
|
||
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.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');
|
||
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>
|