HardYards/web/world/js/tests/balance.test.js
m3ultra 601adedf45 Gate 0' guard: redesign it to measure SHAPE, and prove it fires
The integrator demoted my settled-at-entry guard to a warning at the Sprint-8
merge and handed B+D the redesign. Investigating it refuted the stated cause and
found a better observable.

The ponding diagnosis does not hold: every storm's rain curve starts at [0,0], so
storm_02.rainMmPerHour(0) is 0.0 mm/h and a held clock rains nothing. The real
confound was that the guard measured in the STORM's wind while the rig settled in
CALM — and that calm->storm step is one the REAL GAME ALSO HAS (wind.use() swaps
on the phase change), so it was never a harness artifact. Their 2 s windows were
also shorter than the cloth's breath: 2 s reads a 32% trend on a settled rig
where 3 s reads 6%.

But fixing the clock isn't enough, because LOAD cannot answer this question at
all. In calm there is no transient to see — the unsettled rig is LIGHTER (0.30 kN
vs 0.47 settled) and the trend test passes at every settle length from 0 to 20 s.
A guard that cannot fail is decoration, which is precisely what StumbleBack and
the fake skips were.

So ask the physical question: has the cloth stopped MOVING. Mean node drift
separates ~6x either side, and cannot be confused by wind or water:
    unsettled (0 s)  -> 212 mm/s  (cover quad) · 208 (miss quad)
    settled (8-20 s) ->  35 mm/s worst breath, typically 4-6
Limit 100 mm/s. Proved it FAILS before trusting it to pass: sabotaged the settle
to 0 s and it fired — "cloth still drifting 212 mm/s (limit 100)".

The integrator's 600 N floor was a sound amendment to the LOAD design; it isn't
carried over because drift has no units of force and 212-vs-35 is not a judgement
call. 274/0/0, all five balance asserts green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:25:08 +10:00

424 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;
// 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 period = 3;
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
}
}
/**
* D's settled-at-entry guard, REDESIGNED (SPRINT9, B+D). It now measures SHAPE, not load.
*
* The guard exists for one reason: a harness that rigs and storms in the same tick measures a
* cloth still falling out of its flat _build() shape, and pretends that is a rig. That bug cost
* two sprints, so the settle above has to prove it worked.
*
* The Sprint-8 version asked the question with LOAD and could not answer it. Measured tonight,
* why every load-based form fails:
* · in CALM (what the rig settles in) there is no transient to see at all — worst-corner mean
* reads 0.30 kN unsettled vs 0.47 kN settled, i.e. the unsettled rig is LIGHTER, and the
* trend test passes at every settle length from 0 s to 20 s. A guard that cannot fail is
* decoration.
* · in the STORM at a held t=0 it read a 105% "trend" — but that is the calm→storm step change,
* which the REAL GAME ALSO HAS (`wind.use(to === 'storm' ? winds[stormKey] : calmWind)` on the
* phase change). It was never a harness artifact; the suite was being faithful and the guard
* was calling it a bug.
* · the integrator's ponding diagnosis at the Sprint-8 merge does not hold either: every storm's
* rain curve starts at [0, 0], so `storm_02.rainMmPerHour(0)` is 0.0 mm/h and a held clock
* rains nothing. (Their 2 s windows were also shorter than the cloth's breath — 2 s reads a
* 32% trend on a settled rig where 3 s reads 6%.)
*
* So ask the physical question instead: HAS THE CLOTH STOPPED MOVING. Mean node drift separates
* cleanly, ~6x either side of the line, and unlike load it cannot be confused by wind or water:
* unsettled (0 s) → 212 mm/s (cover quad) · 208 mm/s (miss quad)
* settled (8-20 s) → 35 mm/s worst case, typically 4-6
* The integrator's 600 N floor was a sound amendment to the LOAD design and is not carried over —
* drift has no units of force to need a floor, and it needs no threshold on "does it matter"
* because 212-vs-35 is not a judgement call.
*/
const settleDriftMmPerSec = (() => {
const before = Float64Array.from(rig.pos);
const secs = 2;
for (let i = 0, n = Math.round(secs / FIXED_DT); i < n; i++) {
rig.step(FIXED_DT, calmWind, ((12 + i * FIXED_DT) % Math.max(1, calmDef.duration)));
}
let sum = 0;
for (let i = 0; i < before.length; i += 3) {
sum += Math.hypot(rig.pos[i] - before[i], rig.pos[i + 1] - before[i + 1], rig.pos[i + 2] - before[i + 2]);
}
return (sum / (before.length / 3)) * 1000 / secs;
})();
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 drifting when the storm started — the settled-at-entry guard. */
settleDrift: settleDriftMmPerSec,
};
}
/**
* @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 -----------------------------------------------------------
// 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.
t.test("balance: storm_02's best line saves the garden (pyrrhic — see SPRINT8 gate 0')", () => {
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
// The garden half — the part the player is actually protecting — must hold.
if (line.hp < 50) {
throw new Error(`the wild night's best $${line.spent} line let the garden die: hp=${line.hp} ` +
`(need >=50). This is a real balance regression, not the pyrrhic-win question.`);
}
const pyrrhic = line.lost >= 2;
return `$${line.spent} on ${COVER_QUAD.join(',')} -> garden hp ${line.hp}` +
(pyrrhic
? `, but ${line.lost}/4 corners gone — PYRRHIC. Holding all four costs $105 (+$15 spare) on an $80 budget; A owns the win rule.`
: `, ${line.lost}/4 lost — a clean win.`);
});
/**
* 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}`;
});
}