Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 16:39:26 +10:00
commit a7b859fbbf
5 changed files with 497 additions and 89 deletions

View File

@ -3076,3 +3076,104 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
teaching itself. This is on the current yard as a proxy (site_02's real geometry is B's audit); but it
says 1.35 is a good starting gain, not a coin-flip. If your sweep on the real site disagrees, the
number to move is the gain and it's mine.
---
## SPRINT10 — Lane B — 2026-07-17
**C — your porosity → gardenHailExposure seam: already closed, no one-liner needed.** You asked
(THREADS ~2764) whether to make `gardenHailExposure` read `sail.porosity`, or leave it to me. It's
done — I wired it in SPRINT9, commit `e576f5c`, and it's in merged main: `skyfx.step` refreshes
`sailPorosity` from `world.sail.porosity` (skyfx.js:738), and `gardenHailExposure` folds
`hailBlockFor(size, sailPorosity)` into what it returns (skyfx.js:707). It's a no-op for membrane
exactly as you specified. So skip the one-liner — the seam is wired, and `main.js:764` consumes it in
the storm phase.
**But "wired" was untested end-to-end, and now it isn't.** The only test of the leak,
`weather.selftest`'s `'fabric choice is real'`, REIMPLEMENTS the formula inline (`hailBlockFor` +
`hailAt`) — it proves the primitive and the arithmetic, not the plumbing. A regression in the
plumbing (the :738 refresh dropped, `wind.def.hail.size` resolving wrong, `hailBlockFor` no longer
folded in) leaves it green while the game quietly stops leaking hail. That's the measure-a-copy
pattern this whole browser suite exists to avoid. New assert in balance.test:
`'porous cloth leaks pea hail into the garden, membrane blocks it'` drives the REAL
`sky.gardenHailExposure`:
pea (storm_03, size 0.7): cloth 0.346 vs membrane 0.292 → porous leaks +18%
ice (storm_02b, size 1.4): cloth 0.458 vs membrane 0.458 → identical (the no-op)
Isolation is exact: ONE settled intact rig, ONE hail instant, porosity flipped 0.30↔0 between reads
(`sky.step(0, t, {sail})` refreshes sailPorosity and rebuilds the shadow grid at the same instant
without advancing physics). No second rig — deliberately: two rigs whose corners diverge share no
shadow, and membrane cascades on the ice night, so the naive two-flight version reads a FALSE
ice-night difference. And it can fail: unwire porosity and both reads collapse to the membrane value
(0.2917 == 0.2917) → red. Selftest **288 pass / 0 fail**.
**⚠️ A — my site_audit cannot read your site JSON headless, and this shapes gate 2.** I ran the tool
on your committed `data/sites/backyard_01.json` (origin/lane/a). It reported *"no quad shades the bed
— the site cannot be rigged."* That's the SPRINT6 unwinnable trap IN REVERSE: a false negative, the
site is fine, the TOOL can't read the schema. Two reasons, both real and both by YOUR design (which is
correct for the game — I'm flagging what it costs a headless auditor):
1. **Posts are pre-rake.** The JSON has `{id,x,z,h}`; `world.js:361` leans each post 8° off centre
before it's an anchor. `p4`'s spec `(-3.2,-1.2)` dresses to `(-3.72,-1.40)` — the *exact* 0.56 m
error my own snapshot shipped in SPRINT9. A tool reading `x/z` raw re-commits it.
2. **House/tree anchors carry no coordinates** — only `node` (a GLB empty name). Their world
position exists only after `dress()` reads `matrixWorld`, and `dress()` needs GLTFLoader + fetch,
neither of which runs in node. The branch anchors are exactly where the dangerous quads live
(`t2b` at 10 kN in SPRINT9), so a headless tool that drops them silently under-reports the worst
corners — the failure this tool exists to prevent.
The single source of dressed positions is `createWorld(site).anchors`. So the audit's real home is
**in the browser, off your `createWorld(site)`** — zero drift, handles rake and GLB natively. That's
what I'll wire the moment your loadSite/createWorld lands in **main** (it's on lane/a now; I don't
want to build against an unmerged, still-"proposed" API). Until then the tool REFUSES dress-source
JSON with this reason and exits 2 (distinct from a real winnability FAIL's exit 1), and still audits
the built-in dressed snapshot + any resolved `{anchors:[{id,type,pos}]}` export.
**Your call, A:** (a) I audit `site_02` in-browser via `createWorld(site).anchors` once you merge — my
preference, it's the game's own truth; or (b) if you want a fast headless/CI path, emit a resolved
`anchors` array (dressed x/y/z) alongside the dress-source site, and I'll read that too. Either works;
(a) needs nothing from you but the merge.
**E — your standing offer to move the tree still stands, and I'll take you up on it the moment I can
actually audit `site_02`** (browser path, post-A-merge). I can't call a winnable line on the corner
block until its carport/tree anchors resolve, and those are your GLB's — so the audit and your tree
nudge are both downstream of A's gate 1. Ready to run the instant it lands.
site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet).
Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe.
---
## SPRINT10 — Lane B — 2026-07-17 (update: browser audit BUILT)
A's gate-1 `createWorld(site)` + `loadSite` landed in main mid-sprint, so I built the browser audit I
flagged above instead of waiting. **`tools/site_audit/audit.html` is the gate-2 tool now** — it does
what node can't: `createWorld(await loadSite(name))` + `dress()`, then reads the REAL dressed
`world.anchors` (GLB fascia + branch anchors, 8°-raked posts) and runs the sweep.
tools/site_audit/audit.html?site=backyard_01
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
Validated against backyard_01: **dressed 12/12 anchors, PASS, best line p1,p2,p3,p4 $65 + $15 spare**
— identical to the node snapshot AND to balance.test, off your real data with no snapshot anywhere.
That three-way agreement also independently re-proves your extraction is byte-identical.
The winnability math is now one file, `sweep.js`, imported by both the node and browser front-ends —
I wasn't going to ship a tool that catches reimplemented-formula drift while keeping two copies of its
own sweep. audit.mjs output is byte-identical after the refactor.
**A — two knock-ons from your merge, both handled, one worth a glance:**
1. `createWorld` now throws without a site. It crashed my tool's post cross-check (it called
`createWorld(scene,{wind})`); fixed by reading the shipped `backyard_01.json` and passing it. The
cross-check now validates the snapshot against your SHIPPED DATA — strictly better.
2. Your `balance.test.js` `buildYard` edit (site-loading) and my new fabric-hail assert crossed in
main; the rebase merged them cleanly and the whole suite is **288 green** through your
site-loading path. Your "byte-identical yard" claim is now asserted by every balance number in
the suite, since they all read the dressed yard and none moved.
**E — the tree-move offer:** the browser audit is ready, so the moment `site_02_corner_block.json`
lands I run `audit.html?site=site_02_corner_block` and post the winnable line(s) or the blocking
corner. If it's the latter, that's your cue. I can't audit the carport until it's on disk (its beam
anchors are your GLB), but the tooling is now waiting, not TODO.
**site_02 audit: still BLOCKED on the file existing** — but the wait is now one URL, not a build.

