diff --git a/web/world/js/testkit.js b/web/world/js/testkit.js index 76b33bd..21c5724 100644 --- a/web/world/js/testkit.js +++ b/web/world/js/testkit.js @@ -60,10 +60,42 @@ export class Suite { this.results = []; } + /** + * A test passes by returning, fails by throwing, and SKIPS by returning a + * string that starts with 'SKIPPED'. + * + * That last clause is not sugar — it is a repair. This method used to ignore + * its return value entirely, so a test that did + * if (!ok) return `SKIPPED — harness dispute: ...`; + * was recorded as a PASS. Two suites had already been written that way (the + * integrator's storm_02 skip at the Sprint-6 merge, and a storm_01 one), which + * means main has been reporting green on asserts that were neither passing nor + * skipping — they returned a message and the suite called it a win. `skip: 0` + * in every report we ever printed was the tell, and nobody read it, including + * me: I misread one of those fake passes as my own win reproducing. + * + * It's the same disease as the wind router swallowing rainMmPerHour — a + * mechanism that looks wired and silently isn't — and it lived in the harness + * every lane trusts. A skip must be visible or it is a lie with good manners. + */ test(label, fn) { const t0 = performance.now(); try { - fn(); + const out = fn(); + if (typeof out === 'string' && out.startsWith('SKIPPED')) { + this.results.push({ + lane: this.lane, label, status: 'skip', + err: out.replace(/^SKIPPED\s*[—-]?\s*/, ''), ms: performance.now() - t0, + }); + return; + } + if (out && typeof out.then === 'function') { + // An async fn hands back a Promise that cannot throw synchronously, so + // this would pass forever while proving nothing. Lane B called this out + // in balance.test.js's header; make it impossible rather than a warning. + throw new Error(`test "${label}" is async — Suite.test() cannot await it. ` + + `Do the awaiting in run() and assert synchronously.`); + } this.results.push({ lane: this.lane, label, status: 'pass', ms: performance.now() - t0 }); } catch (err) { this.results.push({ diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index fba1915..dd281a5 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -52,8 +52,15 @@ export default async function run(t) { }); // --- the wind router ----------------------------------------------------- + // Loaded HERE, not inside t.test(). These two were written `async` and + // Suite.test() cannot await — so they were recorded as passes while asserting + // nothing at all, for two sprints, including the sprint where I claimed the + // tripwire had "caught its first real omission". It had: in a hand-run probe, + // not in the suite. runAll DOES await run(), so awaiting belongs out here. + const realWild = createWind(await loadStorm('storm_02_wildnight')); + const realGentle = createWind(await loadStorm('storm_01_gentle')); - t.test('wind router forwards EVERYTHING the real wind exposes', async () => { + t.test('wind router forwards EVERYTHING the real wind exposes', () => { // This exists because the omission it catches has already shipped once. // // Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a @@ -68,7 +75,7 @@ export default async function run(t) { // garden all over again, which is the exact thing this sprint exists to fix. // Diffing the two objects means the next omission is a red test that names // the missing member, rather than a system that quietly isn't plugged in. - const real = createWind(await loadStorm('storm_02_wildnight')); + const real = realWild; const router = createWindRouter([real]); const missing = Object.keys(real).filter((k) => !(k in router)); @@ -81,11 +88,11 @@ export default async function run(t) { assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`); }); - t.test('wind router delegates live — use() re-points every consumer at once', async () => { + t.test('wind router delegates live — use() re-points every consumer at once', () => { // The router's whole reason to exist: consumers bind once at construction, // so a storm swap has to be a re-point rather than a re-wire. - const gentle = createWind(await loadStorm('storm_01_gentle')); - const wild = createWind(await loadStorm('storm_02_wildnight')); + const gentle = realGentle; + const wild = realWild; const router = createWindRouter([gentle, wild]); const at = new THREE.Vector3(0, 0, 0); diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index afc8b31..c590e37 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -22,6 +22,7 @@ * 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'; @@ -105,7 +106,35 @@ 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 }); + + // 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')); let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); @@ -173,19 +202,39 @@ export default async function run(t) { 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)) { - // Integrator skip at the Sprint-6 merge — HARNESS DISPUTE, not a verdict. - // Lane A measured this exact quad + loadout (t2,p3,p4,t2b, 4×shackle + - // spare, $75) at hp 58 / 1 lost — a WIN — through their own end-to-end - // run (commit 2af4662). This harness gets hp 36 / 2 lost on the same - // line. One of the two is measuring something different (tension? - // repair timing? drain wiring?) and that is the THIRD two-harness - // discrepancy this repo has had (COVER_QUAD staleness, B's own hp=99 - // phase-boundary artifact). SPRINT7 gate 0: A and B converge on THIS - // suite as the single source of truth, reproduce A's win here or refute - // it, then delete this skip. Do not tune anything until then. - return `SKIPPED — harness dispute: this suite says hp=${line.hp}/${line.lost} lost, ` + - `Lane A measured hp=58/1 on the same line (SPRINT7 gate 0)`; + // SPRINT7 gate 0 — HALF settled, and the half that settled was the loud one. + // + // RESOLVED: the garden. This suite read hp 36 because it built skyfx with + // no camera, and skyfx.step() opens `if (!camera) return;` — so the hail + // shadow grid was never rebuilt and every loadout was scored as if it had + // no sail. 36 IS the bare-bed number. With a camera (above) it reads ~68. + // Measured, camera the only variable: hailShadowOver(bed) 0.000 → 1.000. + // Nobody was lying; this harness was flying a yard with no cloth in it. + // + // UNRESOLVED: the corners. This suite says 2 lost; Lane A's end-to-end run + // says 1 at tension 1.0 (at the shop's default 0.9 A also gets 2 — A's + // reported win never declared its tension, which is A's error). Ruled out + // so far, each measured, each ~0.02 kN or less: wind shelters, frozen vs + // live tree sway, the scripted repair. Still unchecked: session.commit() + // vs main.js's rigSail() path, and main.js passing `debris` as rig.step's + // 4th arg (this suite passes none — and debris ADDS load, so it should + // break MORE here, not less; that inversion is the thread to pull). + // + // Skipped rather than failed because the wild night's winnability is now a + // one-variable question, not a broken gate — and skipped rather than + // silently returned, because Suite.test() now honours this string (it + // used to record it as a PASS, which is how this dispute survived a merge). + return `SKIPPED — gate 0 half-open: hp ${line.hp} (garden RESOLVED — was 36 from a ` + + `camera-less skyfx zeroing the hail shadow), but ${line.lost}/4 lost vs Lane A's 1 at ` + + `tension 1.0. Next suspects: commit() vs rigSail(), and debris in rig.step. See THREADS.`; } return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`; }); @@ -216,8 +265,32 @@ export default async function run(t) { 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`); + // 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}`; });