HardYards/tools/site_audit/gardenfly.js
type-two edce5c2758 Lane B S16: flyGarden grows a porosity param — the audit engine can fly membrane
Default stays the pinned 0.30 shade cloth (asserted: omitting porosity ===
explicit 0.30 to the digit, hp and every corner peak), so no pinned number
moves. porosity 0 flies genuinely heavier (per-corner strictly greater,
measured 1.23x average on the synthetic funnel yard). C's storm_06 gate-3.1
membrane-vs-cloth pin and B's membrane-balance tables ride this seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:25:42 +10:00

183 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 ~515% 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 flown. DEFAULT 0.30 (shade cloth — the
* fabric a competent player flies, same as sweep.js,
* and every pinned number to date was measured on
* it); pass 0 for membrane. SPRINT16: the F key is
* live and storm_06's whole bet is membrane-vs-
* cloth, so the audit engine must be able to fly
* both — C's gate-3.1 pin and B's membrane-balance
* tables ride this parameter.
* @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 = 0.30 }) {
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);
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 };
}