HardYards/tools/site_audit/sweep.js
type-two 4095df8b10 Lane B S11: audit the venturi, land setWorld/setBudget, revive the harness
site_audit's headless false-negative is fixed by the browser front-end
(A's loadSite/createWorld landed in main — that was all it waited on), and
fixing it surfaced a worse bug underneath: the sweep built its wind from the
STORM def alone and never called setVenturi, which main.js:424 does at every
site load. The venturi is SITE data, so every site_02 audit ever run — including
the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The
SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable,
here it called a mean site cheap.

- sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle);
  both front-ends pass the site's funnel and print it, so a funnelled run is
  legible as one.
- sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor.
  Funnel must strictly raise every corner; a funnel-less site must be
  byte-identical. Verified it fails when the setVenturi calls are deleted.
- rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks.
  Markers are rebuilt (the anchor set changes across sites) and disposed; stale
  picks die with the old yard. Deletes A's "one private touch" into this module.
- dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which
  now throws) and nothing noticed, because a dev page has no selftest to go red.
  Fixed, dressed, and given the site switch it should have had (N = next yard).

Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines
affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need
dropping and E's tree stays put. It's a reach problem, not a gain problem: the
throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over
the carport at +50% and reaches the garden at +0.0%.

selftest 300/0/0 (296 + 4 new).
2026-07-17 17:42:54 +10:00

122 lines
6.5 KiB
JavaScript

/**
* 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, the storm + calm-day defs, and the site's venturi list. 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)
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi).
* Omitted = no funnel, which is backyard_01's truth
* and a LIE on the corner block. See below.
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
*/
export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
// 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.
// · setVenturi — the SITE's funnel, and the bug this clause exists for.
// A venturi lives in the site JSON, not the storm, so a sweep built only
// from stormDef silently drops it: main.js:424 calls setVenturi on the
// wind at every site load, and this tool did not. On the corner block —
// whose entire weather personality IS the funnel — that under-reports
// every corner load and hands back an easier yard than the one that
// ships. A false PASS, which is the SPRINT6 trap wearing the other face:
// there the tool called a good site unriggable, here it would call a
// mean site cheap. Both are the tool lying about a yard it can't see.
const trees = anchors.filter((a) => a.type === 'tree');
const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(trees);
const calmWind = createWind(calmDef);
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
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] },
};
}