site_audit: browser front-end — audit any site off the real dressed world
Gate 2's actual deliverable. The node tool cannot dress, so it can only audit
the built-in snapshot or a resolved export and REFUSES A's dress-source site
JSON. This is the honest audit: audit.html builds the site the way the game does
— createWorld(await loadSite(name)) then dress() — and reads world.anchors, the
single source of DRESSED positions (GLB fascia + branch anchors, 8°-raked posts,
all of it), then runs the SAME sweep. It 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
The winnability math now lives in ONE place, sweep.js, imported by both
front-ends — a tool built to catch reimplemented-formula drift doesn't get to
keep two copies of its own sweep. audit.mjs is refactored onto it (output
byte-identical); the node refusal message now hands you the exact audit.html URL.
Validated in-browser against backyard_01: dressed 12/12 anchors (GLBs loaded),
PASS, best line p1,p2,p3,p4 $65+$15 spare — identical to the node snapshot
verdict and to balance.test, off A's real data with no snapshot. That
cross-agreement also re-proves A's extraction is byte-identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c313f593f
commit
6f2990c76a
132
tools/site_audit/audit.html
Normal file
132
tools/site_audit/audit.html
Normal file
@ -0,0 +1,132 @@
|
||||
<!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`);
|
||||
|
||||
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` +
|
||||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||||
|
||||
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef });
|
||||
|
||||
// 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, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw })) };
|
||||
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>
|
||||
@ -24,17 +24,8 @@
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const SPARE_COST = 15;
|
||||
const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band
|
||||
const MIN_COVER = 0.25; // A's "shades the bed" bar
|
||||
const SETTLE_S = 12; // D's settle — a player plays through prep
|
||||
const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep
|
||||
const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
|
||||
@ -152,14 +143,16 @@ async function loadSite(path) {
|
||||
const resolved = Array.isArray(j.anchors) && j.anchors.length
|
||||
&& j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x));
|
||||
if (dressSource && !resolved) {
|
||||
const name = (j.id || j.name || 'the_site').replace(/\.json$/, '');
|
||||
throw new Error(
|
||||
`"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` +
|
||||
` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` +
|
||||
` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` +
|
||||
` Audit it in the browser off createWorld(site).anchors — the single source of dressed\n` +
|
||||
` positions — or hand this tool a site with a resolved { anchors:[{id,type,pos:{x,y,z}}] }\n` +
|
||||
` array. Run with no argument to audit the built-in DRESSED backyard_01 snapshot.\n` +
|
||||
` (This limitation and the browser-audit plan are in THREADS for Lane A.)`);
|
||||
` → Audit it in the browser, off the real dressed world.anchors:\n` +
|
||||
` tools/site_audit/audit.html?site=${name}\n` +
|
||||
` (serve the repo with server.py, open that URL — it dresses the yard the way the game\n` +
|
||||
` does and runs the same sweep). Or hand THIS tool a resolved\n` +
|
||||
` { anchors:[{id,type,pos:{x,y,z}}] } export. No argument audits the built-in snapshot.`);
|
||||
}
|
||||
|
||||
// Resolved-positions shape: a flat anchors[] each carrying real coordinates.
|
||||
@ -173,21 +166,11 @@ async function loadSite(path) {
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
function areaOf(q) {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */
|
||||
const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
@ -206,71 +189,15 @@ async function main() {
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
const A = anchors;
|
||||
for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++)
|
||||
for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) {
|
||||
const q = [A[a], A[b], A[c], A[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < BAND.lo || area > BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
// The sweep itself is shared with the browser front-end — see sweep.js.
|
||||
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef });
|
||||
|
||||
if (!cands.length) {
|
||||
console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`);
|
||||
if (verdict.code === 'no-cover') {
|
||||
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
|
||||
console.log(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check.
|
||||
//
|
||||
// This drives the wind the way main.js does, and every clause below is here
|
||||
// because the first draft skipped it and MIS-MEASURED:
|
||||
//
|
||||
// · createWind (not the raw field) + setSheltersFromTrees — trees knock a
|
||||
// hole downwind, and a quad hanging off tree anchors sits in it. main.js
|
||||
// does this at boot.
|
||||
// · the settle runs on the CALM day on a RUNNING clock, because that is what
|
||||
// wind.use() hands the rig during prep. The first draft settled on STORM
|
||||
// wind frozen at t=0 — the exact anti-pattern balance.test documents at
|
||||
// 1.94 kN vs 0.40 kN of standing load at storm entry.
|
||||
// · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and
|
||||
// was quietly folding the attach transient + settle into the storm peak.
|
||||
//
|
||||
// A tool that vets sites is worthless if it doesn't fly the storm the game
|
||||
// flies. It reported p1 at 1.1 kN with all three of those wrong.
|
||||
const trees = site.anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth: the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad }));
|
||||
const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
@ -278,18 +205,17 @@ async function main() {
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
console.log('');
|
||||
if (!winners.length) {
|
||||
const best = rows[0];
|
||||
if (!verdict.ok) {
|
||||
const best = verdict.best;
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
(best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
|
||||
console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const w = winners[0];
|
||||
const w = verdict.best;
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
|
||||
106
tools/site_audit/sweep.js
Normal file
106
tools/site_audit/sweep.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* sweep.js — the winnability sweep, shared by BOTH front-ends. [Lane B, SPRINT10]
|
||||
*
|
||||
* There is exactly one copy of "is this site winnable" and this is it. audit.mjs
|
||||
* (node, fast, but blind to GLB-dressed anchors) and audit.html (browser, reads
|
||||
* the fully dressed createWorld(site).anchors) both import auditSweep and only
|
||||
* differ in how they GET the anchors and how they PRINT the result. A tool built
|
||||
* to catch reimplemented-formula drift must not carry two copies of its own math.
|
||||
*
|
||||
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
|
||||
* the bed rect, and the storm + calm-day defs. It flies the same settle+storm the
|
||||
* game flies and returns ranked rows + a verdict. No I/O, no process, no DOM.
|
||||
*/
|
||||
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
/** Audit knobs, in one place so both front-ends and any future site agree. */
|
||||
export const AUDIT = {
|
||||
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
|
||||
MIN_COVER: 0.25, // A's "shades the bed" bar
|
||||
SETTLE_S: 12, // D's settle — a player plays through prep
|
||||
PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
|
||||
CALM_STORM: 'storm_01_gentle',
|
||||
};
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
export function areaOf(q) {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peakN` newtons, or null if the shop can't. */
|
||||
export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
/**
|
||||
* Sweep every bed-covering quad and price the cheapest holding line.
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway }
|
||||
* @param {object} o.bed garden bed rect { x, z, w, d }
|
||||
* @param {object} o.stormDef the storm JSON to fly
|
||||
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
|
||||
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
|
||||
*/
|
||||
export function auditSweep({ anchors, bed, stormDef, calmDef }) {
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
|
||||
for (let c = b + 1; c < anchors.length; c++) for (let d = c + 1; d < anchors.length; d++) {
|
||||
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < AUDIT.BAND.lo || area > AUDIT.BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
// 2. peak corner loads, settled the way the game settles, on the real storm.
|
||||
// Every clause here is a bug the first draft shipped:
|
||||
// · createWind + setSheltersFromTrees — trees knock a hole downwind.
|
||||
// · settle on the CALM day on a RUNNING clock (main.js's prep), not storm
|
||||
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
|
||||
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
|
||||
// the settle transient into the storm peak.
|
||||
const trees = anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(stormDef);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const tiers = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad, tier: tierFor(c.peakLoad) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
return {
|
||||
cands, rows, winners,
|
||||
verdict: winners.length
|
||||
? { ok: true, code: 'pass', best: winners[0] }
|
||||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user