Lane B S13: THE AUDIT REWRITE — the audit predicts the garden, on C's corrected picture

The scoring quantity is the FLOWN garden state now, not static cover%.
New gardenfly.js: windForSite (C's shared builder — venturi + shelters, one
copy in the repo) → real attach (throws on D's skipped-attach trap) → skyfx
exposure → garden.js, per candidate line; cover% demoted to a labelled
geometry diagnostic. audit.html flies every affordable line + bare bed and
judges the site's pinned separation block on the block's own storm; audit.mjs
(node) keeps fast winnability, gains p5 in its verified dump, and points at
the browser for garden truth.

On C's correction, three structural adoptions:
- windForSite everywhere (three harnesses independently mis-built site wind;
  no fourth copy);
- LIVE anchors via the re-pointable wind proxy — the audit's own frozen-sway
  remap was C's landmine 2 wearing my file's name (audit.html:69);
- the MARGIN rule (AUDIT.MARGIN = 0.15, one copy): every row prices twice —
  $hw holds vs $cleanHw with >=15% headroom — and only clean lines are
  winners; verdict code 'marginal-only' when the budget can only buy the
  knife edge (D's 39.8-TATTERED wild night, C's dead 91.9 headline).

Also game-true flight: the phantom 12s calm settle is GONE from the sweep
(commit->attach->storm is one keypress; the settle skewed every storm sample
12 s off the authored curve — SailRig samples wind at its INTERNAL clock).
That skew is not academic: it is a.test's separation-flight harness too, and
gardenfly.selftest now pins BOTH chains (game-true 63.8 tattered vs skewed
68.4 'full' on the pinned p5 line — the disagreement is flagged to A in
THREADS, not overruled here). The pinned recipe also sweeps regardless of the
18-45 band (it is 45.9 m² — the band predates p5; flagged to A).

Selftest 356/0/0 on the scratch merge (b+a+c@45bdc2d). Mutation-checked:
MARGIN=0 reddens the margin test (node); dropping siteDef from gardenfly's
windForSite call reddens the venturi test (browser, exact test named).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 13:43:43 +10:00
parent 25eefccc69
commit e1e14018bf
8 changed files with 768 additions and 162 deletions

View File

@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<!-- <!--
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool] site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool;
SPRINT13 gate 1.2 — the audit predicts the GARDEN now]
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only 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 sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
@ -11,8 +12,17 @@
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to 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. audit a site with a carport (site_02) before it ships.
SPRINT13: winnability was never the whole question — the audit's static
"cover%" predicted the wild night's garden BACKWARDS (r = 0.42) and told
three people a $20 rig was fine. Every affordable line is now FLOWN through
the real scoring chain (gardenfly.js: attach → settle → skyfx exposure →
garden.js) with the SITE's venturi live, and the verdict reports the garden
state the sim would invoice — FULL / tattered / dead — plus the site's
pinned `separation` target (A's gate-1.4 block in the site JSON), judged on
the block's own storm. cover% stays as a geometry diagnostic only.
tools/site_audit/audit.html?site=backyard_01 tools/site_audit/audit.html?site=backyard_01
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
Served from the repo root (server.py), so the relative imports resolve. Served from the repo root (server.py), so the relative imports resolve.
--> -->
@ -45,6 +55,7 @@ import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js'; import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
import { AUDIT, auditSweep } from './sweep.js'; import { AUDIT, auditSweep } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'backyard_01'; const siteName = q.get('site') || 'backyard_01';
@ -54,29 +65,39 @@ const el = (id) => document.getElementById(id);
const loadJSON = async (path) => (await fetch(path)).json(); const loadJSON = async (path) => (await fetch(path)).json();
async function run() { async function run() {
// Build the site the way the game does — data in, dressed yard out. // Build the site the way the game does — data in, dressed yard out — on a
// RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly
// world.anchors THEMSELVES. The old version here remapped every anchor to a
// static `sway: () => pos`, which froze the gum tree — the same landmine
// that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig
// eats the tree's sway as dynamic load). Backyard posts never noticed;
// every tr1/t1/t2 line was optimistic.
const site = await loadSite(siteName); const site = await loadSite(siteName);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const calmStub = { let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], gustTelegraph: () => null, eventsBetween: () => [],
}; };
const world = createWorld(scene, { wind: calmStub, site }); const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
let dressed = false; let dressed = false;
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } } 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. // world.anchors, LIVE — sway closures and ratingHint intact. The sweep and
// ratingHint rides along (SPRINT12): the sweep prices hardware against // every garden flight below re-point the proxy at their own wind via `use`.
// rating × hint, and dropping it here would audit a yard where the fascia const anchors = world.anchors;
// 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 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 vlist = site.wind?.venturi ?? [];
const vtxt = vlist.length const vtxt = vlist.length
@ -89,23 +110,86 @@ async function run() {
`venturi: ${vtxt}\n` + `venturi: ${vtxt}\n` +
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`; `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 // The venturi rides in on the SITE def — windForSite (C's shared builder,
// at every site load, so the audit must too or the corner block flies with its // inside sweep.js and gardenfly.js) is the ONE door site wind comes through
// funnel switched off (an easier yard than ships). // now. Three harnesses independently mis-built it; there is no fourth copy.
const venturi = site.wind?.venturi ?? []; const bed = world.gardenBed;
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi }); const { cands, rows, verdict, winners, marginalWinners } =
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
// render rows // ── SPRINT13: fly the garden for every line the budget can actually buy ──
// The scoring quantity is the FLOWN outcome (gardenfly.js — real attach,
// real skyfx exposure, real garden.js), not static cover%. Marginal lines
// fly too, deliberately: they are the trap the margin rule exists to name.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
const FLY_CAP = 12;
const flown = new Map();
for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) {
// clean winners fly the CLEAN tiers (what the audit recommends buying);
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId
flown.set(r.ids.join(','), flyGarden({
anchors, bed, stormDef, siteDef: site, use,
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
}));
}
const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP);
// best GARDEN line the budget buys — the audit's answer to "what should I
// rig", which used to be "the cheapest line that holds" and is now "the line
// that saves the garden" — cheapest on ties, and never a marginal one (a
// marginal flight is C's 91.9-FULL illusion; it gets reported, not sold).
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
if (!best) return { key, g };
if (g.hp > best.g.hp + 0.05) return { key, g };
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
return best;
}, null);
const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean);
const bestGarden = pickBest(cleanFlown);
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
// ── the site's pinned separation target (A's gate-1.4 ruling), judged on the
// block's OWN storm — the target is site data, not a per-run choice.
let sep = null, sepStormName = null;
if (site.separation) {
sepStormName = site.separation.stormKey;
const sepStorm = sepStormName === stormName ? stormDef
: await loadJSON(`../../web/world/data/storms/${sepStormName}.json`);
sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
}
// render rows — cover% is a DIAGNOSTIC column now (static overhead projection
// of the taut attach shape); the flown garden column is what scores.
const tbl = document.createElement('table'); const tbl = document.createElement('table');
const hd = tbl.insertRow();
for (const h of ['line', 'm²', 'vert cover (static)', 'holds?', 'garden (flown)', 'corners: peak → tier']) {
const td = hd.insertCell(); td.textContent = h; td.className = 'corner';
}
for (const r of rows) { for (const r of rows) {
const tr = tbl.insertRow(); const tr = tbl.insertRow();
tr.insertCell().textContent = r.ids.join(','); tr.insertCell().textContent = `${r.pinned ? '📌 ' : ''}${r.ids.join(',')}`;
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`; tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`; tr.insertCell().textContent = `${(r.cover * 100).toFixed(0)}%`;
const mk = tr.insertCell(); const mk = tr.insertCell();
if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; } if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; }
else if (r.hw <= START_BUDGET) { mk.textContent = `✓ $${r.hw}`; mk.className = 'ok'; } else if (!r.affordable) { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; } else if (!r.clean) {
mk.textContent = `⚠ $${r.hw} MARGINAL (${r.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}%`).join(' ')})` +
` — clean ${r.cleanHw != null ? `$${r.cleanHw} > $${START_BUDGET}` : 'over the shop ceiling'}`;
mk.className = 'warn';
} else {
mk.textContent = `✓ $${r.cleanHw} clean${r.hw < r.cleanHw ? ` (holds at $${r.hw})` : ''}`;
mk.className = 'ok';
}
const gd = tr.insertCell();
const g = flown.get(r.ids.join(','));
if (g) {
gd.textContent = `${g.hp} ${g.state.toUpperCase()}${g.cornersLost ? ` (${g.cornersLost} lost)` : ''}` +
`${g.marginal.length ? ` ⚠ ${g.marginal.join(',')} inside margin` : ''}`;
gd.className = (g.marginal.length || g.state === 'tattered') ? 'warn' : g.state === 'full' ? 'ok' : 'bad';
} else { gd.textContent = '—'; gd.className = 'corner'; }
const cs = tr.insertCell(); cs.className = 'corner'; const cs = tr.insertCell(); cs.className = 'corner';
cs.innerHTML = r.tiers.map((c) => 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(' '); `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
@ -114,26 +198,79 @@ async function run() {
// verdict // verdict
const v = el('verdict'); const v = el('verdict');
const gardenLine = () => {
const bg = bestGarden ? rowBy(bestGarden.key) : null;
return `\n— the garden (flown, funnel ${vlist.length ? 'ON' : 'n/a'}): bare bed ${bare.hp} ${bare.state.toUpperCase()}` +
(bestGarden
? ` · best clean buyable ${bg.ids.join(',')} ($${bg.cleanHw}) → ${bestGarden.g.hp} ${bestGarden.g.state.toUpperCase()}` +
`${bestGarden.g.cornersLost ? ` (${bestGarden.g.cornersLost} corner(s) lost)` : ''}` +
` · the sail is worth ${(bestGarden.g.hp - bare.hp) >= 0 ? '+' : ''}${(bestGarden.g.hp - bare.hp).toFixed(1)} HP`
: ' · NO clean buyable line to fly') +
(bestMarginal && (!bestGarden || bestMarginal.g.hp > bestGarden.g.hp)
? `\n ⚠ best MARGINAL line ${bestMarginal.key} reads ${bestMarginal.g.hp} ${bestMarginal.g.state.toUpperCase()} on paper — ` +
`inside the 15% break-in-game band, treat as ${bestMarginal.g.hp > bare.hp ? 'the trap, not the answer' : 'dead'}`
: '') +
(skipped ? `\n (${skipped} affordable line(s) beyond the ${FLY_CAP}-flight cap not flown)` : '');
};
const sepLine = () => {
if (!sep) return `\n— separation target: none pinned in the site JSON (A's gate-1.4 block) — nothing to judge against.`;
const s = site.separation;
return `\n— separation target (${sepStormName}): ` +
`held ${sep.held.hp} ${sep.held.state.toUpperCase()} ($${sep.held.cost}, ${sep.held.cornersLost}/4 lost` +
`) vs pinned >${s.heldMustExceed}` +
` · bare ${sep.bare.hp} vs pinned <${s.bareMustLoseBelow}` +
` → ${sep.ok ? 'MEETS the target' : `FAILS (${[!sep.heldOk && 'held not FULL', !sep.heldWin && 'held does not WIN', !sep.bareOk && 'bare does not lose'].filter(Boolean).join(', ')})`}` +
(sep.ok && !sep.heldClean
? `\n ⚠ but the pinned line is KNIFE-EDGED: ${sep.held.marginal.map((id) => {
const p = sep.held.peaks.find((x) => x.id === id);
return `${id} ${(p.headroom * 100).toFixed(0)}% headroom (${p.peakN}/${p.effN} N)`;
}).join(', ')} — inside the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band. ` +
`Flagged to A/D in THREADS, not overruled here: the margin rule's cause is unfound and this yard's ` +
`bench has matched the UI to the decimal — but nobody has PLAYED this line yet.`
: '');
};
if (verdict.code === 'no-cover') { if (verdict.code === 'no-cover') {
v.className = 'verdict fail'; 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` + 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.`; `The site cannot be rigged. It needs an anchor near the bed before it ships.`;
} else if (verdict.code === 'marginal-only') {
const b = verdict.best;
v.className = 'verdict fail';
v.textContent = `✗ MARGINAL ONLY — the budget's ${marginalWinners.length} affordable line(s) all carry a corner inside ` +
`the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (best: ${b.ids.join(',')} at $${b.hw}, ` +
`${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')}; ` +
`clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).\n` +
`On the bench it holds; in the real game it is D's 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.` +
gardenLine() + sepLine();
} else if (!verdict.ok) { } else if (!verdict.ok) {
const b = verdict.best; const b = verdict.best;
v.className = 'verdict fail'; v.className = 'verdict fail';
v.textContent = `✗ FAIL — no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` + 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.` : '.') + (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.`; `\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.` +
gardenLine() + sepLine();
} else { } else {
const w = verdict.best; const w = verdict.best;
v.className = 'verdict pass'; const wTotal = w.cleanHw + AUDIT.SPARE_COST;
v.textContent = `✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` + v.className = (sep && !sep.ok) ? 'verdict fail' : 'verdict pass';
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` + v.textContent = `${(sep && !sep.ok) ? '✗' : '✓'} winnability: ${winners.length} clean affordable line(s)` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`; `${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}; cheapest clean ${w.ids.join(',')} — $${w.cleanHw} hardware` +
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}.` +
gardenLine() + sepLine();
} }
// a machine-readable line, so this page can also be driven headless-in-browser // 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 })) }; window.__audit = {
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`; 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, garden: flown.get(w.ids.join(',')) ?? null })),
marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw,
marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })),
garden: flown.get(w.ids.join(',')) ?? null })),
bare, bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation: sep ? { storm: sepStormName, ...sep } : null,
};
document.title = `site_audit — ${verdict.ok ? 'PASS' : verdict.code.toUpperCase()}${sep ? ` · sep ${sep.ok ? 'MEETS' : 'FAILS'}` : ''}`;
} }
run().catch((e) => { run().catch((e) => {

View File

@ -16,11 +16,19 @@
* balance decision disguised as art, and it should be audited the minute it's * balance decision disguised as art, and it should be audited the minute it's
* drawn, not the sprint after it ships. * drawn, not the sprint after it ships.
* *
* What it does NOT do: score the garden. Winnability is decided before any of * What it does NOT do: score the garden and SPRINT13 made the garden the
* that by whether a quad exists that (a) covers the bed and (b) has peak * scoring quantity (the audit's old static cover% predicted the wild night's
* corner loads the budget can hold. The garden drain only decides how BADLY you * garden BACKWARDS, r = 0.42). skyfx needs `document`, so garden truth lives
* lose a site that was already unwinnable. That's also why this runs in node: * in the BROWSER front-end: audit.html flies every affordable line through
* no skyfx, no document, no browser, ~20 s per site. * gardenfly.js (real attach skyfx exposure garden.js) and judges the
* site's pinned `separation` block. This node tool remains the fast
* winnability check peaks, pricing, the margin rule and it TELLS you
* where the garden verdict is rather than pretending it has one.
*
* Known blindness beyond dressing (stated, not hidden): node hands the sweep
* STATIC anchors, so tree sway real dynamic load on any tr1/t1/t2 line,
* C's landmine 2 is frozen here. Post lines are exact; tree lines read
* optimistic. The browser front-end flies world.anchors live.
*/ */
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
@ -94,11 +102,15 @@ const BACKYARD_01 = {
['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0], ['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0],
['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0], ['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0],
['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0], ['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0],
// SPRINT13: p5, the clothesline post — A's gate-1 anchor (the ruling that
// gave the backyard a FULL-capable wild-night line). h 2.6 in the JSON,
// raked like every post; verified live like every post.
['p5', 'post', 5.840064309022781, 2.591333620780842, -2.1236597487355566, 1.0],
].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })), ].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
}; };
/** Anchors dress() never touches — so live world.js IS authoritative for these. */ /** Anchors dress() never touches — so live world.js IS authoritative for these. */
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']); const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']);
/** /**
* Re-derive the posts from live world.js and fail loudly if the dump drifted. * Re-derive the posts from live world.js and fail loudly if the dump drifted.
@ -187,7 +199,9 @@ async function loadSite(path) {
})); }));
// A resolved export still owes us the site's funnel — the venturi is site data, // A resolved export still owes us the site's funnel — the venturi is site data,
// not storm data, and a sweep without it flies an easier yard than ships. // not storm data, and a sweep without it flies an easier yard than ships.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] }; // The separation block rides along so the tool can at least PRINT the pin.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors,
venturi: j.wind?.venturi ?? [], separation: j.separation ?? null };
} }
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos })); const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
@ -196,7 +210,6 @@ async function main() {
const site = await loadSite(sitePath); const site = await loadSite(sitePath);
const anchors = withSway(site.anchors); const anchors = withSway(site.anchors);
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8')); 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}`); console.log(`\nsite_audit — ${site.name}`);
if (site.dumped) { if (site.dumped) {
@ -211,6 +224,9 @@ async function main() {
console.log(` posts verified against live world.js ✓ ` + console.log(` posts verified against live world.js ✓ ` +
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`); `dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`); console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
// the pinned separation block is site JSON — read it so the dump path can print the pin
const j = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
site.separation = j.separation ?? null;
} }
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`); console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
const venturi = site.venturi ?? []; const venturi = site.venturi ?? [];
@ -218,7 +234,11 @@ async function main() {
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`); console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
// The sweep itself is shared with the browser front-end — see sweep.js. // 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, venturi }); // siteDef carries the venturi AND the pinned separation recipe (the pin
// sweeps regardless of the band — see sweep.js).
const { cands, rows, winners, marginalWinners, verdict } =
auditSweep({ anchors, bed: site.bed, stormDef: def,
siteDef: { wind: { venturi }, separation: site.separation ?? null } });
if (verdict.code === 'no-cover') { 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(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
@ -226,14 +246,33 @@ async function main() {
process.exit(1); process.exit(1);
} }
console.log(`${cands.length} quad(s) in band shading the bed:\n`); console.log(`${cands.length} quad(s) in band shading the bed (cover% is the STATIC vertical projection — a`);
console.log(`geometry diagnostic; the garden verdict is flown in audit.html via gardenfly.js):\n`);
for (const r of rows) { for (const r of rows) {
const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' '); const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `$${r.hw}` : `$${r.hw} > $${START_BUDGET}`; const mark = r.unholdable.length ? '✗ unholdable'
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}`); : !r.affordable ? `$${r.hw} > $${START_BUDGET}`
: !r.clean ? `$${r.hw} MARGINAL(${r.marginal.map((c) => c.id).join(',')})`
: `$${r.cleanHw} clean`;
console.log(` ${((r.pinned ? '📌' : '') + r.ids.join(',')).padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(22)} ${cs}`);
}
if (site.separation) {
console.log(`\nseparation target pinned (${site.separation.stormKey}): ` +
site.separation.line.map((l) => `${l.anchor} ${l.hw}`).join(' + ') +
` — held >${site.separation.heldMustExceed} & WIN, bare <${site.separation.bareMustLoseBelow}.` +
`\n Judged in the BROWSER front-end (garden needs skyfx): tools/site_audit/audit.html?site=...`);
} }
console.log(''); console.log('');
if (verdict.code === 'marginal-only') {
const b = verdict.best;
console.log(`✗ MARGINAL ONLY — every affordable line carries a corner inside the ` +
`${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (C's residual rule). Best: ${b.ids.join(',')} at $${b.hw}` +
` (${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')};` +
` clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).`);
console.log(` On this bench it holds; in the real game it is the 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.\n`);
process.exit(1);
}
if (!verdict.ok) { if (!verdict.ok) {
const best = verdict.best; const best = verdict.best;
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` + console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
@ -242,9 +281,12 @@ async function main() {
process.exit(1); process.exit(1);
} }
const w = verdict.best; const w = verdict.best;
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')}$${w.hw} hardware` + const wTotal = w.cleanHw + AUDIT.SPARE_COST;
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` + console.log(`✓ PASS — ${winners.length} clean affordable line(s)` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`); `${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}. ` +
`Best clean: ${w.ids.join(',')}$${w.cleanHw} hardware` +
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% static cover. Garden verdict: audit.html.\n`);
} }
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); }); main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });

View File

@ -44,12 +44,9 @@
<script type="module"> <script type="module">
import * as THREE from '../../web/world/vendor/three.module.js'; import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js'; import { createWorld, loadSite } from '../../web/world/js/world.js';
import { createWind } from '../../web/world/js/weather.js'; import { HARDWARE } from '../../web/world/js/contracts.js';
import { createSkyFx } from '../../web/world/js/skyfx.js'; import { auditSweep } from './sweep.js';
import { SailRig } from '../../web/world/js/sail.js'; import { flyGarden } from './gardenfly.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
import { createGarden } from '../../web/world/js/garden.js';
import { AUDIT, auditSweep } from './sweep.js';
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'site_02_corner_block'; const siteName = q.get('site') || 'site_02_corner_block';
@ -57,65 +54,47 @@ const stormName = q.get('storm') || 'storm_03b_earlybuster';
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json(); const loadJSON = async (p) => (await fetch(p)).json();
const calmStub = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, setVenturi() {}, eventsBetween: () => [],
};
async function run() { async function run() {
// The yard on a re-pointable wind proxy (C's bench pattern) so world.anchors
// fly THEMSELVES — a static sway remap froze the gum tree and under-read
// every tree corner (C's landmine 2; q4 1.02 frozen vs 1.24 live).
const site = await loadSite(siteName); const site = await loadSite(siteName);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const world = createWorld(scene, { wind: calmStub, site }); let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
await world.dress(); await world.dress();
const bed = world.gardenBed; const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? []; const venturi = site.wind?.venturi ?? [];
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 stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
// The audit's own verdict, for its cover% numbers. // The audit's own sweep, for its (diagnostic) cover% numbers.
const { rows } = auditSweep({ anchors, bed, stormDef, calmDef, venturi }); const { rows } = auditSweep({ anchors, bed, stormDef, siteDef: site, use });
/** /** Fly one line through gardenfly — THE audit scoring chain, no local copy. */
* Fly one line and return the garden HP the SIM would report. const gardenFor = (ids, hwTier, { bare = false } = {}) =>
* Mirrors main.js: sky.step(dt, t, {sail: rig}) then garden.step off the two flyGarden({ anchors, bed, stormDef, siteDef: site, use,
* exposure terms, storm phase only. `held` false = never rig at all (bare bed). ids: bare ? null : ids, hw: hwTier });
*/
function gardenFor(ids, hwTier, { bare = false } = {}) {
const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
let rig = null;
if (!bare) {
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(ids, Array(4).fill(hwTier), 1.0);
}
const sky = createSkyFx({ wind, night: true });
// THE model, not a copy: main.js's own createGarden, imported from garden.js.
const garden = createGarden({ setPlants() {} });
const n = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < n; i++) {
const t = i * FIXED_DT;
if (rig) rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
const cornersLost = rig ? rig.corners.filter((c) => c.failed).length : 4;
return { hp: +garden.hp.toFixed(1), state: garden.state,
byHail: +garden.damage.hail.toFixed(1), byRain: +garden.damage.rain.toFixed(1), cornersLost };
}
el('sub').textContent = el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` + `${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
`flying the SIM's garden loop (skyfx gardenExposure + gardenHailExposure, main.js weights) per line`; `venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain}`).join(' · ') : 'none'} · ` +
`flying gardenfly.js — the sim's own chain (windForSite → attach → skyfx → garden.js), live anchors`;
// The bare bed is the control: what the garden does with NO sail at all. // The bare bed is the control: what the garden does with NO sail at all.
const bareRes = gardenFor(null, null, { bare: true }); const bareRes = gardenFor(null, null, { bare: true });
@ -132,7 +111,7 @@ async function run() {
const tbl = document.createElement('table'); const tbl = document.createElement('table');
const head = tbl.insertRow(); const head = tbl.insertRow();
for (const h of ['line', 'audit cover%', 'hw $', 'garden HP', 'by hail', 'by rain', 'corners lost', 'vs bare bed']) for (const h of ['line', 'audit cover% (static)', 'hw $', 'garden (flown)', 'by hail', 'by rain', 'corners lost', 'vs bare bed'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); } { const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const results = []; const results = [];
@ -141,13 +120,15 @@ async function run() {
results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g }); results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g });
const tr = tbl.insertRow(); const tr = tbl.insertRow();
const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`, const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`,
g.hp.toFixed(1), g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost), `${g.hp.toFixed(1)} ${g.state.toUpperCase()}${g.marginal.length ? ` ⚠ ${g.marginal.join(',')}` : ''}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
`${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`]; `${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`];
cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c; cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c;
if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; }); if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; });
} }
const tr = tbl.insertRow(); const tr = tbl.insertRow();
['BARE BED (no sail)', '—', '$0', bareRes.hp.toFixed(1), bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—'] ['BARE BED (no sail)', '—', '$0', `${bareRes.hp.toFixed(1)} ${bareRes.state.toUpperCase()}`,
bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; }); .forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl); el('out').appendChild(tbl);

View File

@ -0,0 +1,175 @@
/**
* gardenfly.js fly a rig line through the REAL scoring chain and return the
* garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2]
*
* This module exists because the audit's old ranking quantity static cover%
* of the taut attach-shape predicted the wild night's garden BACKWARDS
* (r = 0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was
* bad while the sim paid it +$97. The quantity that scores is the FLOWN
* vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25
* (garden.js), stones fall near-vertical, and a sail either blocks the hail
* over the bed or blocks nothing that matters. Only flying the real chain
* measures that, so this is the audit's scoring engine now; cover% is demoted
* to a geometry diagnostic in both front-ends.
*
* THE chain, one copy of it, shared by audit.html and garden_probe.html:
*
* windForSite(stormDef, siteDef, anchors) (C's shared builder
* venturi + tree shelters, byte-for-byte main.js's site-load wiring)
* SailRig.attach(ids, hw, tension) (the real attach)
* rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call)
* createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy)
*
* NO calm settle, deliberately: in the real game the rig does not exist before
* commit, and commitattachstorm is one keypress (the rig.t fix's own words),
* so the attach transient flies under STORM wind and counts. C measured the
* phantom settle's skew and removed it from the bench the same day.
*
* Landmines this file exists to never re-arm (each earned this sprint):
*
* · D's skipped-attach trap (skyfx.js:803): a harness that never runs
* attach() scores EVERY rig as bare the bare-bed constant lies politely.
* flyGarden throws if the rig it built has no shadow mesh.
* · The venturi lives in the SITE def, not the storm def. THREE harnesses
* shipped that bug (my Sprint-11 audit, C's bench, D's first harness half);
* windForSite is the one door now, and the selftest mutation-checks that
* the funnel reaches this module's wind.
* · The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos`
* freezes the gum tree whose sway is the dynamic load a tr1 rig eats
* q4 read 1.02 frozen vs 1.24 live. Callers hand this module
* world.anchors THEMSELVES and re-point the world's wind proxy via `use`
* so the sway closures sample the storm that is actually flying.
* · THE MARGIN RULE (C's residual): even corrected, benches under-read the
* real UI's peaks by ~515% on site_02 (cause unfound, in the pool). Until
* it is found, any corner within 15% of its effective rating is scored
* "breaks in the game" a flight that holds on paper with q4 at 1.24 vs
* 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden
* reports `marginal` and callers must not print PASS over it.
*
* Per-corner peaks are labelled from `corners[k].anchorId`, never input order
* C's label-permutation warning (attach reorders picks into ring order).
*
* Browser-only by necessity: skyfx needs `document` (node throws), which is
* why audit.mjs (node) prints winnability and POINTS HERE for garden truth.
*/
import { SailRig } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
import { AUDIT } from './sweep.js';
// The margin rule's knob is AUDIT.MARGIN — sweep.js holds the ONE copy and
// both audit engines read it (a local copy here would be the exact drift this
// sprint spent itself killing).
/** Shop hardware by its contracts.js name ("rated shackle") throws on a
* stranger, because a typo'd tier silently becoming a carabiner is exactly
* the setHardware() lesson from gate 3. */
export function hardwareByName(name) {
const hw = HARDWARE.find((h) => h.name === name);
if (!hw) throw new Error(`unknown hardware "${name}" — the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`);
return hw;
}
/**
* Fly one line (or a bare bed) and return the garden verdict.
*
* @param {object} o
* @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved
* {id, type, pos, sway, ratingHint} for synthetic yards
* @param {object} o.bed garden bed rect {x, z, w, d}
* @param {object} o.stormDef storm JSON to fly
* @param {object} [o.siteDef] site JSON its wind.venturi is half the yard's
* weather; omit ONLY for a site with no funnel
* @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern):
* called with this flight's wind so world.anchors'
* tree-sway closures sample the storm actually flying
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
* @param {number} [o.tension]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
let rig = null, cost = 0;
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {
throw new Error('flyGarden: rig is not attached (no shadow mesh) — a bypassed attach scores ' +
'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.');
}
}
const sky = createSkyFx({ wind, night: true });
const garden = createGarden({ setPlants() {} }); // THE model — garden.js, main.js's own
const n = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < n; i++) {
const t = i * FIXED_DT;
if (rig) rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact third param
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
sky.dispose?.();
// labelled from the rig's OWN anchorId — attach reorders picks into ring
// order, and a peak quoted against input order swaps corners (C's warning).
const peaks = rig ? rig.corners.map((c) => {
const eff = c.hw.rating * (c.anchor.ratingHint ?? 1);
return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff),
headroom: +(1 - c.peakLoad / eff).toFixed(3) };
}) : [];
return {
hp: +garden.hp.toFixed(1),
state: garden.state, // 'full' | 'tattered' | 'dead' — garden.js's own thresholds
byHail: +garden.damage.hail.toFixed(1),
byRain: +garden.damage.rain.toFixed(1),
cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4,
peaks,
// C's margin rule: corners that held on paper but sit within MARGIN of
// their effective rating — in the real game these break. A prediction
// carrying names here is NOT a clean hold, whatever the hp says.
marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN)
.map((p) => p.id),
cost,
};
}
/**
* Judge a site against its pinned `separation` block (A's gate-1.4 ruling,
* backyard_01.json): fly the pinned recipe AND a bare bed on the block's own
* storm, and answer the only question the target asks does the best line the
* budget buys read FULL and WIN, and does a bare bed LOSE the night.
*
* `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) EXACTLY the
* rule, so this stays in lockstep with a.test's pin of the same block.
* `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on
* purpose: the pinned target is A's ruling and this module doesn't get to
* overrule it with a working rule whose cause is still unfound but a pinned
* line carrying a corner inside the 15% band is a knife edge the front-end
* must SAY (it's a flag for A/D, not a verdict).
*
* @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }}
*/
export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) {
const ids = separation.line.map((l) => l.anchor);
const hw = separation.line.map((l) => hardwareByName(l.hw));
const tension = separation.tension ?? 1.0;
const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension });
const bare = flyGarden({ anchors, bed, stormDef, siteDef, use });
const heldOk = held.hp > separation.heldMustExceed;
const heldWin = held.hp >= 50 && held.cornersLost < 2;
const heldClean = held.marginal.length === 0;
const bareOk = bare.hp < separation.bareMustLoseBelow;
return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk };
}

