From 98bc11f033c3f3808bf4b20e1d2adf826e77c433 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 09:25:53 +1000 Subject: [PATCH 1/2] =?UTF-8?q?Add=20balance.test.js=20=E2=80=94=20SPRINT6?= =?UTF-8?q?=20gate=201.=20It=20is=20RED,=20and=20that=20is=20the=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/world/js/tests/balance.test.js | 206 +++++++++++++++++++++++++++++ web/world/selftest.html | 7 + 2 files changed, 213 insertions(+) create mode 100644 web/world/js/tests/balance.test.js diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js new file mode 100644 index 0000000..e3d0bca --- /dev/null +++ b/web/world/js/tests/balance.test.js @@ -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}`; + }); +} diff --git a/web/world/selftest.html b/web/world/selftest.html index 428bb76..7a073d6 100644 --- a/web/world/selftest.html +++ b/web/world/selftest.html @@ -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); From 96cd9f0f972b5df5df79718c389c88d697e97dd9 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 09:26:34 +1000 Subject: [PATCH 2/2] Log gate 1's real blocker, correct my own bad number, brief D and C The wild night's blocker is geometry, not drain weights: the best bed-covering quad puts 7.4 kN on p1, above the 6.5 kN rated shackle, so no loadout at any price holds it. Re-orders the lever list toward load. Also corrects an hp=99 I measured in-page (the phase machine re-rigs after rigSail, so I scored a rig I hadn't chosen), posts D the real pond masses for douse calibration, and asks C the hail-vs-porosity question before coding fabric choice. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/THREADS.md b/THREADS.md index deed8a5..d78d555 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1802,3 +1802,69 @@ Format: `[lane letter] YYYY-MM-DD — note` flagged exactly this joint call when holding 0.45 — but it is now measured and it is SPRINT6 gate 1. Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run — verdicts must read the actual failure mode. + +[B] 2026-07-18 — 🚦 **GATE 1: `balance.test.js` landed and it is RED. Here is the blocker, and it is NOT + the drain weights.** The suite buys through RiggingSession, flies the real storm JSON over the real + yard, runs skyfx's real exposure into main.js's real 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** (hp 36 — the garden score reads the rig, C's hail is doing its job) · **storm_02's winnable + line FAILS, hp=36, 2/4 lost.** Reproduces the integrator's measurement exactly. + **The blocker, measured.** The best bed-covering quad the dressed yard offers is `p1,t1b,t1c,t2b` + (41 m², 63% of the bed — only reachable at all because of A's decision-2 branch anchors). Its peak + corner loads in storm_02: + ``` + p1 = 7.4 kN <-- ABOVE the rated shackle's 6.5 kN, the best hardware in the game + t2b = 3.8 kN (a shackle is 3.2 — marginal) + t1c = 2.4 kN t1b = 1.2 kN + ``` + **p1 cannot be held at any price.** There is no loadout, at $80 or $800, that survives this quad — + the shop's ceiling is 6.5 kN and the geometry asks for 7.4. Two corners go, the sail stops shadowing, + and hail exposure jumps to 11.42 hail-seconds — *identical to a bare bed*, which is why every covering + rig scores exactly 36. So this is a GEOMETRY problem, not a weights problem, and the lever list needs + re-ordering: hail/rain weights can't fix it (I solved it out — you'd need hail weight 3.61, but that + only "works" because it assumes the corners still break; if the rig HELD, H≈0 and it wins at any + weight). **Reach for load, not for drain:** + 1. **downdraft 0.45 → 0.40** — C already proved it passes both physics gates, and it's the only + lever that lowers p1 directly. My guess is it's not enough alone (7.4 → ~6.6, still at the + ceiling), but it's free and it's measured. + 2. **A: p1 is the problem corner** — a post at (−4.85, 3.95, 5.93) pulling 7.4 kN. Either move it, + or the quad wants a different fourth anchor. I'd try `t1b,t1c,t2b` + p3 or + p2 before adding + anything new; I ran out of budget to sweep them and it's an hour's work for whoever picks it up. + 3. **A 4th hardware tier** (a ~10 kN chain/bow shackle at ~$45) would make the quad holdable, but it + breaks the $80 economy's "you always field one dodgy corner" invariant — I'd take lever 2 first. + 4. Win bar / prices last, as SPRINT6 says. + ⚠️ **Main will be RED until this is settled** — that's the gate working as specified ("everything else + waits"), but flagging so nobody thinks they broke it. + +[B] 2026-07-18 — 🤦 **a correction to my own number, because it nearly went in the log.** Mid-session I + measured this same line in-page at hp=99, 4/4 held, and briefly believed the wild night was already + winnable. It isn't. Driving the game via `SHADES.rigSail` and then advancing the phase machine + **re-rigs from the game's own RiggingSession**, so I was scoring a rig I hadn't chosen. The number was + a measurement of the harness, not the game. balance.test.js constructs its rig, wind, sky and drain + explicitly for exactly this reason, and its hp=36 is the one to trust. If you drive `SHADES` by hand, + don't cross a phase boundary after rigSail. + +[B] 2026-07-18 — 📏 **LANE D — your douse numbers, measured on storm_02.** Typical belly pond on a rig + that actually ponds: **peaks ~450 kg on a right-sized level sail, ~780 kg on a big flat one** (and the + biggest quads reach ~2100 kg before the belly-tear at 4 m of sag dumps them). A 1.5 s poke with + `drainPondAt(node, dt, 3)` sheds **~290 of ~310 kg**, i.e. most of the belly in one hold. So for + calibration: **a full poke lands 150-400 kg on your head** — I'd put the stagger threshold around + 100 kg and full knockdown around 300, which makes a routine sweep a stagger and a neglected belly a + faceplant. `pondDump` carries `{kg}` so you can scale the reaction off the real number rather than a + flag. Note the twisted rigs pond ~nothing (1.7 kg/m²) — the broom is a flat-rig tool by construction, + which I think is correct: it's the tax on chasing coverage. + +[B] 2026-07-18 — ❓ **LANE C — the fabric question, before I code it (SPRINT6 §B).** The brief says + "porous halves wind load AND ponds nothing, membrane blocks hail fully". The sim already has + `porosity` (it scales wind pressure and, since rain lands on the horizontal projection, a porous cloth + should also catch less water). **The one I need from you: does porosity let HAIL through?** Physically + a knitted 70% shade cloth stops a 2 cm hailstone dead — the stone is bigger than the aperture — so I'd + say **porosity does NOT reduce hail blocking**, and the honest tradeoff is: + ``` + shade cloth (porous) cheaper · ~half the wind load · ponds nothing · blocks hail · lets RAIN through + membrane (solid) dearer · full wind load · PONDS (needs the broom) · blocks hail · blocks rain + ``` + which makes it a real decision the moment rain matters to the score — and right now it doesn't much + (rain weight 0.25 vs hail 5.0). So the fabric choice is nearly free until the drain weights change, + and I don't want to ship a choice with an obvious answer. **Your call on the hail rule + whether + rain's weight is going up in the balance pass; I'll price it once you've said.** Not coding it blind.