/** * gardenfly.js — fly a rig line through the REAL scoring chain and return the * garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2] * * This module exists because the audit's old ranking quantity — static cover% * of the taut attach-shape — predicted the wild night's garden BACKWARDS * (r = −0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was * bad while the sim paid it +$97. The quantity that scores is the FLOWN * vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25 * (garden.js), stones fall near-vertical, and a sail either blocks the hail * over the bed or blocks nothing that matters. Only flying the real chain * measures that, so this is the audit's scoring engine now; cover% is demoted * to a geometry diagnostic in both front-ends. * * THE chain, one copy of it, shared by audit.html and garden_probe.html: * * windForSite(stormDef, siteDef, anchors) (C's shared builder — * venturi + tree shelters, byte-for-byte main.js's site-load wiring) * → SailRig.attach(ids, hw, tension) (the real attach) * → rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call) * → createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy) * * NO calm settle, deliberately: in the real game the rig does not exist before * commit, and commit→attach→storm is one keypress (the rig.t fix's own words), * so the attach transient flies under STORM wind and counts. C measured the * phantom settle's skew and removed it from the bench the same day. * * Landmines this file exists to never re-arm (each earned this sprint): * * · D's skipped-attach trap (skyfx.js:803): a harness that never runs * attach() scores EVERY rig as bare — the bare-bed constant lies politely. * flyGarden throws if the rig it built has no shadow mesh. * · The venturi lives in the SITE def, not the storm def. THREE harnesses * shipped that bug (my Sprint-11 audit, C's bench, D's first harness half); * windForSite is the one door now, and the selftest mutation-checks that * the funnel reaches this module's wind. * · The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos` * freezes the gum tree whose sway is the dynamic load a tr1 rig eats — * q4 read 1.02 frozen vs 1.24 live. Callers hand this module * world.anchors THEMSELVES and re-point the world's wind proxy via `use` * so the sway closures sample the storm that is actually flying. * · THE MARGIN RULE (C's residual): even corrected, benches under-read the * real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool). Until * it is found, any corner within 15% of its effective rating is scored * "breaks in the game" — a flight that holds on paper with q4 at 1.24 vs * 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden * reports `marginal` and callers must not print PASS over it. * * Per-corner peaks are labelled from `corners[k].anchorId`, never input order — * C's label-permutation warning (attach reorders picks into ring order). * * Browser-only by necessity: skyfx needs `document` (node throws), which is * why audit.mjs (node) prints winnability and POINTS HERE for garden truth. */ import { SailRig } from '../../web/world/js/sail.js'; import { windForSite } from '../../web/world/js/weather.js'; import { createSkyFx } from '../../web/world/js/skyfx.js'; import { createGarden } from '../../web/world/js/garden.js'; import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js'; import { AUDIT } from './sweep.js'; // The margin rule's knob is AUDIT.MARGIN — sweep.js holds the ONE copy and // both audit engines read it (a local copy here would be the exact drift this // sprint spent itself killing). /** Shop hardware by its contracts.js name ("rated shackle") — throws on a * stranger, because a typo'd tier silently becoming a carabiner is exactly * the setHardware() lesson from gate 3. */ export function hardwareByName(name) { const hw = HARDWARE.find((h) => h.name === name); if (!hw) throw new Error(`unknown hardware "${name}" — the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`); return hw; } /** * Fly one line (or a bare bed) and return the garden verdict. * * @param {object} o * @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved * {id, type, pos, sway, ratingHint} for synthetic yards * @param {object} o.bed garden bed rect {x, z, w, d} * @param {object} o.stormDef storm JSON to fly * @param {object} [o.siteDef] site JSON — its wind.venturi is half the yard's * weather; omit ONLY for a site with no funnel * @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern): * called with this flight's wind so world.anchors' * tree-sway closures sample the storm actually flying * @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control) * @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4 * @param {number} [o.tension] * @param {number} [o.porosity] fabric: 0.30 = shade cloth (the default every * audit number to date was flown on — unchanged), * 0 = membrane. SPRINT16 gate 3.1 measures the * fabric bet through THIS chain, same driver as * every other garden number, so cloth-vs-membrane * differ by exactly one variable. [Lane C addition, * THREADS 2026-07-20 — B: contest at integration if * you want a different door.] * @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }} */ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = AUDIT.POROSITY }) { const wind = windForSite(stormDef, siteDef, anchors); use?.(wind); let rig = null, cost = 0; if (ids) { const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle')); cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0); // shade cloth (AUDIT.POROSITY — the sweep charter, one knob since // SPRINT17 gate 0.3) unless the caller is measuring the fabric bet. rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension); // D's skipped-attach trap: a rig without its shadow mesh scores as a bare // bed and the number LOOKS plausible. Refuse to fly it. if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) { throw new Error('flyGarden: rig is not attached (no shadow mesh) — a bypassed attach scores ' + 'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.'); } } const sky = createSkyFx({ wind, night: true }); const garden = createGarden({ setPlants() {} }); // THE model — garden.js, main.js's own const n = Math.round(stormDef.duration / FIXED_DT); for (let i = 0; i < n; i++) { const t = i * FIXED_DT; if (rig) rig.step(FIXED_DT, wind, t); sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact third param garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t)); } sky.dispose?.(); // labelled from the rig's OWN anchorId — attach reorders picks into ring // order, and a peak quoted against input order swaps corners (C's warning). const peaks = rig ? rig.corners.map((c) => { const eff = c.hw.rating * (c.anchor.ratingHint ?? 1); return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff), headroom: +(1 - c.peakLoad / eff).toFixed(3) }; }) : []; return { hp: +garden.hp.toFixed(1), state: garden.state, // 'full' | 'tattered' | 'dead' — garden.js's own thresholds byHail: +garden.damage.hail.toFixed(1), byRain: +garden.damage.rain.toFixed(1), cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4, peaks, // C's margin rule: corners that held on paper but sit within MARGIN of // their effective rating — in the real game these break. A prediction // carrying names here is NOT a clean hold, whatever the hp says. marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN) .map((p) => p.id), cost, }; } /** * Judge a site against its pinned `separation` block (A's gate-1.4 ruling, * backyard_01.json): fly the pinned recipe AND a bare bed on the block's own * storm, and answer the only question the target asks — does the best line the * budget buys read FULL and WIN, and does a bare bed LOSE the night. * * `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) — EXACTLY the * rule, so this stays in lockstep with a.test's pin of the same block. * `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on * purpose: the pinned target is A's ruling and this module doesn't get to * overrule it with a working rule whose cause is still unfound — but a pinned * line carrying a corner inside the 15% band is a knife edge the front-end * must SAY (it's a flag for A/D, not a verdict). * * @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }} */ export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) { const ids = separation.line.map((l) => l.anchor); const hw = separation.line.map((l) => hardwareByName(l.hw)); const tension = separation.tension ?? 1.0; const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension }); const bare = flyGarden({ anchors, bed, stormDef, siteDef, use }); const heldOk = held.hp > separation.heldMustExceed; const heldWin = held.hp >= 50 && held.cornersLost < 2; const heldClean = held.marginal.length === 0; const bareOk = bare.hp < separation.bareMustLoseBelow; return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk }; }