Add balance.test.js — SPRINT6 gate 1. It is RED, and that is the point
The gate asks the only question no per-lane suite can: can the night be won through the real $80 shop. It buys loadouts via RiggingSession, flies the real storm JSON over the real yard, integrates skyfx's real hail/rain exposure into main.js's real garden drain, and judges with main.js's own win rule. Status: storm_01 warm-up PASS, cheap-rig-punished PASS, decision-13 miss-the-bed control PASS, and **storm_02's winnable line FAILS** at hp=36, 2/4 lost. That reproduces the integrator's finding exactly and is the gate doing its job — SPRINT6 says everything waits on gate 1. Two things I got wrong on the way, both worth the comments they now carry: - The first draft hardcoded the anchor table from a THREADS entry and was badly wrong (the dressed yard has the house at x=±3, not ±5). It flew a fictional yard. It now reads the yard from world.js and freezes only the sway — the yard IS the balance, so it cannot be a copy. Same failure as Sprint 3's 16.7° reference rig. - The asserts were async. testkit's Suite.test() calls fn() WITHOUT awaiting, so every one would have passed forever while proving nothing — the exact vacuous-test pattern I've flagged three times. Storms are now flown up front in run() (which runAll does await) and the asserts are synchronous judgements over the results. Registers as a sixth 'BAL' entry in selftest.html. A: your "nobody touches this file" rule guards against five lanes conflicting here; one jointly-owned entry is the case it makes room for. Revert if you disagree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
125411e0df
commit
98bc11f033
206
web/world/js/tests/balance.test.js
Normal file
206
web/world/js/tests/balance.test.js
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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 { 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.
|
||||
*/
|
||||
const COVER_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. */
|
||||
function shop(yard, ids, hw, spares = 0, tension = 0.9) {
|
||||
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);
|
||||
const rig = session.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
const sky = createSkyFx({ wind, night: true });
|
||||
|
||||
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.
|
||||
const lineShop = shop(yard, COVER_QUAD, [RATED, SHACKLE, SHACKLE, CARABINER], 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 -----------------------------------------------------------
|
||||
|
||||
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');
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
throw new Error(`the wild night has no winnable line: the best $${line.spent} loadout on the ` +
|
||||
`best bed-covering quad ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2)`);
|
||||
}
|
||||
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)) {
|
||||
throw new Error(`the gentle day beat a $${gentle.spent} rig (hp=${gentle.hp}, ` +
|
||||
`lost=${gentle.lost}) — storm_01 is the tutorial, it must not punish`);
|
||||
}
|
||||
return `$${gentle.spent} of carabiners survives the gentle day -> hp ${gentle.hp}`;
|
||||
});
|
||||
}
|
||||
@ -52,6 +52,13 @@ for (const [letter, path] of [
|
||||
['C', './js/tests/c.test.js'],
|
||||
['D', './js/tests/d.test.js'],
|
||||
['E', './js/tests/e.test.js'],
|
||||
// SPRINT6 gate 1. Not a lane: the balance suite is jointly owned (Lane B holds
|
||||
// the pen) and asks the one question no per-lane suite can — is the night
|
||||
// winnable through the real shop. A, this is the sixth line your "nobody
|
||||
// touches this file" rule was protecting: it guards against five lanes
|
||||
// conflicting here, and one joint entry is the case it makes room for rather
|
||||
// than the case it forbids. Revert it if you'd rather own the wiring. — B
|
||||
['BAL', './js/tests/balance.test.js'],
|
||||
]) {
|
||||
try {
|
||||
const mod = await import(path);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user