diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index c239f68..1ac9c2d 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -334,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se }; } +/** + * Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the + * sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING + * else touched. Returns { cloth, membrane } exposure numbers. + * + * This exists to close a gap C's fabric-hail seam left open. porosity now feeds + * the garden hail score in production — rig.porosity -> skyfx.step reads it into + * sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity) + * into what it returns (wired SPRINT9, e576f5c). But the only test of that leak, + * weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with + * hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the + * plumbing. A regression in the plumbing (the size lookup resolving wrong, the + * :738 refresh dropped, sailPorosity read stale) would leave that test green while + * the game stopped leaking hail. This drives the actual return, which is the whole + * reason balance.test is a browser suite: measure the real chain, never a copy. + * + * The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t, + * porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity + * and rebuilds the shadow grid at the same instant without advancing physics, so + * the shadow geometry is identical across the two reads and `block` is the only + * thing that moved. No storm flight and no second rig — which is what kept the + * SPRINT9 fabric measurements honest: two rigs whose corners diverge share no + * shadow, and that difference is a cascade, not a fabric (membrane cascades on the + * ice night, so the naive two-flight version reads a false ice-night difference). + * + * `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and + * both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7); + * storm_02b_icenight at t=50 (ice, size 1.4). + */ +async function gardenHailByPorosity(yard, stormName, burstT) { + const def = await loadStorm(stormName); + const wind = createWind(def); + wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); + const calmWind = createWind(await loadStorm(CALM_STORM)); + calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); + + const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); + if (!s) return null; + s.setFabric('cloth'); + const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); + // Camera is load-bearing, not decoration — without it skyfx returns before the + // shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly(). + const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); + camera.position.set(0, 2, 8); + const sky = createSkyFx({ wind, night: true, camera }); + // settle on the calm day so the cloth holds a real drape and stays intact — the + // shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing. + for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3); + + rig.porosity = 0.30; sky.step(0, burstT, { sail: rig }); + const cloth = sky.gardenHailExposure(yard.bed, burstT); + rig.porosity = 0; sky.step(0, burstT, { sail: rig }); + const membrane = sky.gardenHailExposure(yard.bed, burstT); + sky.dispose?.(); + return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length }; +} + /** * @param {import('../testkit.js').Suite} t * @@ -372,6 +429,13 @@ export default async function run(t) { if (membraneShop) membraneShop.setFabric('membrane'); const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null; + // The fabric's OTHER half, and the one C wired the primitive for: porous cloth + // leaks pea hail into the garden score, membrane blocks it. Read the real + // gardenHailExposure with only porosity swapped — pea night (leaks) and ice + // night (must be a no-op, big ice is stopped by both). See the helper. + const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38); + const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57); + const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null; @@ -489,6 +553,41 @@ export default async function run(t) { `(hp ${membrane.hp}) — the fabric is the decision`; }); + // The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it. + // `fabric decides p1` above proves porous saves the CORNER (less wind load); this + // proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises + // and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a + // regression in the porosity->skyfx->exposure plumbing goes red HERE even though + // weather.selftest's inline-formula version would stay green. (Answering C's + // THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.) + t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => { + if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80'); + if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) { + throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` + + `${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` + + `(camera dropped?). With nothing to block, this proves nothing.`); + } + // pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all. + if (!(peaHail.cloth > peaHail.membrane * 1.05)) { + throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` + + `cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` + + `membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` + + `so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` + + `lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`); + } + // ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1. + // This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which + // is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade. + if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) { + throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` + + `membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` + + `size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`); + } + const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100; + return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` + + `ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`; + }); + // The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the // garden". Now that a clean win EXISTS, this stops being the wild night's only // outcome and becomes what it should always have been — a different, worse bet