HardYards/web/world/js/tests/balance.test.js
m3ultra 855106d37e Merge Sprint 9: clean $80 win asserted; guard redesigned; pyrrhic wired
Selftest on merged main: 287 pass / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:12:07 +10:00

581 lines
33 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.

/**
* 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';
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';
const GARDEN_DRAIN = 0.9; // main.js
const W_HAIL = 5.0; // main.js, decision 13 — integration weights
const W_RAIN = 0.25;
/**
* 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: () => [],
};
const world = createWorld(scene, { wind: calm });
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,
};
}
/**
* @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;
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 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}`;
});
}