View File

@ -0,0 +1,175 @@
/**
* gardenfly.selftest.js the audit's garden engine audits itself. [Lane B, SPRINT13]
*
* Browser-only (skyfx needs `document`), so unlike sweep.selftest.js this is an
* ASYNC factory: b.test.js awaits it and registers the returned [name, fn]
* pairs. The flights happen here, once; the tests assert on captured results
* the same Suite-cannot-await shape a.test.js's pinned-separation flight uses.
*
* What it pins, and why each can fail:
*
* 1. TWO HARNESSES, ONE PIN. a.test flies backyard_01's `separation` block
* through the RiggingSession shop path; this flies the SAME block through
* the audit's own chain (gardenfly: windForSite, direct attach, named
* hardware). Both must land on A's ruling held FULL and winning, bare
* losing. If a retune moves the wild night, both go red together and the
* argument happens in THREADS, not in a drifted tool.
*
* 2. THE VENTURI REACHES THE FLOWN WIND. Three harnesses shipped the same
* bug (site_audit SPRINT11, C's bench, D's first harness): the funnel
* lives in the SITE def and a harness that doesn't wire it flies an
* easier yard than ships. Same synthetic-yard trick as sweep.selftest:
* starve gardenfly of the siteDef's venturi and this goes red.
*
* 3. The shop has no mystery tier: hardwareByName throws on a stranger
* rather than quietly handing back nothing (the setHardware lesson).
*/
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { SailRig } from '../../web/world/js/sail.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/** sweep.selftest's synthetic yard: one quad, storm dead along +Z, funnel on it. */
const SYN_ANCHORS = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const SYN_BED = { x: 0, z: 0, w: 4, d: 4 };
const SYN_STORM = { id: 'gardenfly_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 } };
const SYN_FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
export async function buildGardenflyTests() {
// The real yard, the way the game builds it — on a re-pointable wind proxy
// (C's bench pattern) so world.anchors fly THEMSELVES with live sway (a
// static remap froze the gum tree, C's landmine 2). Fail LOUD if dress
// fails — an undressed yard has no fascia hint, and a garden number
// measured on it is about a different game (C's bare-specifier gun).
const site = await loadSite('backyard_01');
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(new THREE.Scene(), { wind: windProxy, site });
await world.dress();
let sepRun = null, skewRun = null, sepErr = null;
if (site.separation) {
try {
const stormDef = await loadStorm(site.separation.stormKey);
sepRun = flySeparation({
anchors: world.anchors, bed: world.gardenBed,
separation: site.separation, stormDef, siteDef: site, use,
});
// The OTHER harness's chain, reproduced on purpose: a.test settles 12 s
// on the calm day before the storm, and SailRig samples wind at its
// INTERNAL clock — so its storm flies 12 s off the authored curve
// against the sky's t. Measured (2026-07-18, tmp probe → THREADS): the
// skew is worth +4.6 HP on the pinned line (63.8 game-true vs 68.4
// skewed; A's 68.3 to within 0.1, peaks to the digit). This flight
// exists so BOTH harnesses are pinned from one file: if either chain
// drifts, the agreement asserts below go red and name which one.
const calmDef = await loadStorm('storm_01_gentle');
const sep = site.separation;
const wind = windForSite(stormDef, site, world.anchors);
const calm = windForSite(calmDef, site, world.anchors);
use(calm);
const rig = new SailRig({ anchors: world.anchors, gridN: 10, porosity: 0.30 })
.attach(sep.line.map((l) => l.anchor), sep.line.map((l) => hardwareByName(l.hw)), sep.tension ?? 1);
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calm, (i * FIXED_DT) % 3);
use(wind);
const sky = createSkyFx({ wind, night: true });
const garden = createGarden({ setPlants() {} });
const bed = world.gardenBed;
for (let i = 0, n = Math.round(stormDef.duration / FIXED_DT); i < n; i++) {
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
sky.dispose?.();
skewRun = { hp: +garden.hp.toFixed(1), lost: rig.corners.filter((c) => c.broken).length };
} catch (err) { sepErr = String((err && err.stack) || err); }
}
let venRun = null, venErr = null;
try {
const fly = (venturi) => flyGarden({
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
siteDef: { wind: { venturi } },
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
});
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
} catch (err) { venErr = String((err && err.stack) || err); }
return [
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
// the pinned recipe HOLDS and rigging matters by a full state's width —
// but the >66 FULL half of the target is only met in a.test's chain,
// whose 12 s calm settle runs the storm 12 s off the authored curve
// (SailRig samples wind at its INTERNAL clock). The game-true chain
// (commit→attach→storm at t=0, this module) reads ~63.8 TATTERED.
// Ruled numbers are A's; the game is D's to play; this test pins BOTH
// measurements so any drift in either chain goes red and names itself.
// When A re-rules (fix a.test's harness, restate the threshold, or
// re-steel the recipe), the constants below move WITH the ruling.
if (!site.separation) throw new Error('backyard_01 lost its separation block — the audit has no target to read');
if (sepErr) throw new Error(`separation flight died: ${sepErr}`);
const s = site.separation;
assert(sepRun.held.cost <= 80, `the pinned recipe costs $${sepRun.held.cost} — must be night-1 buyable`);
assert(sepRun.held.cornersLost === 0, `pinned line lost ${sepRun.held.cornersLost}/4 corners — it must HOLD the wild night`);
assert(sepRun.bareOk, `bare bed ${sepRun.bare.hp} vs pinned <${s.bareMustLoseBelow} — bare must LOSE the night`);
assert(sepRun.held.hp - sepRun.bare.hp > 25,
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state's width`);
// the two-chain pin: game-true in [60, 66]; a.test's skewed chain > 66.
assert(sepRun.held.hp > 60 && sepRun.held.hp <= s.heldMustExceed,
`game-true chain reads ${sepRun.held.hp} — measured 63.8 at landing, BELOW the pinned >${s.heldMustExceed}. ` +
`If this now exceeds the pin, the disagreement is RESOLVED: delete this ceiling, assert heldOk, ` +
`and close the [B] THREADS flag`);
assert(skewRun.lost === 0 && skewRun.hp > s.heldMustExceed,
`a.test's settle-skewed chain reads ${skewRun.hp} — measured 68.4 at landing (a.test's own 68.3). ` +
`If this dropped, a.test's pin is about to go red too: the wild night itself moved`);
assert(sepRun.ok === (sepRun.heldOk && sepRun.heldWin && sepRun.bareOk),
'flySeparation.ok must agree with its own parts');
}],
['gardenfly: the SITE venturi reaches the flown wind (the bench landmine, third time charmed)', () => {
if (venErr) throw new Error(`venturi flight died: ${venErr}`);
const { bare, funnelled } = venRun;
assert(bare.peaks.length === 4 && funnelled.peaks.length === 4, 'both flights fly 4 corners');
for (const b of bare.peaks) {
// align by anchorId — peaks are ring-ordered, never input-ordered (C's warning)
const f = funnelled.peaks.find((p) => p.id === b.id);
assert(f.peakN > b.peakN,
`corner ${b.id}: funnelled peak ${f.peakN} N is not above bare ${b.peakN} N — ` +
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
}
}],
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
let threw = false;
try { hardwareByName('titanium dream'); } catch { threw = true; }
assert(threw, 'hardwareByName("titanium dream") must throw, not hand back undefined');
assert(hardwareByName('rated shackle').rating === 6500, 'and the real names still resolve');
}],
];
}