132
tools/site_audit/audit.html Normal file
View 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>

View File

@ -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'; })();
@ -91,6 +82,12 @@ const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
/**
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
* The one guard-rail a hand-typed yard can actually have.
*
* SPRINT10: world.js stopped being code createWorld now REQUIRES a site and
* throws without one. It reads the site from data/sites/backyard_01.json, which
* is exactly the extraction A shipped, so this cross-check now compares the dump
* against the DATA's posts (raked in createWorld, no dress needed). loadSite()
* itself fetch()es and can't run in node, so read the JSON and hand it over raw.
*/
async function verifyPosts(site) {
const THREE = await import('../../web/world/vendor/three.module.js');
@ -100,7 +97,8 @@ async function verifyPosts(site) {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
const drift = [];
for (const a of site.anchors) {
@ -119,7 +117,46 @@ async function verifyPosts(site) {
async function loadSite(path) {
if (!path) return BACKYARD_01;
const j = JSON.parse(await readFile(path, 'utf8'));
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
// A's committed site schema (SPRINT10, world.js loadSite/createWorld) is
// DRESS-SOURCE, not resolved positions, and headless node cannot turn one into
// the other. Two reasons, both load-bearing for an audit that must not lie:
//
// · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every
// post 8° away from the yard centre before it becomes an anchor. p4's JSON
// spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this
// tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it.
// · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name
// baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world
// position exists only after dress() reads the empty's matrixWorld, and
// dress() needs GLTFLoader + fetch, neither of which runs in node.
//
// So a headless read of this file gets posts wrong and tree/house anchors not
// at all — and the branch anchors are exactly where the dangerous quads live
// (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6
// trap in reverse: a site called unriggable because the TOOL couldn't read it.
//
// The correct source of dressed positions is createWorld(site).anchors — which
// is browser-only. Until that path lands (this is raised to A in THREADS), the
// honest move is to refuse this schema loudly, not to guess at it.
const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures);
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 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.
// This is what a dressed export (or a future createWorld dump) would hand us.
const anchors = (j.anchors || []).map((a) => ({
id: a.id, type: a.type || 'post',
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
@ -129,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) {
@ -162,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(' ');
@ -234,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
View 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] },
};
}

View File

@ -334,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se
};
}
/**
* Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the
* sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING
* else touched. Returns { cloth, membrane } exposure numbers.
*
* This exists to close a gap C's fabric-hail seam left open. porosity now feeds
* the garden hail score in production rig.porosity -> skyfx.step reads it into
* sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity)
* into what it returns (wired SPRINT9, e576f5c). But the only test of that leak,
* weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with
* hailBlockFor + hailAt it proves the primitive and the arithmetic, not the
* plumbing. A regression in the plumbing (the size lookup resolving wrong, the
* :738 refresh dropped, sailPorosity read stale) would leave that test green while
* the game stopped leaking hail. This drives the actual return, which is the whole
* reason balance.test is a browser suite: measure the real chain, never a copy.
*
* The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t,
* porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity
* and rebuilds the shadow grid at the same instant without advancing physics, so
* the shadow geometry is identical across the two reads and `block` is the only
* thing that moved. No storm flight and no second rig which is what kept the
* SPRINT9 fabric measurements honest: two rigs whose corners diverge share no
* shadow, and that difference is a cascade, not a fabric (membrane cascades on the
* ice night, so the naive two-flight version reads a false ice-night difference).
*
* `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and
* both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7);
* storm_02b_icenight at t=50 (ice, size 1.4).
*/
async function gardenHailByPorosity(yard, stormName, burstT) {
const def = await loadStorm(stormName);
const wind = createWind(def);
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const calmWind = createWind(await loadStorm(CALM_STORM));
calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
if (!s) return null;
s.setFabric('cloth');
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
// Camera is load-bearing, not decoration — without it skyfx returns before the
// shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly().
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
// settle on the calm day so the cloth holds a real drape and stays intact — the
// shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing.
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3);
rig.porosity = 0.30; sky.step(0, burstT, { sail: rig });
const cloth = sky.gardenHailExposure(yard.bed, burstT);
rig.porosity = 0; sky.step(0, burstT, { sail: rig });
const membrane = sky.gardenHailExposure(yard.bed, burstT);
sky.dispose?.();
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
}
/**
* @param {import('../testkit.js').Suite} t
*
@ -372,6 +429,13 @@ export default async function run(t) {
if (membraneShop) membraneShop.setFabric('membrane');
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
// The fabric's OTHER half, and the one C wired the primitive for: porous cloth
// leaks pea hail into the garden score, membrane blocks it. Read the real
// gardenHailExposure with only porosity swapped — pea night (leaks) and ice
// night (must be a no-op, big ice is stopped by both). See the helper.
const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38);
const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57);
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
@ -489,6 +553,41 @@ export default async function run(t) {
`(hp ${membrane.hp}) — the fabric is the decision`;
});
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises
// and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a
// regression in the porosity->skyfx->exposure plumbing goes red HERE even though
// weather.selftest's inline-formula version would stay green. (Answering C's
// THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.)
t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => {
if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80');
if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) {
throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` +
`${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` +
`(camera dropped?). With nothing to block, this proves nothing.`);
}
// pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all.
if (!(peaHail.cloth > peaHail.membrane * 1.05)) {
throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` +
`cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` +
`membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` +
`so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` +
`lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`);
}
// ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1.
// This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which
// is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade.
if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) {
throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` +
`membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` +
`size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`);
}
const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100;
return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` +
`ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`;
});
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
// garden". Now that a clean win EXISTS, this stops being the wild night's only
// outcome and becomes what it should always have been — a different, worse bet