Files
splatter-tennis/test.js
T
wuwiandClaude Opus 5 435a470ec7 The umpire is one man with one microphone, and voiceSeq forgot it
"the drill mode immediately goes into some horrible audio loop" — on a phone,
never on desktop, and that is the tell. Three of the four defects can only
happen on a phone, and every one of them is a rule voice() had followed for
months that my new function quietly broke.

- A SUSPENDED AudioContext HAS A FROZEN CLOCK. Scheduling four clips at
  currentTime +0/+0.9/+1.7/+2.4 while the context is suspended puts all of
  them in the past by the time it starts, so they fire on one instant through
  a 1.3s reverb tail. Resume first, re-read the clock after the decode.
- A second call layered instead of replacing. voiceSeq SET voiceUntil and
  never checked it. It stops the outstanding list now.
- ...and cancelling a list has to give back the time it booked, or the
  replacement queues behind a list that will never play. Measured at 6.6s of
  silence before the call the player is waiting on.
- The amplifier was in drill.js: the empty-court net redealt on the spot,
  making the repair rate the frame rate — and every deal calls the umpire. It
  goes through the ordinary between-rounds beat now.

Verified by tagging each decoded buffer with its source file and counting
start() calls: one drill_call, three colours, spaced, zero overlapping voice
lines. The earlier "clean" reading counted fetches, which proves nothing —
VOICE[] caches, so a repeat play never fetches again.

27 tests; the new one empties the court every frame for six seconds and
asserts at most one umpire call per beat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:29:11 +02:00

438 lines
22 KiB
JavaScript

