319 lines
19 KiB
JavaScript
319 lines
19 KiB
JavaScript
/**
|
|
* gardenfly.selftest.js — the audit's garden engine audits itself. [Lane B, SPRINT13]
|
|
*
|
|
* Browser-only (skyfx needs `document`), so unlike sweep.selftest.js this is an
|
|
* ASYNC factory: b.test.js awaits it and registers the returned [name, fn]
|
|
* pairs. The flights happen here, once; the tests assert on captured results —
|
|
* the same Suite-cannot-await shape a.test.js's pinned-separation flight uses.
|
|
*
|
|
* What it pins, and why each can fail:
|
|
*
|
|
* 1. TWO HARNESSES, ONE PIN. a.test flies backyard_01's `separation` block
|
|
* through the RiggingSession shop path; this flies the SAME block through
|
|
* the audit's own chain (gardenfly: windForSite, direct attach, named
|
|
* hardware). Both must land on A's ruling — held FULL and winning, bare
|
|
* losing. If a retune moves the wild night, both go red together and the
|
|
* argument happens in THREADS, not in a drifted tool.
|
|
*
|
|
* 2. THE VENTURI REACHES THE FLOWN WIND. Three harnesses shipped the same
|
|
* bug (site_audit SPRINT11, C's bench, D's first harness): the funnel
|
|
* lives in the SITE def and a harness that doesn't wire it flies an
|
|
* easier yard than ships. Same synthetic-yard trick as sweep.selftest:
|
|
* starve gardenfly of the siteDef's venturi and this goes red.
|
|
*
|
|
* 3. The shop has no mystery tier: hardwareByName throws on a stranger
|
|
* rather than quietly handing back nothing (the setHardware lesson).
|
|
*/
|
|
|
|
import * as THREE from '../../web/world/vendor/three.module.js';
|
|
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
|
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
|
import { SailRig } from '../../web/world/js/sail.js';
|
|
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
|
import { createGarden } from '../../web/world/js/garden.js';
|
|
import { FIXED_DT } from '../../web/world/js/contracts.js';
|
|
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
|
|
import { buildScoringWorld, collateralExposure } from './scorecard.js';
|
|
import { factoryExtras } from '../../web/world/js/ratings.js';
|
|
|
|
// SPRINT17 gate 3 [B] — CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
|
|
// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
|
|
// this pin SKIPS WITH A DISCLOSURE and goes live at integration — the same
|
|
// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
|
|
// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
|
|
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
|
|
|
|
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
|
|
|
/** sweep.selftest's synthetic yard: one quad, storm dead along +Z, funnel on it. */
|
|
const SYN_ANCHORS = [
|
|
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
|
|
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
|
|
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
|
|
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
|
|
].map((a) => ({ ...a, sway: () => a.pos }));
|
|
const SYN_BED = { x: 0, z: 0, w: 4, d: 4 };
|
|
const SYN_STORM = { id: 'gardenfly_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
|
|
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 } };
|
|
const SYN_FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
|
|
|
|
export async function buildGardenflyTests() {
|
|
// The real yard, the way the game builds it — on a re-pointable wind proxy
|
|
// (C's bench pattern) so world.anchors fly THEMSELVES with live sway (a
|
|
// static remap froze the gum tree, C's landmine 2). Fail LOUD if dress
|
|
// fails — an undressed yard has no fascia hint, and a garden number
|
|
// measured on it is about a different game (C's bare-specifier gun).
|
|
const site = await loadSite('backyard_01');
|
|
let currentWind = {
|
|
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
|
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
|
gustTelegraph: () => null, eventsBetween: () => [],
|
|
};
|
|
const windProxy = {
|
|
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
|
speedAt: (p, t) => currentWind.speedAt(p, t),
|
|
rainAt: (t) => currentWind.rainAt(t),
|
|
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
|
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
|
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
|
setSheltersFromTrees() {},
|
|
};
|
|
const use = (w) => { currentWind = w; };
|
|
const world = createWorld(new THREE.Scene(), { wind: windProxy, site });
|
|
await world.dress();
|
|
|
|
let sepRun = null, skewRun = null, sepErr = null;
|
|
if (site.separation) {
|
|
try {
|
|
const stormDef = await loadStorm(site.separation.stormKey);
|
|
sepRun = flySeparation({
|
|
anchors: world.anchors, bed: world.gardenBed,
|
|
separation: site.separation, stormDef, siteDef: site, use,
|
|
});
|
|
// The OTHER harness's chain, reproduced on purpose: a.test settles 12 s
|
|
// on the calm day before the storm, and SailRig samples wind at its
|
|
// INTERNAL clock — so its storm flies 12 s off the authored curve
|
|
// against the sky's t. Measured (2026-07-18, tmp probe → THREADS): the
|
|
// skew is worth +4.6 HP on the pinned line (63.8 game-true vs 68.4
|
|
// skewed; A's 68.3 to within 0.1, peaks to the digit). This flight
|
|
// exists so BOTH harnesses are pinned from one file: if either chain
|
|
// drifts, the agreement asserts below go red and name which one.
|
|
const calmDef = await loadStorm('storm_01_gentle');
|
|
const sep = site.separation;
|
|
const wind = windForSite(stormDef, site, world.anchors);
|
|
const calm = windForSite(calmDef, site, world.anchors);
|
|
use(calm);
|
|
const rig = new SailRig({ anchors: world.anchors, gridN: 10, porosity: 0.30 })
|
|
.attach(sep.line.map((l) => l.anchor), sep.line.map((l) => hardwareByName(l.hw)), sep.tension ?? 1);
|
|
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calm, (i * FIXED_DT) % 3);
|
|
use(wind);
|
|
const sky = createSkyFx({ wind, night: true });
|
|
const garden = createGarden({ setPlants() {} });
|
|
const bed = world.gardenBed;
|
|
for (let i = 0, n = Math.round(stormDef.duration / FIXED_DT); i < n; i++) {
|
|
const t = i * FIXED_DT;
|
|
rig.step(FIXED_DT, wind, t);
|
|
sky.step(FIXED_DT, t, { sail: rig });
|
|
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
|
|
}
|
|
sky.dispose?.();
|
|
skewRun = { hp: +garden.hp.toFixed(1), lost: rig.corners.filter((c) => c.broken).length };
|
|
} catch (err) { sepErr = String((err && err.stack) || err); }
|
|
}
|
|
|
|
// SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation —
|
|
// either it pins a block (backyard_01) or it records a measured refusal
|
|
// (_separation_finding). Read all three here so the assert below can hold
|
|
// the invariant: neither is "unjudged", both at once is a stale finding.
|
|
let sepStates = null, sepStatesErr = null;
|
|
try {
|
|
sepStates = [];
|
|
// SPRINT17 gate 3.2 [D]: site_04_pool_yard added to the judged walk in the
|
|
// same commit that ships it (B's file, flagged in THREADS, revert-and-tell-
|
|
// me standard) — a yard missing from this list is UNJUDGED silently, which
|
|
// is the exact silence the gate-2.3 rule forbids. It carries a refusal
|
|
// finding (bare 83.7 FULL under its southerly; receipts in the site file).
|
|
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', 'site_04_pool_yard']) {
|
|
const sd = await loadSite(name);
|
|
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
|
|
}
|
|
} catch (err) { sepStatesErr = String((err && err.stack) || err); }
|
|
|
|
let venRun = null, venErr = null;
|
|
try {
|
|
const fly = (venturi) => flyGarden({
|
|
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
|
|
siteDef: { wind: { venturi } },
|
|
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
|
|
});
|
|
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
|
|
} catch (err) { venErr = String((err && err.stack) || err); }
|
|
|
|
// SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet
|
|
// needs the audit engine to fly both fabrics). Two things must hold or every
|
|
// pinned number in THREADS quietly changes meaning: the DEFAULT is still the
|
|
// 0.30 shade cloth every pin was measured on, and membrane genuinely flies
|
|
// as a different fabric (double the pressure term → heavier corners).
|
|
let fabRun = null, fabErr = null;
|
|
try {
|
|
const flyFab = (porosity) => flyGarden({
|
|
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
|
|
siteDef: { wind: { venturi: SYN_FUNNEL } },
|
|
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
|
|
...(porosity === undefined ? {} : { porosity }),
|
|
});
|
|
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
|
|
} catch (err) { fabErr = String((err && err.stack) || err); }
|
|
|
|
// SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
|
|
// OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
|
|
// deferred gate 3; this pins that a corroded anchor prices honestly the
|
|
// moment it is placed. Node-less placement (E's THREADS: "place with a plain
|
|
// entry and no node field and the rating still lands") — the harder case,
|
|
// where the rating and the collateral both come from the factory, not a
|
|
// dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
|
|
let corRun = null, corErr = null;
|
|
try {
|
|
if (!CORRODED_TIER_LIVE) throw { skip: true };
|
|
const corSite = await loadSite('backyard_01');
|
|
corSite.structures = [...(corSite.structures ?? []), {
|
|
id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
|
|
x: 6, z: 2, rotYDeg: 0, solid: true,
|
|
collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
|
|
anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
|
|
}];
|
|
const built = await buildScoringWorld(corSite);
|
|
const anc = built.anchors.find((a) => a.id === 'cor1_a1');
|
|
const col = {};
|
|
for (const a of built.anchors) {
|
|
if (!a.collateral) continue;
|
|
const p = built.world.collateralFor(a.collateral);
|
|
col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
|
|
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
|
|
}
|
|
corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
|
|
exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
|
|
} catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
|
|
|
|
return [
|
|
['gate 3: a corroded post prices honestly in the card — 0.55 hint, $45 collateral, dressed', () => {
|
|
if (!CORRODED_TIER_LIVE) return 'SKIPPED — corroded tier lands with lane/e; measured GREEN on '
|
|
+ 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
|
|
if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
|
|
assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) — GLB missing or broke`);
|
|
const a = corRun.anchor;
|
|
assert(a, 'the corroded anchor cor1_a1 was not created');
|
|
// The STEEL half, node-less: factoryExtras had to reach it, or the tier
|
|
// math prices this trap as the best steel in the game (adoptAnchor's
|
|
// `?? 1`, E's load-bearing _v1 warning).
|
|
assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 — a node-less placement `
|
|
+ 'lost E\'s factory hint and the card would price it as sound steel');
|
|
assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
|
|
// The COLLATERAL half, through the card's OWN resolver and exposure sum —
|
|
// not the baked GLB value read directly (the carport-to-null shortcut).
|
|
assert(corRun.priced && corRun.priced.cost === 45,
|
|
`world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
|
|
assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
|
|
const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
|
|
assert(line && line.cost === 45,
|
|
`collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
|
|
assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
|
|
'the corroded post landed in UNPRICED — the exposure total silently drops its $45');
|
|
return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
|
|
}],
|
|
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
|
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
|
// the pinned recipe HOLDS and rigging matters by a full state's width —
|
|
// but the >66 FULL half of the target is only met in a.test's chain,
|
|
// whose 12 s calm settle runs the storm 12 s off the authored curve
|
|
// (SailRig samples wind at its INTERNAL clock). The game-true chain
|
|
// (commit→attach→storm at t=0, this module) reads ~63.8 TATTERED.
|
|
// Ruled numbers are A's; the game is D's to play; this test pins BOTH
|
|
// measurements so any drift in either chain goes red and names itself.
|
|
// When A re-rules (fix a.test's harness, restate the threshold, or
|
|
// re-steel the recipe), the constants below move WITH the ruling.
|
|
if (!site.separation) throw new Error('backyard_01 lost its separation block — the audit has no target to read');
|
|
if (sepErr) throw new Error(`separation flight died: ${sepErr}`);
|
|
const s = site.separation;
|
|
assert(sepRun.held.cost <= 80, `the pinned recipe costs $${sepRun.held.cost} — must be night-1 buyable`);
|
|
assert(sepRun.held.cornersLost === 0, `pinned line lost ${sepRun.held.cornersLost}/4 corners — it must HOLD the wild night`);
|
|
assert(sepRun.bareOk, `bare bed ${sepRun.bare.hp} vs pinned <${s.bareMustLoseBelow} — bare must LOSE the night`);
|
|
assert(sepRun.held.hp - sepRun.bare.hp > 25,
|
|
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state's width`);
|
|
// RESOLVED at SPRINT13 integration, per this assert's own instruction: A
|
|
// re-ruled heldMustExceed 66 → 60 on the game-true number (their THREADS
|
|
// entry, "restated, not nudged"), so the two-chain disagreement this used
|
|
// to pin is closed and the pin is asserted directly. B pre-authorized the
|
|
// edit in the message above; A verified 357/0/0 on a scratch merge first.
|
|
assert(sepRun.heldOk,
|
|
`game-true chain reads ${sepRun.held.hp} vs pinned >${s.heldMustExceed} — the held bed must ` +
|
|
`clear the site's separation block for the RIGHT reason (no settle skew)`);
|
|
assert(skewRun.lost === 0 && skewRun.hp > s.heldMustExceed,
|
|
`a.test's settle-skewed chain reads ${skewRun.hp} — measured 68.4 at landing (a.test's own 68.3). ` +
|
|
`If this dropped, a.test's pin is about to go red too: the wild night itself moved`);
|
|
assert(sepRun.ok === (sepRun.heldOk && sepRun.heldWin && sepRun.bareOk),
|
|
'flySeparation.ok must agree with its own parts');
|
|
}],
|
|
['gardenfly: the SITE venturi reaches the flown wind (the bench landmine, third time charmed)', () => {
|
|
if (venErr) throw new Error(`venturi flight died: ${venErr}`);
|
|
const { bare, funnelled } = venRun;
|
|
assert(bare.peaks.length === 4 && funnelled.peaks.length === 4, 'both flights fly 4 corners');
|
|
for (const b of bare.peaks) {
|
|
// align by anchorId — peaks are ring-ordered, never input-ordered (C's warning)
|
|
const f = funnelled.peaks.find((p) => p.id === b.id);
|
|
assert(f.peakN > b.peakN,
|
|
`corner ${b.id}: funnelled peak ${f.peakN} N is not above bare ${b.peakN} N — ` +
|
|
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
|
|
}
|
|
}],
|
|
['gate 2.3: every shipped yard is separation-JUDGED — pinned, or measured-and-refused', () => {
|
|
// SPRINT16's rule made durable: a yard may pin a separation block or it
|
|
// may carry a measured refusal (_separation_finding, the honest "this
|
|
// yard cannot separate at $80 under its storm" verdict with receipts) —
|
|
// but silence is not an option, and holding both means the finding went
|
|
// stale the day the pin landed and must be deleted (its own text says so).
|
|
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
|
|
assert(sepStates.length === 4, `expected 4 shipped yards, read ${sepStates.length}`);
|
|
for (const s of sepStates) {
|
|
assert(s.pinned || s.finding,
|
|
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
|
|
'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)');
|
|
assert(!(s.pinned && s.finding),
|
|
`${s.name} carries BOTH a pin and a refusal finding — the finding is stale, delete it in the pinning commit`);
|
|
}
|
|
return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' · ');
|
|
}],
|
|
['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => {
|
|
if (fabErr) throw new Error(`fabric flight died: ${fabErr}`);
|
|
const { byDefault, cloth, membrane } = fabRun;
|
|
// omitting porosity === saying 0.30, to the digit — every pinned number
|
|
// in THREADS was measured on the default and must not move under it
|
|
assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} — the default fabric drifted`);
|
|
for (const d of byDefault.peaks) {
|
|
const c = cloth.peaks.find((p) => p.id === d.id);
|
|
assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} — the default fabric drifted`);
|
|
}
|
|
// porosity 0 must be a genuinely different fabric: the solid membrane
|
|
// catches the full pressure term, so every corner flies heavier
|
|
for (const m of membrane.peaks) {
|
|
const c = cloth.peaks.find((p) => p.id === m.id);
|
|
assert(m.peakN > c.peakN,
|
|
`corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N — the porosity param is not reaching the rig`);
|
|
}
|
|
// Measured 1.23x on this synthetic yard (browser, 2026-07-20) — less
|
|
// than the raw 1/0.7 pressure ratio because relative wind bleeds load as
|
|
// the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner
|
|
// strictly-greater asserts above prove the wiring; this only guards
|
|
// against the difference collapsing to epsilon.
|
|
const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4;
|
|
assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average — porosity is wired but not biting`);
|
|
}],
|
|
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
|
|
let threw = false;
|
|
try { hardwareByName('titanium dream'); } catch { threw = true; }
|
|
assert(threw, 'hardwareByName("titanium dream") must throw, not hand back undefined');
|
|
assert(hardwareByName('rated shackle').rating === 6500, 'and the real names still resolve');
|
|
}],
|
|
];
|
|
}
|