HardYards/web/world/js/tests/balance.test.js
m3ultra abeefad72d Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
#	web/world/js/tests/balance.test.js
2026-07-17 11:51:44 +10:00

350 lines
19 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;
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);
// 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 (gate 0, cause #2, found by Lane D 2026-07-18). A player plays
// through prep, so ~12 s of world.update pass before ENTER and the cloth +
// tree sway reach steady state. A harness that rigs and storms in the same
// tick lands the storm on the ATTACH TRANSIENT instead — measured at 2.7× the
// settled load on tree corners (t2 4.54 kN unsettled vs 1.71 settled), loud
// enough to swamp tension entirely. Holding t=0 keeps the wind at its mild
// ramp start (first gust ≥3 s) — near-calm, like prep. Per D's spec; the
// "assert the yard IS settled at storm entry" guard is theirs to formalize.
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, 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),
};
}
/**
* @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)) {
// SPRINT7 gate 0 — CLOSED at the merge, three causes named, none a villain:
// 1. GARDEN (A): skyfx built with no camera → `if (!camera) return;`
// skipped the shadow-grid rebuild → every loadout scored as bare bed.
// hp 36 WAS the no-sail constant. Fixed above (camera in fly()).
// 2. CORNERS (D): the settle. A harness that rigs and storms in the
// same tick measures the ATTACH TRANSIENT — trees at rest, cloth
// still falling into shape — worth 2.7× on tree corners (t2 4.54 kN
// unsettled vs 1.71 settled). A plays through prep, so A's rig was
// settled; fly() wasn't. Fixed below (12 s settle, per D's spec).
// 3. TENSION (A, self-reported): the win was measured at 1.0 and
// published without declaring it; the shop default was 0.9.
// B's elimination sweep (shelters/tension/drain/geometry/sway) was right
// about everything it measured — the transient swamped tension, which is
// why four tensions returned identical numbers. See THREADS 2026-07-18.
throw new Error(
`no winnable line even settled: ${COVER_QUAD.join(',')} on 4×shackle+spare ` +
`($${line.spent}) ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). ` +
`If this is red after the settle fix, gate 0 is OPEN again — post the numbers.`);
}
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
});
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}`;
});
}