// Headless state-machine tests: `node test.js`.
// The game's modules are imported for real; only the platform (DOM, WebAudio, storage) is stubbed.
// These pin the court-ownership contract: horde, sudden-death duel, boss and changeover are
// EXCLUSIVE control modes — no interleaving of beats may ever let two of them run at once.
// Born from three field freezes: the last one was a duel starting inside a live horde
// (pretoss = no steering, horde = no serving, spawns keep coming → unrecoverable).
import assert from 'node:assert/strict';
// ---- platform stubs, installed before the game modules load ----------------
const absorb = new Proxy(function () {}, {
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
set: () => true,
apply: () => absorb,
construct: () => absorb,
});
const fakeCtx = () => new Proxy({}, {
get: (t, p) => {
if (p === 'canvas') return fakeCanvas();
if (p === 'measureText') return () => ({ width: 0 });
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
return () => undefined;
},
set: () => true,
});
function fakeCanvas() {
return {
width: 270, height: 480, style: {},
getContext: () => fakeCtx(),
addEventListener() {}, removeEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
toDataURL: () => '',
};
}
const store = new Map();
globalThis.localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
globalThis.document = {
getElementById: () => fakeCanvas(),
createElement: () => fakeCanvas(),
addEventListener() {}, removeEventListener() {},
body: { style: {}, appendChild() {} },
};
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js');
const { drillTarget } = await import('./src/drill.js');
const { TUNE, bossArt } = await import('./src/config.js');
const DT = 1 / 60;
function freshSplatter() {
restartRun();
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
}
let passed = 0;
function test(name, fn) {
freshSplatter();
fn();
passed++;
console.log(' ok - ' + name);
}
// ---- the contract -----------------------------------------------------------
test('sanity: a horde starts and owns the court in rally phase', () => {
forceZombies();
assert.ok(G.horde, 'horde should start on a clean court');
assert.equal(G.phase, 'rally', 'horde steering requires rally phase');
});
test('a duel refuses to start while a horde is live', () => {
forceZombies();
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a horde');
});
test('a horde refuses to start while a duel is live', () => {
startDuel();
assert.ok(G.interlude, 'duel should start on a clean court');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a duel');
});
test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => {
// A game won during the set card left clearing+afterClear armed while pendingHorde cashed:
spawnZombie(0, 5, 'a');
G.clearing = true; G.afterClear = 'game'; G.duelDue = true;
forceZombies();
assert.ok(G.horde, 'setup: horde live over a latched clear');
zombies.length = 0; // the gun mows the court down to an empty frame between spawns
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; }
assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde');
assert.ok(G.horde, 'the horde must keep running');
assert.ok(G.clearing, 'the clear waits for the horde to end');
// ...and once the horde expires, the parked clear advances and the duel fires — sequentially.
for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; }
assert.equal(G.horde, null, 'horde over');
for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; }
assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde');
});
test('kills mid-duel do not score: resolveInterlude is the only exit', () => {
startDuel();
const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]);
for (let i = 0; i < 20; i++) splatPoint('P');
splatPoint('O');
assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before,
'splatPoint must be a no-op during the duel');
resolveInterlude('P');
assert.equal(G.interlude, false, 'the duel still resolves normally');
});
test('invariant net: an injected duel-inside-horde is repaired within one frame', () => {
forceZombies();
G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state
G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving
update(DT);
assert.equal(G.interlude, false, 'watchdog must drop the impossible duel');
assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it');
assert.equal(G.phase, 'rally', 'and hand the finger back to the player');
assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log');
});
test('a pending horde defers while a duel runs, then fires after it', () => {
startDuel();
G.interlude.introT = 0;
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold while the duel owns the court');
assert.ok(G.pendingHorde, 'and stay armed');
resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires
for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s
assert.ok(G.horde, 'the set still gets its horde, after the duel');
});
// ---- regressions mined from the project log (each of these shipped broken once) ----------------
test('a pending horde never pours onto a boss stage', () => {
// commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the
// boss fight. The latch must hold while the boss owns the court, and fire after.
startBossFight(3);
assert.ok(G.bossFight, 'setup: boss on court');
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold during the boss fight');
assert.ok(G.pendingHorde, 'and stay armed');
});
test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => {
// endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the
// EPIC card before it drew a frame. The handback runs first now and the boss card wins.
startBossFight(3);
G.afterDuel = 'set'; G.seen.horde = true;
zombies.length = 0;
endBossFight();
assert.equal(G.bossFight, 0, 'fight over');
assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card');
assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde');
for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; }
assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it');
});
test('mgT counts down in every phase and releases the serve lock', () => {
// commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase
// mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock.
G.mgT = 0.5; G.gunId = 'gun';
assert.equal(G.phase, 'pretoss', 'setup: not in rally');
for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; }
assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally');
assert.equal(G.phase, 'pretoss', 'and hand back the serve loop');
});
test('a won duel pays out: the tin and the trainer', () => {
// commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel.
G.hp = G.hpMax - 3;
startDuel();
G.interlude.introT = 0;
const rackBefore = G.rack.length + (G.temp ? 1 : 0);
resolveInterlude('P');
const rackAfter = G.rack.length + (G.temp ? 1 : 0);
assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon');
assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel');
});
test('a lost duel banks their game — and their set — without a horde', () => {
score.games[1] = 5;
startDuel();
G.interlude.introT = 0;
resolveInterlude('O');
assert.equal(G.sets[1], 1, 'their 6th game banks their set');
assert.deepEqual(score.games, [0, 0], 'games reset');
assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge');
assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde');
});
// ---- the announcements: nothing that changes the control mode may arrive unannounced -----------
test('a set warns THEY KEEP COMING before the wave lands', () => {
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement');
assert.ok(G.hordeWarn > 0, 'the breather is running');
assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so');
for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second
assert.equal(G.horde, null, 'the breather actually holds');
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.ok(G.horde, 'and the wave lands when it runs out');
});
test('a mode that steals the court during the warning re-arms the horde', () => {
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.ok(G.hordeWarn > 0, 'setup: warning running');
startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here
G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping)
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.equal(G.horde, null, 'the wave must not land inside the duel');
assert.ok(G.pendingHorde, 'the set is still owed its horde');
});
test('a chapter boss announces itself and hands the court back on its own', () => {
startBossFight(2);
assert.ok(G.modal, 'the walk-on is an overlay, not a band card');
assert.equal(G.modal.title, bossArt(2).name, 'it names the boss');
assert.deepEqual(G.modal.body, ['ENTERED THE COURT']);
for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT);
assert.ok(G.modal, 'and holds the frozen court while the player reads it');
for (let i = 0; i < 20 && G.modal; i++) update(DT);
assert.equal(G.modal, null, 'then lets go without waiting for a tap');
assert.ok(G.bossFight, 'the fight is on');
});
test('a burst fires through the beat that follows the kill that emptied the court', () => {
// The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the
// court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent
// exactly when it had killed everything. Its OWN clock is the only thing that ends it.
G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0;
G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst
pellets.length = 0;
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill
assert.equal(G.phase, 'over', 'setup: the beat is still running');
assert.ok(pellets.length > 0, 'the burst keeps firing through it');
assert.ok(G.mgT > 0, 'and its window is still open');
});
// ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------
// Drive a drill round past its announcement: no serving and no targets while the umpire reads.
function drillPlaying({ bodies } = {}) {
// Pin the court size where a test needs a guaranteed decoy (bodies > the called sequence). The
// count is a RANGE in play, so an unpinned round can legitimately deal no decoy at all.
const min = TUNE.drillBodiesMin, max = TUNE.drillBodies;
if (bodies) { TUNE.drillBodiesMin = TUNE.drillBodies = bodies; }
startDrill();
TUNE.drillBodiesMin = min; TUNE.drillBodies = max;
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
}
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test
// A shambler soaks TUNE.shamblerHp splat-quality hits, so this re-arms the ball until the body is
// actually down — the drill keeps the roster's toughness on purpose (Oliver: "some are easier, some
// more difficult to kill"), which means a round is paced by hp as well as by order.
// Returns false when the body is already down — a ball carries through the lane, so a shot at one
// body can take the one standing behind it (see PROJECT.md's open question 1).
const drillKillBib = bib => {
const zb = zombies.find(z => z.bib === bib);
if (!zb) return false;
for (let i = 0; i < 40 && zombies.includes(zb); i++) {
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0;
update(DT);
}
assert.ok(!zombies.includes(zb), 'the body should be down');
return true;
};
test('a drill owns the court: every other mode refuses while it runs', () => {
assert.ok(startDrill(), 'a drill starts on a clean court');
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a drill');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a drill');
startBossFight(1);
assert.equal(G.bossFight, 0, 'startBossFight must refuse during a drill');
assert.equal(startDrill(), false, 'and a drill must refuse a second drill');
});
test('a drill refuses to start while another mode owns the court', () => {
startDuel();
assert.ok(G.interlude, 'setup: the duel has the court');
assert.equal(startDrill(), false, 'startDrill must refuse during a duel');
assert.equal(G.drill, null, 'and leave no half-built round behind');
});
test('the call is not play: no target and no serving until the umpire finishes', () => {
startDrill();
assert.ok(G.drill.callT > 0, 'the walk-on is running');
assert.equal(drillTarget(), null, 'nothing is a legal target during the call');
assert.ok(zombies.length >= TUNE.drillBodiesMin && zombies.length <= TUNE.drillBodies,
'the whole court is placed up front, ' + TUNE.drillBodiesMin + '..' + TUNE.drillBodies + ' of them');
assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'every bib on court is distinct');
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
assert.ok(drillTarget(), 'and the first target arms when the call ends');
assert.equal(drillTarget().bib, G.drill.seq[0], 'which is the first colour called');
});
test('killing in the called order wins the round; the tennis ladder stays out of it', () => {
drillPlaying();
const seq = G.drill.seq.slice(), games = score.games.slice(), pts = score.pts.slice();
for (const bib of seq) drillKillBib(bib);
assert.equal(G.drill.won, 1, 'the round is won');
assert.equal(G.drill.faults, 0, 'cleanly');
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
assert.deepEqual(score.pts, pts, '...nor tennis points — it keeps its own count');
for (let i = 0; i < TUNE.drillBeat * 60 + 4; i++) update(DT);
assert.ok(zombies.length > 0, 'and the next round places a fresh court');
});
test('two wrong bodies is a double fault, and the round goes to them', () => {
drillPlaying({ bodies: 5 }); // a full court, so a decoy is guaranteed
// a colour the sequence never called is a DECOY — the body you must let walk at you
const decoy = zombies.find(z => !G.drill.seq.includes(z.bib));
assert.ok(decoy, 'setup: the court carries at least one decoy');
drillKillBib(decoy.bib);
assert.equal(G.drill.faults, 1, 'a decoy is a fault');
assert.equal(G.drill.lost, 0, 'but not the round');
assert.ok(G.drill.rush > 0, 'the rest lose patience — the punishment is tempo');
const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]);
assert.ok(wrong, 'setup: something is still out of turn');
drillKillBib(wrong.bib);
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
assert.equal(G.drill.won, 0);
});
test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => {
// It used to lock onto the called body wherever the pull pointed, which made the shot unmissable
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
drillPlaying();
const called = G.drill.seq[G.drill.step];
const wrong = zombies.find(z => z.bib !== called);
assert.ok(wrong, 'setup: something else is on court');
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it');
});
test('rounds vary: which colours turn up, how many, and where they stand', () => {
startDrill();
const seen = { cols: new Set(), counts: new Set(), shapes: new Set() };
for (let r = 0; r < 40; r++) {
for (const z of zombies) seen.cols.add(z.bib);
seen.counts.add(zombies.length);
seen.shapes.add(G.drill.shape);
G.drill.beat = 0.001; update(DT); update(DT); // deal the next round
}
assert.ok(seen.cols.size > 4, 'the palette is bigger than any one court: ' + seen.cols.size + ' colours seen');
assert.ok(seen.counts.size > 1, 'the body count moves: ' + [...seen.counts]);
assert.ok(seen.shapes.size > 2, 'the placement shape moves: ' + [...seen.shapes]);
});
test('a wrong kill never leaves an un-winnable round', () => {
drillPlaying({ bodies: 5 });
const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first
drillKillBib(later);
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
assert.ok(!G.drill.seq.includes(later), 'and the dead colour leaves the list rather than blocking it');
assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)),
'every colour still on the list is still on the court');
});
test('being caught costs the point, not the run', () => {
drillPlaying();
killPlayer();
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
assert.equal(G.drill.lost, 1, 'they take the round instead');
assert.equal(zombies.length, 0, 'and the court is cleared for the next one');
});
test('an emptied court cannot machine-gun the umpire', () => {
// Oliver, on a phone: "the drill mode immediately goes into some horrible audio loop". The trigger
// was never reproduced on desktop (a suspended AudioContext has a frozen clock, so a scheduled
// sequence all fires at once on resume — see voiceSeq), but the AMPLIFIER was here: the
// empty-court net used to redeal on the spot, which makes the repair rate the frame rate, and
// every deal calls the umpire. Whatever empties the court, it must never announce more than once
// per beat.
drillPlaying();
let deals = 0;
const seq0 = G.drill.seq;
for (let i = 0; i < 60 * 6; i++) { // six seconds with the court emptied every single frame
zombies.length = 0;
update(DT);
if (G.drill.seq !== seq0 && G.drill.callT > 0 && zombies.length) { deals++; }
if (zombies.length) zombies.length = 0; // ...and emptied again, so it can never settle
}
const repairs = G.missLog.filter(m => m.result === 'INVARIANT').length;
assert.ok(repairs > 0, 'the net notices and leaves evidence');
assert.ok(repairs <= 6 / TUNE.drillBeat + 2,
'at most one repair per drillBeat, not one per frame — got ' + repairs);
assert.ok(deals <= 6 / TUNE.drillBeat + 2, 'and at most one deal (one umpire call) per beat — got ' + deals);
});
test('endDrill hands the court back', () => {
drillPlaying();
endDrill();
assert.equal(G.drill, null);
assert.equal(zombies.length, 0, 'the drill takes its bodies with it');
assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report