View File

@ -7,25 +7,44 @@
* differ in how they GET the anchors and how they PRINT the result. A tool built * 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. * 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}), * Pure given its inputs: hand it anchors (world.anchors themselves, ideally
* the bed rect, the storm + calm-day defs, and the site's venturi list. It flies * live sway matters, see below), the bed rect, the storm def and the SITE def,
* the same settle+storm the game flies and returns ranked rows + a verdict. * and it flies the same attachstorm chain the game flies and returns ranked
* No I/O, no process, no DOM. * rows + a verdict. No I/O, no process, no DOM.
*
* SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) a
* corner priced inside 15% of its effective rating holds on this bench and
* "breaks in the game" (C's residual, cause unfound), so every row prices
* twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines
* are winners. The garden side of the audit lives in gardenfly.js (browser).
*/ */
import { SailRig, orderRing } from '../../web/world/js/sail.js'; import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { createWind } from '../../web/world/js/weather.js'; import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.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. */ /** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = { export const AUDIT = {
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m² BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
MIN_COVER: 0.25, // A's "shades the bed" bar 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 SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
CALM_STORM: 'storm_01_gentle', /**
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
* the real UI's peaks by ~515% on site_02 (cause unfound, in the pool).
* Until it's found, a corner priced within this fraction of its effective
* rating "breaks in the game" the bench held q4 at 1.24-vs-1.2 and read
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
* A line with a marginal corner is flagged, never sold as a clean PASS.
*/
MARGIN: 0.15,
}; };
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
// settle they parameterised. In the real game the rig does not exist before
// commit — commit→attach→storm is one keypress (the rig.t fix's own words) —
// so the attach transient flies under STORM wind and its loads count. The old
// 12 s calm settle + resetPeaks measured a chain the game never flies, and
// also skewed every storm sample 12 s off the authored curve (C measured both
// on the bench and removed its copy the same day).
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */ /** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
export function areaOf(q) { export function areaOf(q) {
@ -52,20 +71,28 @@ export const tierFor = (peakN, ratingHint = 1) =>
/** /**
* Sweep every bed-covering quad and price the cheapest holding line. * Sweep every bed-covering quad and price the cheapest holding line.
* @param {object} o * @param {object} o
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway, * @param {Array} o.anchors world.anchors THEMSELVES where possible (their
* ratingHint } omit ratingHint and the anchor is * sway closures carry the tree's real movement a
* priced at full hardware strength (hint 1), which * static remap froze the gum tree and under-read
* is a LIE for any GLB-dressed anchor (fascia 0.35, * every tr1 corner, C's landmine 2), or resolved
* { id, type, pos, sway, ratingHint } for synthetic
* yards. Omit ratingHint and the anchor is priced
* at full hardware strength (hint 1), which is a
* LIE for any GLB-dressed anchor (fascia 0.35,
* carport beam 0.22). Hand this the dressed truth. * carport beam 0.22). Hand this the dressed truth.
* @param {object} o.bed garden bed rect { x, z, w, d } * @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly * @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle) * @param {object} [o.siteDef] the SITE json its wind.venturi is half the
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi). * yard's weather (three harnesses learned this the
* Omitted = no funnel, which is backyard_01's truth * hard way); preferred over `venturi`
* and a LIE on the corner block. See below. * @param {Array} [o.venturi] bare funnel list, for synthetic yards with no
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }} * site JSON (sweep.selftest). Ignored if siteDef given.
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) called with the sweep's wind so live
* tree-sway closures sample the storm being flown
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
*/ */
export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) { export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
// 1. every quad, in the rigging band, that shades the bed // 1. every quad, in the rigging band, that shades the bed
const cands = []; const cands = [];
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++) for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
@ -81,61 +108,93 @@ export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover }); 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 } }; // The site's PINNED separation line sweeps regardless of the band: A's
// gate-1.4 recipe (p1+p2+p3+p5) is 45.9 m² against the band's 45 — the band
// is an audit heuristic calibrated before p5 existed, and an audit that
// cannot see the site's own ruled-best line is auditing a different yard.
// Flagged `pinned` so front-ends can say why it's there; band-vs-pin
// reconciliation is A's (posted in THREADS).
const pinIds = siteDef?.separation?.line?.map((l) => l.anchor);
if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) {
const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean);
if (q.length === 4) {
try {
const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0);
cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true });
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
}
}
// 2. peak corner loads, settled the way the game settles, on the real storm. if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
// Every clause here is a bug the first draft shipped:
// · createWind + setSheltersFromTrees — trees knock a hole downwind. // 2. peak corner loads, flown the way the GAME flies them. Every clause here
// · settle on the CALM day on a RUNNING clock (main.js's prep), not storm // is a bug some harness shipped:
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40). // · windForSite — the one shared wind builder (C's helper): venturi from
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding // the SITE def + tree shelters, byte-for-byte main.js's site-load
// the settle transient into the storm peak. // wiring. THREE harnesses independently mis-built site wind before it
// · setVenturi — the SITE's funnel, and the bug this clause exists for. // existed (this tool's Sprint-11 funnel-off audit, C's bench reading
// A venturi lives in the site JSON, not the storm, so a sweep built only // def.wind.venturi off the STORM def, D's first garden harness) — a
// from stormDef silently drops it: main.js:424 calls setVenturi on the // fourth copy of the wiring is how there's a fifth bug.
// wind at every site load, and this tool did not. On the corner block — // · `use` re-points the caller's world-wind proxy so LIVE tree-sway
// whose entire weather personality IS the funnel — that under-reports // closures sample this sweep's storm (frozen sway under-read q4 by
// every corner load and hands back an easier yard than the one that // 0.22 kN on the $80 line — C's landmine 2).
// ships. A false PASS, which is the SPRINT6 trap wearing the other face: // · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
// there the tool called a good site unriggable, here it would call a // in the real game, so the attach transient flies under storm wind
// mean site cheap. Both are the tool lying about a yard it can't see. // and its loads count (cheap steel genuinely dies "at the settle" —
const trees = anchors.filter((a) => a.type === 'tree'); // that's the storm's opening seconds, not a separate phase).
const wind = createWind(stormDef); const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
wind.setVenturi(venturi); use?.(wind);
wind.setSheltersFromTrees(trees);
const calmWind = createWind(calmDef);
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
calmWind.setSheltersFromTrees(trees);
const rows = []; const rows = [];
for (const cnd of cands) { for (const cnd of cands) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night // 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 }) const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0); .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); for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the // Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures. // resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
const tiers = rig.corners.map((c) => ({ //
id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1, // TWO prices per corner (C's margin rule):
tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1), // `tier` cheapest hardware that HOLDS the measured peak — what the
})); // old audit sold, and what a player gambling the knife edge
// actually buys;
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
// the price the margin rule trusts (demand ÷ (1 MARGIN)).
// A corner whose `tier` sits inside the margin band is `marginal`: it
// holds on this bench and "breaks in the game" (the residual's working
// rule). The row's clean price is what closing that gap costs.
const tiers = rig.corners.map((c) => {
const hint = c.anchor.ratingHint ?? 1;
const tier = tierFor(c.peakLoad, hint);
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
});
const unholdable = tiers.filter((c) => !c.tier); const unholdable = tiers.filter((c) => !c.tier);
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); 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, const cleanHw = tiers.every((c) => c.cleanTier)
affordable: !unholdable.length && hw <= START_BUDGET }); ? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET });
} }
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1)); rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
const winners = rows.filter((r) => r.affordable); // winners are lines the budget can buy at the CLEAN price (≥15% headroom on
// every corner); a line only affordable on knife-edge steel is the wild-night
// 91.9-FULL illusion and gets its own bucket + verdict code so no front-end
// can print PASS over it by accident.
const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw);
const marginalWinners = rows.filter((r) => r.affordable && !r.clean);
return { return {
cands, rows, winners, cands, rows, winners, marginalWinners,
verdict: winners.length verdict: winners.length
? { ok: true, code: 'pass', best: winners[0] } ? { ok: true, code: 'pass', best: winners[0] }
: { ok: false, code: 'unaffordable', best: rows[0] }, : marginalWinners.length
? { ok: false, code: 'marginal-only', best: marginalWinners[0] }
: { ok: false, code: 'unaffordable', best: rows[0] },
}; };
} }

