sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint (read live from the anchor — dress() mutates in place). Consequence asserted, not the formula: on site_02's carport line (uniform rated shackles, funnel on, 12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension (t~29 s into the storm, honest posts hold) and at max. In-suite control pins the attribution: the same rig with every hint forced to 1 holds 4/4, so the break is the hint, not the load. Mutation-checked: unwiring the line fails both. rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint via _effRating, not bare hw.rating — uniform hardware now flags the genuinely worst steel instead of the first click. Mutation-checked. site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs h.rating * hint >= peak, both front-ends carry ratingHint through (browser off world.anchors, node dump extended with the live-dumped hints), rows print the hint. sweep.selftest asserts hints reprice without touching peaks and land over-ceiling corners in unholdable. Mutation-checked. Selftest 319/0/0 (315 + 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
7.5 KiB
JavaScript
142 lines
7.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 off an anchor with
|
|
* `ratingHint` hint, or null if the shop can't at any price.
|
|
*
|
|
* SPRINT12: sail.js fails a corner on `load > hw.rating * anchor.ratingHint`
|
|
* (A's ruling — the anchor is a failure point, not just the steel), so the
|
|
* hardware that HOLDS is the cheapest h with `h.rating * hint >= peak`, i.e.
|
|
* `h.rating >= peak / hint`. An audit that keeps pricing on bare hw.rating
|
|
* flies a different game than ships: on the corner block it would sell you a
|
|
* $5 carabiner for a beam that now lets go at 0.22 of it.
|
|
*/
|
|
export const tierFor = (peakN, ratingHint = 1) =>
|
|
HARDWARE.find((h) => h.rating * ratingHint >= 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,
|
|
* ratingHint } — 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.
|
|
* @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);
|
|
|
|
// 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.
|
|
const tiers = rig.corners.map((c) => ({
|
|
id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1,
|
|
tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1),
|
|
}));
|
|
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] },
|
|
};
|
|
}
|