/** * 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 anchors (world.anchors themselves, ideally — * live sway matters, see below), the bed rect, the storm def and the SITE def, * and it flies the same attach→storm chain the game flies and returns ranked * 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 { windForSite } 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 SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer /** * C's margin rule (SPRINT13 correction): even a corrected bench under-reads * the real UI's peaks by ~5–15% 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. */ 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 world.anchors THEMSELVES where possible (their * sway closures carry the tree's real movement — a * static remap froze the gum tree and under-read * 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. * @param {object} o.bed garden bed rect { x, z, w, d } * @param {object} o.stormDef the storm JSON to fly * @param {object} [o.siteDef] the SITE json — its wind.venturi is half the * yard's weather (three harnesses learned this the * hard way); preferred over `venturi` * @param {Array} [o.venturi] bare funnel list, for synthetic yards with no * 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, siteDef = null, venturi = [], use = null }) { // 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 }); } // 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 */ } } } if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } }; // 2. peak corner loads, flown the way the GAME flies them. Every clause here // is a bug some harness shipped: // · windForSite — the one shared wind builder (C's helper): venturi from // the SITE def + tree shelters, byte-for-byte main.js's site-load // wiring. THREE harnesses independently mis-built site wind before it // existed (this tool's Sprint-11 funnel-off audit, C's bench reading // def.wind.venturi off the STORM def, D's first garden harness) — a // fourth copy of the wiring is how there's a fifth bug. // · `use` re-points the caller's world-wind proxy so LIVE tree-sway // closures sample this sweep's storm (frozen sway under-read q4 by // 0.22 kN on the $80 line — C's landmine 2). // · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress // in the real game, so the attach transient flies under storm wind // and its loads count (cheap steel genuinely dies "at the settle" — // that's the storm's opening seconds, not a separate phase). const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors); use?.(wind); 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; 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. // // TWO prices per corner (C's margin rule): // `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 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 cleanHw = tiers.every((c) => c.cleanTier) ? 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)); // 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 { cands, rows, winners, marginalWinners, verdict: winners.length ? { ok: true, code: 'pass', best: winners[0] } : marginalWinners.length ? { ok: false, code: 'marginal-only', best: marginalWinners[0] } : { ok: false, code: 'unaffordable', best: rows[0] }, }; }