/** * 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] }, }; }