Ran every remaining suite-vs-game difference one at a time against the real storm. None of them moves the corner count: live calm settle (running clock + calmWind) entry 1.94 -> 0.40 kN, 2 lost live tree sway t2 4.33 -> 4.55 kN, 2 lost walked repair timing (~4 s) 2 lost all three together hp 58, 2 lost Then checked the structural candidates: anchors are bit-identical between harnesses (all four, 0.0000 m), ring order is identical (p4,t2,t2b,p3), and wind.sample() at t2 matches to 2 dp (36.49 m/s @ t=75.9) across suite, suite with shelters, and the live router. So I re-ran the live game itself, fresh page, A's exact line, tension 1.0: live -> hp 58, 2 lost [t2,t2b], t2 peak 4.55 kN suite -> hp 58, 2 lost [t2,t2b], t2 peak 4.54 kN They agree. The suite was right. RETRACTED: my Sprint-7 "12 s settle flips 2 lost -> 1". It does not reproduce — 0 s and 12 s both end 2 lost, because the storm's own peak (4.54 kN at t=75.9) dwarfs the attach transient. The settle is a real fidelity fix and NOT the corner-count cause. B's arithmetic was right from their first post: t2 pulls ~4.5 kN, a shackle is rated 3.2, and the spare cannot save it (measured: t2b blew @9 s, repaired, blew again). Landed anyway, because it is still true and still worth guarding: fly() now settles the way main.js drives prep (calmWind on a running clock, not the storm frozen at t=0), and a new "the yard is SETTLED when the storm starts" assert holds every run under 1.0 kN at entry. 266 pass / 1 red — the red is the pyrrhic-win design call, not a bug, and it now says so. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
408 lines
23 KiB
JavaScript
408 lines
23 KiB
JavaScript
/**
|
||
* 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.
|
||
const COVER_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 } = {}) {
|
||
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;
|
||
const period = Math.max(1, (calmDef && calmDef.duration) || 1);
|
||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
|
||
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
|
||
}
|
||
}
|
||
/** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */
|
||
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),
|
||
/** kN on the worst corner at storm entry — the settled-at-entry guard's number. */
|
||
entryPeak: entryPeak / 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).
|
||
const lineShop = shop(yard, COVER_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1);
|
||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { repair: true, 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;
|
||
|
||
// --- then judge -----------------------------------------------------------
|
||
|
||
// SPRINT7 gate 0 — the skip is gone, and the dispute is settled by physics.
|
||
//
|
||
// Lane A measured this exact line (t2,p3,p4,t2b, 4×shackle + spare, $75) at
|
||
// hp 58 / 1 lost. This suite says hp 36 / 2 lost. I went through every way the
|
||
// two harnesses could differ and ruled each out by measurement:
|
||
//
|
||
// tree wind-shelter (main.js:369 calls setSheltersFromTrees, this didn't)
|
||
// ... on vs off: hp 36 either way
|
||
// tension (this hardcoded 0.9, the game defaults 1.0)
|
||
// ... 0.6 / 0.75 / 0.9 / 1.0: hp 36, 2 lost, all four
|
||
// drain wiring (garden.step's signature changed to take hail+rain separately)
|
||
// ... same arithmetic: 5.0/0.25 × 0.9. Identical.
|
||
// yard geometry (the bug that bit this file once already)
|
||
// ... diffed all 12 anchors against the live game: zero drift
|
||
// frozen sway (tree anchors are shock absorbers — DESIGN.md)
|
||
// ... live sway is WORSE, not better: t2 4.4 → 4.6 kN
|
||
//
|
||
// What's left is not a harness difference, it's arithmetic. On this quad t2
|
||
// peaks at 4.4-4.9 kN and a shackle is rated 3.2. t2 cannot hold, under any
|
||
// variation above. Losing it spends the only spare; then p3 (3.7) or t2b (3.4)
|
||
// — both also over 3.2 — goes too. Two corners down, the sail stops shadowing
|
||
// the bed, and hp lands on 36, which is precisely the bare-bed score. That is
|
||
// why every configuration returns the same number: 36 is not a coincidence,
|
||
// it's "the rig contributed nothing".
|
||
//
|
||
// And the quad is not rescuable by shopping: putting the RATED shackle on t2
|
||
// ($80 with a spare) still ends hp 36 / 2 lost, because p3 and t2b then break
|
||
// instead. This quad needs THREE corners above shackle grade and $80 buys two.
|
||
//
|
||
// So: A's win does not reproduce, and I believe A's number is the artifact —
|
||
// most likely the same phase-boundary contamination that produced my own
|
||
// hp=99 last sprint (driving SHADES.rigSail and then advancing the phase
|
||
// machine re-rigs from the game's own RiggingSession, so you score a rig you
|
||
// didn't choose). A: the check is one line — assert rig.corners' anchorIds
|
||
// and hw names immediately before your storm loop and confirm they're what you
|
||
// bought. If they are, I'm wrong and the cause is still open.
|
||
t.test('balance: storm_02 HAS a winnable line through the real $80 shop', () => {
|
||
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
|
||
// SPRINT7 gate 0, settled 2026-07-18: the integrator's skip is deleted
|
||
// because the dispute had a cause, not a winner. This suite built skyfx
|
||
// WITHOUT a camera, and skyfx.step() opens `if (!camera) return;` — so the
|
||
// hail shadow grid was never rebuilt and every loadout was scored as though
|
||
// it had no sail. That is why this line read hp 36: 36 IS the bare-bed
|
||
// number. With a camera it reads what Lane A measured. Neither harness was
|
||
// lying; one of them was flying a yard with no cloth in it.
|
||
if (!WIN(line.hp, line.lost)) {
|
||
// SPRINT8 gate 0' — CLOSED, and this suite was RIGHT. The live game and this harness now
|
||
// agree, measured by Lane D 2026-07-18 on current main (fresh page, A's exact line,
|
||
// t2+p3+p4+t2b on 4×shackle+spare, tension 1.0):
|
||
// live game → hp 58, 2 lost [t2,t2b], t2 peak 4.55 kN
|
||
// this suite → hp 58, 2 lost [t2,t2b], t2 peak 4.54 kN
|
||
// Every suite-vs-game difference gate 0' listed has now been tested one at a time and NONE
|
||
// of them moves the corner count: live-calm settle (entry 1.94→0.40 kN, verdict unchanged),
|
||
// live tree sway (t2 4.33→4.55, verdict unchanged), walked repair timing (unchanged).
|
||
// Anchors are bit-identical between harnesses, ring order is identical (p4,t2,t2b,p3), and
|
||
// wind.sample() at t2 is identical to 2 dp (36.49 m/s @ t=75.9).
|
||
//
|
||
// RETRACTED, on the record: Lane D's "the settle flips 2 lost → 1" (SPRINT7). It does not
|
||
// reproduce — 0 s and 12 s settle both end 2 lost, because the storm's own peak (4.54 kN at
|
||
// t=75.9) dwarfs the attach transient. The settle is a real fidelity fix and NOT the
|
||
// corner-count cause. A's hp 58 reproduces exactly; A's "1 lost" reproduces in neither
|
||
// harness.
|
||
//
|
||
// What's left is B's arithmetic, which was correct from the first post: t2 pulls ~4.5 kN and
|
||
// a shackle is rated 3.2 kN. The corner cannot hold at any tension, and the spare cannot save
|
||
// it — the repaired corner simply blows again (measured: t2b blew @9 s, repaired, blew again).
|
||
// So this is no longer an engineering question. It is SPRINT8's designed escape hatch:
|
||
// hp 58 CLEARS the 50 bar while 2 corners are lost, i.e. the player saves the garden and
|
||
// loses the sail — DESIGN.md's own story — and the win rule (`hp>=50 && lost<2`) calls it a
|
||
// loss. Either the rule admits a PYRRHIC WIN, or the shop must be able to buy a third
|
||
// above-shackle corner ($80 buys two). That is a design call, not a harness bug. See THREADS.
|
||
throw new Error(
|
||
`no winnable line: ${COVER_QUAD.join(',')} on 4×shackle+spare ($${line.spent}) ended ` +
|
||
`hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). CONFIRMED against the live game ` +
|
||
`(hp 58, 2 lost) — the harnesses agree, so this is the pyrrhic-win design call, not a bug.`);
|
||
}
|
||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
|
||
});
|
||
|
||
/**
|
||
* D's settled-at-entry guard (SPRINT8 §Lane D). The suite must enter the storm with the yard in
|
||
* the state a player hands it: rigged, breathing on the calm day, corners loaded by nothing worse
|
||
* than their own tension. It does not decide storm_02's verdict — measured, it doesn't — but a
|
||
* harness silently measuring the attach transient is how three of them disagreed for two sprints,
|
||
* and the transient IS worth 2.7× on tree corners in the moment it exists.
|
||
*/
|
||
t.test('harness: the yard is SETTLED when the storm starts', () => {
|
||
const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r);
|
||
for (const [name, r] of runs) {
|
||
if (!(r.entryPeak < 1.0)) {
|
||
throw new Error(
|
||
`${name} entered the storm carrying ${r.entryPeak.toFixed(2)} kN — that is an attach ` +
|
||
'transient, not a rig. Settle on the calm day with a running clock, as main.js does.');
|
||
}
|
||
}
|
||
return `entry loads: ${runs.map(([n, r]) => `${n} ${r.entryPeak.toFixed(2)} kN`).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}`;
|
||
});
|
||
}
|