diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 6a8517c..aafd18b 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -159,6 +159,36 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = // "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); + // D's settled-at-entry guard (SPRINT8 gate 0'). + // + // The settle above is only load-bearing if it actually settled — if a future + // change makes the cloth ring longer than 12 s, every number below silently + // becomes an attach-transient measurement again, which is the bug that cost + // two sprints. So prove it. + // + // It has to be a TREND test, not an instant one. Measured: with the wind held + // constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN, + // because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind + // drag is meant to do the damping). There is no single settled value; the + // cloth breathes. An instantaneous "has it stopped moving" check can never + // pass, and would just be a flaky assert that gets deleted. Same lesson as the + // statics assert in sail.selftest: compare time-AVERAGED windows. + const meanLoad = (secs) => { + let sum = 0, n = Math.round(secs / FIXED_DT); + for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); } + return sum / n; + }; + const w1 = meanLoad(2); + const w2 = meanLoad(2); + const trend = Math.abs(w2 - w1) / Math.max(1, w1); + if (trend > 0.35) { + throw new Error(`yard is NOT settled at storm entry: worst-corner mean is still trending ` + + `${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` + + `(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` + + `Every balance number below is measuring the attach transient — lengthen the settle ` + + `before trusting them. (Oscillation is expected and fine; a TREND is not.)`); + } + let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); for (let i = 0; i < steps; i++) { @@ -223,72 +253,50 @@ export default async function run(t) { // --- then judge ----------------------------------------------------------- - // SPRINT7 gate 0 — the skip is gone, and the dispute is settled by physics. + // SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount. // - // 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: + // 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: // - // 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 + // 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 // - // 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". + // Nothing could flip it, because losing 2 is not a bug — it is the shop. + // Measured peaks vs what the $80 shop can buy: // - // 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. + // 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. // - // 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', () => { + // 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'); - // 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.`); + // 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.`); } - return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`; + 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.`); }); t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {