site_04_pool_yard: E's ring (fourteen honest noes) east, the bed poolside, ONE corroded post in the venturi channel where the NE corner wants steel. Authored in the editor (palette shapes, iterative SCORE IT x3, canonical export). Gauntlet: WINNABLE at $80 — 13 quads, 6 clean, 1 marginal; cheapest hold $40 (h1,t1b,p2,p3, 92.7 FULL, no rust); every 92%-cover line routes through c1 — clean $65 rated on t1,p1,p2,c1, UNHOLDABLE on the two shapes that pull the eye harder. audit.html over the shipped file reproduces the card to the digit. Separation REFUSED with receipts (bare 83.7 FULL — mild-night canon, A's 0.4). board.js (A's granted edit): pool_yard_southerly POOL entry with the audit receipt. d.test: +5 pins (chosen-never-scripted, ring/rust facts, judged-XOR, wreck apron, exposure $160). gardenfly.selftest (B's file, flagged): judged walk 3 -> 4 in the same commit that ships the yard. Selftest 515/0/0 (510 + 5, the count moved by exactly what was added). Mutations: 1 full-suite (finding stripped -> DOUBLE red: B's walk 'UNJUDGED' + my XOR pin, 513/2), 10 probed (entry deleted/re-paired/receipt stripped/ scripted-slot, gnome unpriced/rust zeroed/fence priced, apron x3) — every one red with its own words, controls green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
255 lines
15 KiB
JavaScript
255 lines
15 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';
|
|
|
|
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); }
|
|
|
|
return [
|
|
['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');
|
|
}],
|
|
];
|
|
}
|