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).
122 lines
6.5 KiB
JavaScript
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] },
|
|
};
|
|
}
|