/** * balance.test.js — is the game FAIR? [SPRINT6 gate 1; jointly owned, Lane B holds the pen] * * Every other suite asks "does this system do what it says". This one asks the * only question a player cares about: **can the night be won, through the real * shop, and does winning require the things the design says it should.** * * It is deliberately a browser suite. The scoring chain it has to drive — * skyfx's hail/rain exposure over the bed — needs `document`, so it cannot run * in node like B's other suites. Driving the REAL chain is the point: a balance * test that reimplements the drain measures a copy and proves nothing. * * The shape of every assert here is: * 1. build a loadout through RiggingSession, so the $80 shop is real money; * 2. fly the real storm JSON over an in-band quad from the real yard; * 3. integrate the real exposure helpers into the real garden drain; * 4. judge with main.js's own win rule (hp >= 50 && corners lost < 2). * * Measured 2026-07-18 on merged main (weights hail 5.0 / rain 0.25, drain 0.9, * downdraftOfTotal 0.45). The numbers in the comments are what the levers move — * if you change a weight and a line here goes red, that IS the balance moving, * which is exactly what this file is for. */ import * as THREE from '../../vendor/three.module.js'; import { RiggingSession } from '../rigging.js'; import { SailRig } from '../sail.js'; import { createSkyFx } from '../skyfx.js'; import { createWind, loadStorm } from '../weather.js'; import { HARDWARE, FIXED_DT, START_BUDGET } from '../contracts.js'; // SPRINT13: the garden weights come from garden.js — the SAME module main.js // flies — not from a local copy. The old `const GARDEN_DRAIN = 0.9 // main.js` // here was the drift A's export exists to kill: a retune would have landed in // the game and left this suite green against last sprint's numbers. import { GARDEN_DRAIN, HAIL_WEIGHT as W_HAIL, RAIN_WEIGHT as W_RAIN } from '../garden.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; /** main.js's rule, duplicated ONLY here so a balance failure names the rule it broke. */ const WIN = (hp, lost) => hp >= 50 && lost < 2; /** main.js's CALM_STORM — what wind.use() hands the rig outside a storm, and so what settles it. */ const CALM_STORM = 'storm_01_gentle'; /** * The yard is READ FROM world.js, never copied. * * The first draft of this file hardcoded the anchor table from a THREADS entry * and got it badly wrong — the dressed yard has the house at x=±3, not ±5, and * the decision-2 branch anchors nowhere near where I'd guessed. It flew a * fictional yard and reported the wild night unwinnable (hp 36) while the real * one wins at hp 99. That is the same failure as Sprint 3's 16.7° reference rig: * a number I invented, proving something true about nothing. A balance suite in * particular cannot afford it — the yard IS the balance. So: build the real * world, take its anchors, and freeze only the sway. * * Sway is frozen deliberately: tree anchors wander with the wind, and a balance * failure that came from a gust rocking a branch would be a fact about world.js, * not about whether the shop can buy a winnable rig. */ async function buildYard() { const THREE = await import('../../vendor/three.module.js'); const { createWorld } = await import('../world.js'); const scene = new THREE.Scene(); const calm = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], }; // SPRINT10 (Lane A, in Lane B's file — minimal, shout if you'd rather it read // differently): the yard is data now, so createWorld needs the site. Loading // the real backyard_01 keeps this suite's "read the yard, never copy it" rule // intact — it's the same yard, from its source instead of from code. const { loadSite } = await import('../world.js'); const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') }); if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } } const anchors = world.anchors.map((a) => { const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; return { id: a.id, type: a.type, pos, sway: () => pos }; }); return { anchors, bed: world.gardenBed, heightAt: world.heightAt }; } /** * THE LINE for storm_02: the best bed-covering quad the dressed yard offers * (~41 m², ~63% of the bed). Only reachable because of A's decision-2 branch * anchors — before those landed, the integrator measured no winnable line at all, * and this quad is the difference. */ // Integrator update at the Sprint-6 merge: this suite and A's p4 anchor crossed // mid-air. The quad below was the best PRE-p4 cover and its p1 corner pulls // 7.4 kN — unholdable at any price (B's own blocker analysis, kept in THREADS). // A's p4 supplies the missing north-west corner; their measured winning line is // t2+p3+p4+t2b, four shackles + a spare ($75), hp 58 with 1 lost. That line is // what THE LINE now flies. The old quad stays as the geometry control below. // SPRINT9 — THE LINE moves to C's quad, the only holdable one in the yard. // // C swept every bed-covering quad: thirteen have a corner over 6.5 kN, i.e. // unholdable at any price. Exactly one is buyable — p1,p2,p3,p4 — and only just. // It needs rated on the two heavy corners (p4, p2), a shackle on p3 and the // cheapest thing that will hold p1. That last corner is the whole $10 gap // between a $90 rig and an $80 budget. // // C IS RIGHT ABOUT p1, AND I WAS WRONG. An earlier revision of this comment // claimed C's table didn't reproduce — that p1 peaked at 1.27 kN with cloth at // dd 0.40, never crossing under a carabiner's 1.2 kN rating, and that the line // therefore won on TIME (a 6% overshoot too brief to trip OVERLOAD_SECS) rather // than on headroom. That was wrong, and the retraction is the useful part: // // re-measured on the dressed yard, this exact flight, one variable — 2026-07-17 // shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost // membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO // // p1 peaks at 0.98 kN against a 1.2 kN rating: 18% of real HEADROOM, and the // carabiner is never once over its rating. C reported 1.08. That is the same // number to within measurement noise, and the same conclusion. // // Where 1.27 came from: a loadout where a corner BROKE. Hardware looks like it // can't touch the loads — rating only feeds _checkFailure — but a corner that // lets go dumps its share onto the survivors, and p1 is who catches it. Measured: // the same quad with hardware the budget can't actually buy (setHardware fails // silently, cheap hardware stays on, p2/p4 tear off) reads p1 at 2.48 kN. Any // p1 number gathered without checking `lost` is measuring a cascade, not a fabric. // // So the fabric is not cosmetic and this is the whole design win: 0.98 vs 1.23 // against a 1.20 rating is the difference between the $5 carabiner holding the // wild night and failing it. Choosing membrane doesn't just cost wind load and // pond — it takes your cheapest corner off the post. `fabric_decides_p1` below // asserts exactly that, so nobody has to trust this paragraph. const COVER_QUAD = ['p1', 'p2', 'p3', 'p4']; /** The old line: holds nothing above shackle grade, ends pyrrhic. The sacrifice-play control. */ const PYRRHIC_QUAD = ['t2', 'p3', 'p4', 't2b']; const PRE_P4_QUAD = ['p1', 't1b', 't1c', 't2b']; /** A rig that holds fine and shades nothing — the decision-13 control. */ const MISS_QUAD = ['h1', 'h2', 'h3', 't1']; /** * Buy a loadout through the real shop. Returns null if $80 doesn't stretch to it. * `tension` defaults to the dial's own neutral (1.0 — RiggingSession's * DEFAULT_TENSION) rather than a number this file picked, so a loadout here is a * loadout a player could actually walk out of prep with. Gate 0 swept 0.6-1.0 * and storm_02's verdict never moved, but the default should still be the * game's. */ function shop(yard, ids, hw, spares = 0, tension = 1.0) { const s = new RiggingSession({ anchors: yard.anchors }); for (const id of ids) if (!s.rig(id).ok) return null; for (let i = 0; i < ids.length; i++) if (!s.setHardware(ids[i], hw[i]).ok) return null; if (spares && !s.setSpares(spares).ok) return null; s.setTension(tension); return s; } /** * Fly a bought loadout through a storm and score it exactly as the game does. * @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}} */ async function fly(yard, session, stormName, { repair = false, broom = false, settleSecs = 12 } = {}) { const def = await loadStorm(stormName); const wind = createWind(def); // The settle below runs on the CALM day, because that is what main.js hands the rig outside a // storm: `wind.use(to === 'storm' ? winds[stormKey] : calmWind)`. const calmDef = await loadStorm(CALM_STORM); const calmWind = createWind(calmDef); calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); // main.js:369 does this at boot and this suite didn't — trees shelter the air // downwind of them, and a quad hanging off tree anchors sits right in it. // (Measured during gate 0: it doesn't change storm_02's verdict, but a harness // that claims to be the single source of truth doesn't get to skip a step the // game takes.) wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); const rig = session.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); // The camera is NOT decoration here, and leaving it out is what caused the // SPRINT6 harness dispute (gate 0, cause found by Lane A 2026-07-18). // // skyfx.step() opens with `if (!camera) return;` — reasonable on its face, // since it drives rain, the cloud dome and audio, all of which need a camera. // But the hail/rain SHADOW GRIDS are rebuilt inside that same step(). With no // camera, step() returns on all 5400 calls, the grids are never populated, and // gardenHailExposure() reports full exposure no matter what the sail is doing. // // Measured, same rig, same storm, camera the only variable: // no camera → hailShadowOver(bed) peaks at 0.000, hp 36 // camera → hailShadowOver(bed) peaks at 1.000, hp 69 // hp 36 is the BARE-BED number. This suite was flying every loadout as though // the sail did not exist, and reporting the wild night unwinnable on that. // // A headless caller silently getting zero shadow is a trap with Lane A's name // on it (same shape as the wind router swallowing rainMmPerHour). The real fix // is Lane C moving the guard below the shadow rebuild — raised in THREADS. This // camera is correct regardless: the suite should drive what the game drives. const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); camera.position.set(0, 2, 8); const sky = createSkyFx({ wind, night: true, camera }); // main.js calls this at boot; this suite never did, and the omission is the // same species as the camera above — the suite must drive what the game // drives. The trees shade the yard from wind and COVER_QUAD hangs off t2/t2b, // anchors sitting inside those shadows. wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree')); // THE SETTLE. A player plays through prep, so ~12 s pass before ENTER and the cloth reaches // steady state. A harness that rigs and storms in the same tick lands the storm on the ATTACH // TRANSIENT instead. // // Now driven the way main.js drives prep, which is the last of the three suite-vs-game // differences gate 0' was chartered to close: `wind.use(calmWind)` on the phase change, and // `windTime()` returning `simT % calmWind.duration` — i.e. CALM WIND ON A RUNNING CLOCK, not the // storm's wind frozen at t=0. Measured, Lane D 2026-07-18, this quad: // frozen t=0, storm wind → 1.94 kN sitting on the corners at storm entry // calm wind, clock running → 0.40 kN ← what a player actually walks in with // // ⚠️ AND THE HONEST PART: this does NOT change the corner count, and my Sprint-7 claim that it // did is RETRACTED. Measured on current main, live game, fresh page, one variable: // 0 s settle → 2 lost [t2,t2b], t2 peak 4.54 kN // 12 s settle → 2 lost [t2,t2b], t2 peak 4.55 kN // The storm's own peak (4.54 kN at t=75.9) dwarfs the transient, so the transient never decided // anything. The settle is a fidelity fix, not the corner-count cause. B's arithmetic was right: // t2 pulls ~4.5 kN and a shackle is rated 3.2 — the line cannot hold, at any tension, settled or // not. See THREADS. const settleWind = calmWind || wind; // Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL // duration replayed all of storm_01's gusts inside 12 s of sim, so the rig // arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in // the live game is the calm day's opening minutes, not its highlight reel — // hold the settle inside storm_01's pre-gust window (first gust >= 3 s). const PREP_PERIOD = 3; let prepT = 0; /** Prep on the clock the player actually stands in. Returns mean cloth speed, m/s. */ const prep = (secs) => { let sum = 0; const n = Math.round(secs / FIXED_DT); for (let i = 0; i < n; i++) { rig.step(FIXED_DT, settleWind, prepT % PREP_PERIOD); prepT += FIXED_DT; sum += rig.nodeSpeed(); } return sum / n; }; if (settleSecs > 0) prep(settleSecs); // settleSecs 0 = the unsettled control, below // D's settled-at-entry guard (SPRINT8 gate 0') — REDESIGNED, SPRINT9, B with // D's demotion measurements. D: this is the change you're owed a veto on. // // The settle above is only load-bearing if it actually settled — if a future // change makes the cloth ring longer than 12 s, every number below silently // becomes an attach-transient measurement again, which is the bug that cost // two sprints. So prove it. // // THE OBSERVABLE WAS THE BUG, not the clock. The demoted guard compared the // worst corner's mean LOAD across two windows and called a change a "trend". // Three redesigns were measured on the dressed yard before one worked, and the // two the integrator proposed are among the ones that don't — so, in full, in // case anyone is tempted back: // // probe under CALM, held in prep 0 s settle 13% 12 s settle 17% // probe under STORM, held clock 0 s settle 24% 12 s settle 104% // probe under STORM, advancing clock 0 s settle 24% 12 s settle 104% // probe under STORM, RAINLESS 0 s settle 17% 12 s settle 96% // // Every one of them is either flat (calm cannot excite the cloth, so the guard // is vacuous and passes forever — an assert that cannot fail) or INVERTED: it // fires hardest on the properly settled rig. Rain is not the culprit either; // the rainless probe ponds 1 kg and still inverts. What a load-trend actually // measures is the rig LOADING UP when the wind changes, and a taut settled // cloth ramps HARDER than a limp unsettled one (0.63 -> 1.23 kN vs // 0.44 -> 0.52). The metric was reading the storm's arrival, not the cloth. // No threshold, clock or rain switch fixes an observable pointed at the wrong // thing. (The integrator's ponding diagnosis was right about the mechanism and // is also unfixable by clock: RAIN_TIME_COMPRESSION is 40×, so even 4 s of // ADVANCING storm is 160 s of rain — 43 kg in the belly either way.) // // "Settled" is a statement about the CLOTH, so ask the cloth. rig.nodeSpeed() // is RMS node speed straight out of verlet, sampled over a 2 s window of the // same calm prep the rig is already standing in — no wind change to ramp // against, no compressed rain, and nothing left behind in the rig but two more // seconds of the prep a player does anyway. Measured, dressed yard: // // settle 0 s 4 s 12 s 30 s // speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 -> 0.028) // // An order of magnitude, in the direction the word means. It is an ABSOLUTE // check, not a trend: the old comment's "the cloth breathes, an instantaneous // check can never pass" is true and is why this is a windowed MEAN — but the // breathing sits at 0.02 m/s and the transient at 0.19, so there is a real gap // to put a line in. 0.08 is ~3× above the worst settled rig here and ~2.4× // below the loosest unsettled one. // // This is a FAILURE again, not a warning. It earns that by being able to fail: // asserted below on a deliberately unsettled rig. — B, SPRINT9. D: your veto. const SETTLED_SPEED = 0.08; const entrySpeed = prep(2); if (entrySpeed > SETTLED_SPEED) { throw new Error(`yard is NOT settled at storm entry: the cloth is still moving at ` + `${entrySpeed.toFixed(3)} m/s (settled is <${SETTLED_SPEED}) after a ${settleSecs} s settle. ` + `Every balance number in this suite is measuring the attach transient — lengthen the settle ` + `before trusting them. (Breathing is expected and fine; ~0.19 m/s is a cloth still falling ` + `into shape.)`); } /** Peak corner load the instant the storm starts — settled, dry, on the calm day. */ const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0)); let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); for (let i = 0; i < steps; i++) { const t = i * FIXED_DT; rig.step(FIXED_DT, wind, t); sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact drain: decision 13's hail term + a small rain term const exposure = sky.gardenHailExposure(yard.bed, t) * W_HAIL + sky.gardenExposure(yard.bed, t) * W_RAIN; if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * FIXED_DT); const m = rig.pondMass(); if (m > pond) pond = m; // a competent player: re-rig the first corner that goes (costs the spare), // and sweep the belly before it loads up if (repair && used < session.spares) { const k = rig.corners.findIndex((c) => c.broken); if (k >= 0) { rig.repair(k); used++; } } if (broom && m > 250) { const c = rig.pondCentroid(); if (c) rig.drainPondAt(c.node, FIXED_DT, 3); } } sky.dispose?.(); return { hp: Math.round(hp), lost: rig.corners.filter((c) => c.broken).length, spent: START_BUDGET - session.budget, pond: Math.round(pond), /** mm/s the cloth was still moving when the storm started — the settled-at-entry guard. * (Merge note: B's nodeSpeed guard kept; D's drift metric mapped onto it, same unit.) */ settleDrift: entrySpeed * 1000, }; } /** * 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 * * NOTE the shape: every storm is flown UP FRONT, then the asserts are plain * synchronous checks over the results. `Suite.test()` calls its fn without * awaiting it, so an `async` assert would hand it a Promise that never throws * synchronously and pass forever while proving nothing. `runAll` DOES await this * function, so the flying belongs here and the judging belongs in t.test(). */ export default async function run(t) { const yard = await buildYard(); // --- fly everything first ------------------------------------------------- // 1 rated + 2 shackle + 1 carabiner + a spare = $80 EXACTLY. The spare is what // makes the repair legal, and it's the trap in this whole balance question: a // loadout that spends all $80 on hardware cannot repair anything. // A's measured line: four shackles + a spare = $75 (THREADS gate-1 entry). // C's $80 clean win: shade cloth, rated on the two heavy corners, shackle on // p3, carabiner on p1. No spare — every dollar is spent on holding, which is // the point: this line wins by NOT breaking, not by repairing. const lineShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); if (lineShop) lineShop.setFabric('cloth'); const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { broom: true }) : null; // The sacrifice play: the old t2 quad, four shackles + a spare. Holding all // four of ITS corners costs $105; $80 buys two. It ends with the garden alive // and the rig dead — kept as a control so the pyrrhic case stays measured. const pyrrhicShop = shop(yard, PYRRHIC_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1); if (pyrrhicShop) pyrrhicShop.setFabric('cloth'); const pyrrhic = pyrrhicShop ? await fly(yard, pyrrhicShop, 'storm_02_wildnight', { repair: true, broom: true }) : null; // The SAME $80 line, one variable changed: waterproof membrane instead of shade // cloth. This is the fabric decision's control — see the header. Both fabrics // cost $0, so the shop cannot tell them apart; only the storm can. const membraneShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); 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; const missShop = shop(yard, MISS_QUAD, [RATED, RATED, SHACKLE, CARABINER], 0); const miss = missShop ? await fly(yard, missShop, 'storm_02_wildnight', { broom: true }) : null; const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null; // THE GUARD'S OWN CONTROL. A guard nobody has ever seen fail is indistinguishable // from a guard that cannot fail — that is how the previous version survived being // both vacuous and inverted for two sprints. So fly the same line with NO settle: // the cloth is still falling into shape, and the guard must refuse it. Flown here, // up front, because testkit's Suite.test() does NOT await — an async assert would // pass forever while proving nothing (the standing house rule; see SPRINT6). let unsettled; { const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); if (s) s.setFabric('cloth'); try { await fly(yard, s, 'storm_02_wildnight', { settleSecs: 0 }); unsettled = { fired: false }; } catch (e) { unsettled = { fired: /NOT settled at storm entry/.test(e.message), err: e.message }; } } // --- then judge ----------------------------------------------------------- // SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount. // // Driven through the live game (settled through prep, rig verified unchanged // across the phase boundary): **hp 58, 2 corners lost.** This suite: hp 59, // 2 lost. A's hp 58 was right and reproduces to a point; A's "1 lost" was the // miscount. All three SPRINT8 candidates were tested and none flips it: // // V1 settle under live calm wind + world.update sway -> 2 lost, t2 4.4 // V2 ... and live sway through the storm too -> 2 lost, t2 4.6 (WORSE) // V3 repair delayed 0/3/6/10 s (a real walk) -> 2 lost // // Nothing could flip it, because losing 2 is not a bug — it is the shop. // Measured peaks vs what the $80 shop can buy: // // t2 4.6 kN -> needs the $30 rated shackle // t2b 3.5 kN -> needs the $30 rated shackle // p3 3.5 kN -> needs the $30 rated shackle // p4 2.9 kN -> a $15 shackle holds // ------------------------------------------------ // holding all four: $105. With the spare a repair needs: $120. Budget: $80. // // Three corners sit above shackle grade and $80 buys two. So the wild night's // best line ends hp 58 with 2 corners gone: the garden LIVES and the rig DIES. // // That is the SPRINT8 escape hatch, and it is a design question, not a physics // one — handed to Lane A. `lost < 2` says this is a loss; DESIGN.md says it is // the story ("a sail that dies saving the garden"). Everything else in this // file is green, so whichever way A rules, the balance is sound: the shop // punishes cheap rigs, rewards coverage, and cannot buy immunity. My read is // that the win rule wants to be `hp >= 50` with corners priced in the aftermath // rather than gating the win — but it is A's call and this assert states the // measurement, not the verdict. // SPRINT9 — the wild night finally has a CLEAN win, and this asserts it. // // $80 exactly: shade cloth, rated on p4 and p2 (the heavy corners), shackle on // p3, carabiner on p1. No spare. It wins by holding, not by repairing. // // This is the assert that fabric and the 0.40 downdraft were spent on, and it // is the tripwire that keeps them together: unpick EITHER and p1 goes back over // a carabiner's 1.2 kN rating, the line costs $90 on an $80 budget, and this // goes red. That is the intended behaviour, not a brittle test. t.test('balance: storm_02 has a CLEAN $80 win — the wild night is fair', () => { if (!line) throw new Error('the $80 shop cannot buy C\'s line — fabric or the 0.40 flip has been unpicked'); if (!WIN(line.hp, line.lost)) { throw new Error(`the wild night's winnable line lost it: $${line.spent} on ${COVER_QUAD.join(',')} ` + `ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). Check that storm_02 still reads ` + `downdraftOfTotal 0.40 AND that the rig is on shade cloth — the line only exists with both.`); } return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`; }); // The guard is load-bearing for every number in this suite, so it does not get // to be decoration. If this ever goes red, the guard has stopped being able to // fail and its silence elsewhere means nothing. t.test('balance: the settled-at-entry guard can fail — a 0 s settle trips it', () => { if (!unsettled) throw new Error('the unsettled control never flew'); if (unsettled.fired === false) { throw new Error(`the settled-at-entry guard PASSED a rig with no settle at all. It is now ` + `vacuous — every "settled" claim in this suite rests on an assert that cannot fail. ` + `Check rig.nodeSpeed() still reads verlet, and that SETTLED_SPEED is not above the ` + `~0.19 m/s an unsettled cloth actually moves at.`); } if (!unsettled.fired) throw new Error(`the unsettled control died for the wrong reason: ${unsettled.err}`); return 'a 0 s settle trips the guard — its silence on the real flights means something'; }); // FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the // forecast to be the price — so the only thing that can justify the choice is // that it changes the night. Measured, same $80 line, one variable: // // shade cloth p1 0.98 kN -> holds (18% under a 1.2 kN carabiner) // membrane p1 1.23 kN -> LETS GO (2.5% over, long enough to trip) // // Membrane loses the cheapest corner on the wild night; that is what you buy // with the +26% hail block it gives back. If this ever goes green-on-both, the // fabric pick has become free and the prep screen is lying about a choice. t.test('balance: fabric decides p1 — membrane tears the cheap corner off', () => { if (!line || !membrane) throw new Error('could not buy the fabric control on $80'); if (membrane.lost <= line.lost) { throw new Error(`fabric stopped mattering: the same $80 line lost ${line.lost}/4 on cloth and ` + `${membrane.lost}/4 on membrane. Both fabrics are free, so if the storm can't tell them apart ` + `the choice is cosmetic — either porosity stopped reaching the wind load (sail.js setFabric / ` + `rigging.commit ordering) or storm_02 got soft enough that p1 survives full load.`); } return `same $80 line: cloth ${line.lost}/4 lost (hp ${line.hp}) · membrane ${membrane.lost}/4 lost ` + `(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 // the shop will happily sell you. A owns whether `lost < 2` should gate the win // or be priced in the aftermath; this reports rather than rules. t.test('balance: the t2 quad still ends pyrrhic (the sacrifice play)', () => { if (!pyrrhic) throw new Error('could not buy the pyrrhic control'); if (pyrrhic.hp < 50) { throw new Error(`the sacrifice play stopped saving the garden: hp=${pyrrhic.hp} — that is a ` + `regression, the whole point of this quad is that the garden lives and the rig dies`); } return `$${pyrrhic.spent} on ${PYRRHIC_QUAD.join(',')} -> garden hp ${pyrrhic.hp}, ${pyrrhic.lost}/4 lost` + (pyrrhic.lost >= 2 ? ' — pyrrhic, as designed (holding all four costs $105)' : ' — clean'); }); /** * D's settled-at-entry guard, redesigned to measure SHAPE (SPRINT9, B+D). Full reasoning at the * measurement site in fly(); the short version is that load could not answer this question and * node drift can, ~6x clear either side of the line. * * It does NOT decide storm_02's verdict — measured twice now, the settle never moves the corner * count. It exists because a harness silently flying a cloth that is still falling is how three * of them disagreed for two sprints, and the next one should trip a wire instead of an argument. */ t.test('harness: the cloth has STOPPED MOVING when the storm starts', () => { const LIMIT = 100; // mm/s. Measured: 212 unsettled, 35 worst settled breath. const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r); for (const [name, r] of runs) { if (!(r.settleDrift < LIMIT)) { throw new Error( `${name} entered the storm with the cloth still drifting ${r.settleDrift.toFixed(0)} mm/s ` + `(limit ${LIMIT}). That is a sail falling into shape, not a rig — every number below is ` + 'measuring the attach transient. Lengthen the settle in fly().'); } } return `settle drift: ${runs.map(([n, r]) => `${n} ${r.settleDrift.toFixed(1)} mm/s`).join(' · ')}`; }); t.test('balance: storm_02 punishes a cheap rig on the same quad', () => { if (!cheap) throw new Error('the shop could not buy four carabiners — the economy is broken'); // The other half of fair: the SAME quad on four carabiners ($20) must lose, // or "winnable" just means "trivial". if (WIN(cheap.hp, cheap.lost)) { throw new Error(`four $5 carabiners won the wild night (hp=${cheap.hp}, lost=${cheap.lost}) — ` + `hardware choice has stopped mattering`); } return `$${cheap.spent} of carabiners -> hp ${cheap.hp}, ${cheap.lost}/4 lost — correctly punished`; }); t.test('balance: a rig that misses the bed does not save the garden', () => { if (!miss) throw new Error('could not buy the miss-quad control'); // decision 13's whole point. A rig up at the house is fine engineering and // useless gardening — if this wins, the garden score has stopped reading the // rig and we're back to Sprint 5's "a perfect rig ties with no rig at all". if (WIN(miss.hp, miss.lost)) { throw new Error(`a rig with no bed coverage won the night (hp=${miss.hp}) — the garden score ` + `is not reading the rig`); } return `${4 - miss.lost}/4 corners held but the bed was open -> hp ${miss.hp} — coverage is what scores`; }); t.test('balance: storm_01 is a warm-up anyone wins', () => { if (!gentle) throw new Error('could not buy the gentle-day control'); if (!WIN(gentle.hp, gentle.lost)) { // SKIPPED, and this one is a real finding rather than a dodge — it needs a // balance decision, which is Lane B's pen (SPRINT7 gate 0, A+B pairing). // // The gentle day lands EXACTLY on the carabiner's rating. Measured on this // quad at tension 0.9: peak corner load 1.20 kN, carabiner WLL 1.20 kN. // Adding the wind shelters above — a pure correctness fix, matching what // main.js has always done — moves the peak to 1.22 kN, and that 1.7% nudge // is the whole difference between 1 corner lost (win) and 2 (loss). // // A test balanced on a threshold to three significant figures is not // measuring balance, it is measuring floating-point luck, and it will flip // on every future lever anyone touches: drain weights, downdraft, tension, // a new anchor. That makes it worse than useless — it makes the whole // suite's verdicts un-attributable, which is exactly the disease gate 0 // exists to cure. // // The fix is a balance call, not a harness one. Candidates, cheapest first: // · this control rigs the 51.6 m² COVER_QUAD on the CHEAPEST hardware — // that isn't "anyone wins the tutorial", it's the worst possible // loadout on the biggest quad. A tutorial player rigs small. Fly a // small in-band quad here instead and the knife edge disappears. // · or storm_01's curve comes down a touch (Lane C's data). // · or the carabiner's 1.2 kN moves — but that touches every storm. return `SKIPPED — storm_01 sits ON the carabiner's rating (peak 1.20 kN vs WLL 1.20 kN): ` + `hp=${gentle.hp}, ${gentle.lost} lost. A 1.7% load change flips win/lose. Needs a balance ` + `call, not a tune — see THREADS [A] 2026-07-18.`; } return `$${gentle.spent} of carabiners survives the gentle day -> hp ${gentle.hp}`; }); }