Gate 0: name the harness dispute's cause; make skips real (testkit)

THE CAUSE, and it is not a winner: balance.test.js built skyfx with no camera,
and skyfx.step() opens `if (!camera) return;` — so the hail shadow grid was
never rebuilt across all 5400 calls and every loadout was scored as though it
had no sail. hp 36 IS the bare-bed number. Measured with the camera as the only
variable: hailShadowOver(bed) peaks 0.000 vs 1.000, hp 36 vs 69. Neither harness
was lying; one was flying a yard with no cloth in it. Camera + wind shelters
added — the suite must drive what the game drives.

MY ERROR, named: the hp-58 win was measured at tension 1.0 and I never declared
it. At the shop's default 0.9 my own end-to-end also loses 2 corners.

STILL OPEN: this suite says 2 corners lost, my end-to-end says 1 at tension 1.0.
Ruled out by measurement, each worth <=0.02 kN: shelters, frozen vs live tree
sway, the scripted repair. Next suspects named in the skip: session.commit() vs
rigSail(), and main.js passing debris as rig.step's 4th arg (debris ADDS load,
so it should break MORE here, not less — that inversion is the thread).

And the find that explains how this survived a merge: Suite.test() ignored its
return value, so `return "SKIPPED — ..."` was recorded as a PASS. The
integrator's storm_02 skip was a fake pass; so was mine for storm_01; `skip: 0`
in every report we ever printed was the tell and nobody read it, me included — I
misread one of those fakes as my win reproducing. test() now honours the string,
and rejects async fns outright (they hand back a Promise that cannot throw
synchronously, passing forever while proving nothing).

That guard immediately caught its own author: BOTH my wind-router tripwire tests
were async and have asserted nothing for two sprints — including the sprint where
I reported the tripwire "caught its first real omission". It had, in a hand-run
probe, never in the suite. Awaits hoisted into run(); they asserted for the first
time today, and pass.

Selftest 261 pass / 0 fail / 2 honest skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 10:21:01 +10:00
parent aef9958337
commit aac1030131
3 changed files with 133 additions and 21 deletions

View File

@ -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({

View File

@ -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);

View File

@ -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}`;
});