View File

@ -23,7 +23,8 @@
* collapse to the same peak loads and the strict inequality goes red. * collapse to the same peak loads and the strict inequality goes red.
*/ */
import { auditSweep } from './sweep.js'; import { AUDIT, auditSweep } from './sweep.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
const TESTS = []; const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]); const test = (name, fn) => TESTS.push([name, fn]);
@ -49,13 +50,12 @@ const STORM = {
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14, id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 }, gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
}; };
const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null };
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */ /** Centred on the bed, wide enough to swallow it, aligned with the storm. */
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }]; const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
const peaksOf = (venturi) => { const peaksOf = (venturi) => {
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi }); const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, venturi });
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test'); assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
return rows[0].tiers.map((c) => c.peak); return rows[0].tiers.map((c) => c.peak);
}; };
@ -97,7 +97,7 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
// every PEAK stays byte-identical (the hint is a pricing fact, not physics — // every PEAK stays byte-identical (the hint is a pricing fact, not physics —
// the sweep flies unbreakable audit hardware). Written to fail if the hint // the sweep flies unbreakable audit hardware). Written to fail if the hint
// is dropped from sweep.js's tierFor call: the two runs collapse into one. // is dropped from sweep.js's tierFor call: the two runs collapse into one.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0]; const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }).rows[0];
const honest = sweep(ANCHORS); const honest = sweep(ANCHORS);
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a))); const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
@ -119,4 +119,34 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
'an over-ceiling corner must land in unholdable, or the verdict lies'); 'an over-ceiling corner must land in unholdable, or the verdict lies');
}); });
test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
// The sweep must therefore refuse to call a knife-edge line a winner — the
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
// headroom on a1's measured peak: still priced, still "holds" on paper,
// and the verdict must refuse to bless it. Delete the marginal logic from
// sweep.js and this goes red on the verdict code.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
const honest = sweep(ANCHORS);
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
assert(honest.verdict.ok && honest.verdict.code === 'pass',
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
const RATED = HARDWARE.at(-1);
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: hint, sway: a.sway } : a)));
const row = lied.rows[0];
const a1 = row.tiers.find((c) => c.id === 'a1');
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`);
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
});
export const SWEEP_TESTS = TESTS; export const SWEEP_TESTS = TESTS;

View File

@ -27,12 +27,19 @@
import { SAIL_TESTS } from '../sail.selftest.js'; import { SAIL_TESTS } from '../sail.selftest.js';
import { RIGGING_TESTS } from '../rigging.selftest.js'; import { RIGGING_TESTS } from '../rigging.selftest.js';
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js'; import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js';
/** @param {import('../testkit.js').Suite} t */ /** @param {import('../testkit.js').Suite} t */
export default function run(t) { export default async function run(t) {
for (const [name, fn] of SAIL_TESTS) t.test(name, fn); for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn); for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
// site_audit's own sweep. SPRINT11: the tool was flying site_02 with the // site_audit's own sweep. SPRINT11: the tool was flying site_02 with the
// venturi switched off — the auditor needed an auditor. See sweep.selftest.js. // venturi switched off — the auditor needed an auditor. See sweep.selftest.js.
for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn); for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn);
// SPRINT13: the audit's garden engine (gardenfly.js) — browser-only, so the
// flights run here in the async prelude and the tests assert on the results
// (Suite.test cannot await; a.test's pinned-separation flight is the
// precedent). run() is async now — runAll awaits it.
const gardenflyTests = await buildGardenflyTests();
for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn);
} }