Files
splatter-tennis/test.js
T
wuwiandClaude Fable 5 98b01fea5a The trail reads as motion, never as residue: the ghosts fade with the sprint
The lingering doubles were a real bug, not a taste problem: racketAI's
!incoming return kept the last chase's vx/vz on the body, so the speed
ghosts trailed a zombie that had already stopped. The velocity dies with
the chase now (tested). The look is halved with it: ghost alphas
0.22/0.10 -> 0.11/0.05, trailK 0.05 -> 0.035 — a hint of speed in the
corner of the eye, never a second body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:36:19 +02:00

2127 lines
124 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';
import { readFileSync } from 'node:fs';
// ---- DETERMINISM. This suite is a Dockerfile build stage: a red run blocks the deploy, so a test
// that fails one run in five is worse than no gate at all — it trains you to re-run it. Two of the
// drill tests did exactly that (they asserted on a random batch composition and on a snapshot of a
// population that legitimately empties), and the pipeline caught what 60 local runs had not.
//
// The game leans on Math.random() everywhere — jitter, variants, lanes, spawn gaps — so the fix is
// to make the STREAM reproducible rather than to strip randomness out of the game. Fixed seed by
// default, and `SEED=1234 node test.js` re-rolls it: run the suite over a spread of seeds when you
// touch anything probabilistic, and any failure you see is one you can reproduce exactly.
//
// The cost to know about: under one seed a guarded test can silently take its early-out every time
// and quietly stop testing anything. If you add a `return` for an awkward deal, fuzz a few seeds and
// make sure the body actually runs.
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 4294967296;
};
// ---- 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() {} });
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
globalThis.hapticLog = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
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 state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js');
const { read: readStats, check: statsCheck } = statsMod;
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 triage drill: one rule, and every part of it pinned --------------------------------
// The rule: always kill the body that would reach you first (arrival = distance / speed down a held
// lane). Wounds are free, the slack forgives near-ties, the streak is the punishment and the payout.
function drillPlaying(seconds = 1) {
startDrill();
for (let i = 0; i < seconds * 60; i++) update(DT);
}
// kill a specific body through the shared funnel, via the ball's contact test
const drillKillBody = zb => {
for (let i = 0; i < 60 && 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');
};
// a hand-built queue with KNOWN arrivals, so a test judges against arithmetic rather than luck:
// far-but-fast arrives at t=4, near-but-slow at t=8, dawdler at t=16
function riggedQueue() {
startDrill();
zombies.length = 0;
G.drill.spawnT = 999; // hold the stream while the fixture stands
const rig = [];
for (const [z, v] of [[COURT.L - 8, 2.0], [COURT.L - 4, 0.5], [COURT.L - 16, 1.0]]) {
spawnZombie(0, z, 'a');
const zb = zombies[zombies.length - 1];
zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1;
rig.push(zb);
}
return rig; // [first, second, third] by arrival: 4 s, 8 s, 16 s
}
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 state behind');
});
test('the queue ranks by ARRIVAL, not distance: far-but-fast outranks near-but-slow', () => {
const [fast, slow] = riggedQueue();
const q = drillQueue();
assert.equal(q[0], fast, 'the 2.0 m/s body 8 m out arrives first (4 s vs 8 s)');
assert.equal(q[1], slow, 'the 0.5 m/s body 4 m out is second');
assert.ok(arrival(q[0]) < arrival(q[1]) && arrival(q[1]) < arrival(q[2]), 'arrivals are ordered');
});
test('killing the queue head grows the streak and banks points', () => {
const [fast] = riggedQueue();
const s0 = G.drill.score;
drillKillBody(fast);
assert.equal(G.drill.streak, 1, 'a correct kill grows the streak');
assert.equal(G.drill.faults, 0, 'and is no fault');
assert.ok(G.drill.score > s0, 'and banks points');
});
test('killing out of turn is a fault, resets the streak, and sweeps nothing', () => {
const rig = riggedQueue();
G.drill.streak = 7; G.drill.score = 500;
drillKillBody(rig[2]); // the 16 s dawdler, with a 4 s body still walking
assert.equal(G.drill.faults, 1, 'a misread faults');
assert.equal(G.drill.streak, 0, 'and the streak dies');
assert.equal(G.drill.score, 500, 'the banked score is untouched');
assert.ok(zombies.includes(rig[0]) && zombies.includes(rig[1]), 'the court is NOT swept — the queue keeps walking');
});
test('the slack forgives a near-tie', () => {
startDrill();
zombies.length = 0; G.drill.spawnT = 999;
for (const dz of [6, 6.3]) { // two bodies arriving ~0.3 s apart at 1 m/s
spawnZombie(dz > 6 ? 2 : -2, COURT.L - dz, 'a');
const zb = zombies[zombies.length - 1];
zb.drill = true; zb.pts = 20; zb.v = 1.0; zb.hp = zb.hpMax = 1;
}
const second = zombies[zombies.length - 1];
drillKillBody(second); // kill #2 of a 0.3 s tie: inside drillSlack 0.9
assert.equal(G.drill.faults, 0, 'inside the slack, either of a near-tie is legal');
assert.equal(G.drill.streak, 1, 'and it counts');
});
test('wounds are free: softening a back-of-queue heavy is never judged', () => {
startDrill();
zombies.length = 0; G.drill.spawnT = 999;
spawnZombie(0, COURT.L - 3, 'a'); // the urgent one, 3 s out
const urgent = zombies[zombies.length - 1];
urgent.drill = true; urgent.pts = 20; urgent.v = 1.0; urgent.hp = urgent.hpMax = 1;
spawnZombie(2, COURT.L - 18, 'g'); // a brute at the back, 5 hp
const brute = zombies[zombies.length - 1];
brute.drill = true; brute.pts = 80; brute.v = 1.0;
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
ball.x = brute.x; ball.z = brute.z; ball.vx = ball.vy = ball.vz = 0;
update(DT);
assert.ok(zombies.includes(brute), 'setup: the brute survived the hit');
assert.ok(brute.hp < brute.hpMax, 'setup: but is wounded');
assert.equal(G.drill.faults, 0, 'a wound out of turn is NOT a fault — only the kill is judged');
});
test('the multiplier steps with the streak and caps', () => {
startDrill();
const d = G.drill;
d.streak = 0; assert.equal(drillMult(d), 1);
d.streak = TUNE.drillMultEvery; assert.equal(drillMult(d), 2);
d.streak = TUNE.drillMultEvery * 9; assert.equal(drillMult(d), TUNE.drillMultCap, 'capped');
});
test('the best score survives in localStorage', () => {
startDrill();
const [fast] = riggedQueue();
G.drill.score = 777; G.drill.best = 0;
drillKillBody(fast); // any correct kill banks through bankBest
assert.ok(G.drill.best >= 777, 'the run became the best');
assert.ok(Number(localStorage.getItem('drillBest')) >= 777, 'and it is written down');
});
test('the stream keeps arriving, capped and spaced', () => {
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
try {
startDrill();
const seeded = zombies.length;
let peak = 0;
for (let i = 0; i < 18 * 60; i++) { update(DT); peak = Math.max(peak, zombies.length); }
assert.ok(peak > seeded, 'bodies keep arriving: peak ' + peak);
assert.ok(peak <= TUNE.drillMaxOnCourt, 'never past the court cap');
for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++)
assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8,
'two bodies ended up on top of each other');
} finally { TUNE.eatenR = eaten; }
});
test('being caught sweeps the court and kills the streak, but the score survives', () => {
drillPlaying();
G.drill.score = 900; G.drill.streak = 6;
killPlayer();
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
assert.equal(G.drill.lost, 1, 'it is a loss');
assert.equal(G.drill.streak, 0, 'the streak dies');
assert.equal(G.drill.score, 900, 'the score does not');
assert.equal(zombies.length, 0, 'the court is swept');
assert.equal(ball.active, false, 'and the finishing ball is spent — the court refills up-court');
for (let i = 0; i < 10 * 60; i++) update(DT);
assert.ok(zombies.length > 0, 'then the stream simply resumes');
});
test('a body that walks past you counts as caught, never as a free escape', () => {
drillPlaying();
const runaway = zombies.find(z => z.drill);
assert.ok(runaway, 'setup: the stream is on court');
runaway.x = P.x + 5; runaway.z = COURT.L + 1;
const lost = G.drill.lost;
update(DT);
assert.equal(G.drill.lost, lost + 1, 'it costs the point');
assert.ok(!zombies.includes(runaway), 'and it is gone');
});
test('drillOneShot is a switch, and OFF means the roster HP is intact', () => {
startDrill();
for (let i = 0; i < 30 * 60 && !zombies.some(z => 'gkhde'.includes(z.variant)); i++) update(DT);
const heavy = zombies.find(z => 'gkhde'.includes(z.variant));
if (!heavy) return; // this seed dealt only shamblers in the window
if (TUNE.drillOneShot >= 0.5) assert.equal(heavy.hp, 1, 'one-shot on: everything is glass');
else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp');
});
test('the rule announces itself once, and the markers are the legal SET', () => {
_startDrill(); // the raw one: we WANT the modal this time
assert.ok(G.modal, 'first drill of a run: the rule gets its one sentence');
assert.equal(G.modal.title, 'CLEARANCE DRILL');
G.modal = null;
endDrill(); _startDrill();
assert.equal(G.modal, null, 'second drill of the same run: no re-teaching');
G.modal = null;
// the legal set: everything within the slack of the head, and never anything beyond it
zombies.length = 0; G.drill.spawnT = 999;
for (const [dz, v] of [[6, 1.0], [6.5, 1.0], [16, 1.0]]) { // 6 s, 6.5 s, 16 s out
spawnZombie(0, COURT.L - dz, 'a');
const zb = zombies[zombies.length - 1];
zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1;
}
const legal = drillLegal();
assert.equal(legal.length, 2, 'the 0.5 s tie is IN the marker set (slack ' + TUNE.drillSlack + ')');
assert.ok(!legal.some(z => Math.abs(arrival(z) - 16) < 0.5), 'the 16 s body is not');
});
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');
});
// ---- zombie tennis: one game per opponent, blood only ------------------------------------------
const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; };
const oppOf = () => zombies.find(z => z.variant === 'r');
// a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on — plus
// the boy's walk-off hold (he must LEAVE the court) and possibly the WOUNDS TREATED popup, which
// freezes the world until tapped. Tests that play across a game boundary step through all of it,
// dismissing popups the way a player would.
const passMopBeat = () => { // ...stopping at the walk-on: past it, the next opponent PLAYS
// the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it
for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
// the walk-on is a MODAL now (2026-08-28) and it arrives WITH the opponent — tap it away too,
// or every step after this one runs against a frozen world
G.modal = null; G.modalQ.length = 0;
};
test('a match owns the court like every other mode', () => {
assert.ok(startMatch(), 'a match starts on a clean court');
assert.ok(G.interlude, 'and runs on a permanent interlude — that is the design');
startDuel(); // interlude guard catches it
forceZombies();
assert.equal(G.horde, null, 'no horde into a match');
startBossFight(1);
assert.equal(G.bossFight, 0, 'no boss into a match');
assert.equal(startDrill(), false, 'no drill into a match');
assert.equal(startMatch(), false, 'no second match');
endMatch();
assert.ok(startDrill(), 'endMatch hands the court back');
});
test('a point won tears a wound stage off the body; only the game point fells it', () => {
startMatch();
const zb = oppOf();
assert.equal(zb.hpMax, TUNE.matchOppHp, 'opponent one carries the base pool');
for (let k = 1; k <= 3; k++) {
resolveInterlude('P');
assert.ok(zombies.includes(zb), 'point ' + k + ': still standing');
assert.ok(zb.hp < zb.hpMax, '...and visibly wounded (' + zb.hp + '/' + zb.hpMax + ')');
}
assert.equal(zb.hp, 1, 'three points in: one stage left');
resolveInterlude('P'); // the game point
// THE KILL FALL: the game point fells the body but no longer bursts it on the same frame —
// the collapse plays, the prone hold holds, and only then the explosion (Oliver: "it would be
// better if the explosion would happen after the fall").
assert.ok(zombies.includes(zb), 'the game point drops the body — the burst waits for the fall');
assert.ok(G.matchFall && G.matchFall.kill, 'the kill fall is armed');
assert.ok(G.interlude && G.interlude.introT > 0, 'serving is barred while it plays');
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
assert.ok(!zombies.includes(zb), 'the fallen body bursts after the prone hold');
assert.ok(!(G.match.mopT > 0), '...and the finish still waits for the smoke to be seen');
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !(G.match.mopT > 0); i++) update(DT);
// THE MOP BEAT: the corpse's gore holds the court first — no fresh body until the boy has had
// his time (TUNE.matchMopT, skipped only on a clean court; the explode() above guarantees gore).
assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court');
assert.ok(G.match.mopT > 0, 'the beat is armed');
assert.ok(G.interlude && G.interlude.introT > 0, 'and serving is barred for its whole length');
// ...and the beat now also WAITS FOR THE BOY TO LEAVE the court before the walk-on
for (let i = 0; i < (TUNE.matchMopT + 2) * 10 && G.boy && Math.abs(G.boy.x) < 6.5; i++) update(0.1);
assert.ok(!oppOf(), 'still no opponent while the boy is on the court');
passMopBeat();
const next = oppOf();
assert.ok(next && next !== zb, 'a fresh opponent shambled on after the mop beat');
assert.equal(next.hpMax, TUNE.matchOppHp, 'same flat pool — the ladder escalates in speed/flub, not hp');
assert.equal(G.runGames, 1, 'the ladder banked the game');
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
});
test('the WOUNDS TREATED card lands after the explosion, never on top of it', () => {
startMatch();
const zb = oppOf();
G.hp = G.hpMax - 2; // something for the trainer to treat
for (let k = 0; k < 4; k++) resolveInterlude('P'); // a straight game
assert.ok(!G.modal, 'no card while the body is still falling');
for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT);
assert.ok(!zombies.includes(zb) && !G.modal, 'no card on the burst frame either');
for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !G.modal; i++) update(DT);
assert.ok(G.modal && G.modal.title === 'WOUNDS TREATED', 'the trainer card follows the burst');
});
test('their points draw blood from the MATCH pool, and running dry is the run', () => {
startMatch();
assert.equal(G.hp, TUNE.matchPlayerHp, 'the match brings its own, deeper pool');
resolveInterlude('O');
assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'a point costs one');
G.hp = 1;
resolveInterlude('O');
assert.equal(G.mode, 'eaten', 'blood only — and it runs out the way it always does');
});
test('a match ball never wounds the body: the score is the only way through', () => {
startMatch();
const zb = oppOf();
// a full-power ball straight into the opponent — the regicide path, now closed for matches
ball.active = true; ball.lastHit = 'P'; ball.y = 1.2; ball.pow = 1; ball.bounces = 0;
ball.x = zb.x; ball.z = zb.z; ball.vx = 0; ball.vy = 0; ball.vz = -1;
for (let i = 0; i < 5; i++) update(DT);
assert.ok(zombies.includes(zb), 'still standing');
assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match');
});
test('THE WOUNDS PLAY: a wounded opponent covers less and whiffs more', () => {
startMatch();
const zb = oppOf();
const w = f => matchWound({ hp: zb.hpMax * f, hpMax: zb.hpMax });
assert.deepEqual(w(1), { reach: 1, flub: 1 }, 'an unhurt body plays exactly as it always did');
const arm = w(0.5), head = w(0.25); // one point in is still stage 0 — a quarter is 0.75
assert.ok(head.reach < arm.reach && arm.reach < 1, 'reach falls with every stage it loses');
assert.ok(head.flub > arm.flub && arm.flub > 1, '...and the whiff rate climbs with it');
assert.ok(head.reach > 0.85 && head.flub < 1.5,
'and it stays TEXTURE under the ladder, not a second difficulty curve: ' + JSON.stringify(head));
// ...and it is wired to the racket, not just to the table: a gap the whole body covers and the
// headless one cannot. ONE frame, so its own chase (~0.06 m) can never close the 0.19 m gap.
const swingsAt = (hp, d) => {
zb.hp = hp; zb.swingCool = 0; zb.swingT = -1;
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.pow = 0;
ball.x = zb.x + d; ball.z = zb.z; ball.y = 1.0;
ball.vx = 0; ball.vy = 0; ball.vz = -1; // incoming, or racketAI ignores it entirely
update(DT);
return zb.swingCool > 0; // a whiff arms the cooldown too — this is REACH
};
const gap = TUNE.aiReach * (1 + head.reach) / 2; // between the two reaches
assert.ok(swingsAt(zb.hpMax, gap), 'the whole body reaches ' + gap.toFixed(2) + ' m');
assert.ok(!swingsAt(1, gap), '...and the headless one does not');
});
test('the FLICK LESSON arms on a run of aimless returns and dies on an aimed one', () => {
assert.equal(flatCount(0, { ax: 0, az: 0 }), 1, 'a flat return is evidence');
assert.equal(flatCount(3, { ax: 0, az: -1 }), 4, '...and so is a depth-only one: this teaches WIDTH');
assert.equal(flatCount(9, { ax: -1, az: 0 }), 0, 'one aimed return and the lesson is over');
assert.equal(flatCount(9, null), 10, 'no aim at all is the flattest shot there is');
assert.ok(TUNE.flickHintAfter > 2, 'hitting flat is legal — it takes a RUN of them to nag');
// the wiring: a real match return with nothing on it counts itself
startMatch();
const zb = oppOf();
G.interlude.introT = 0; // the umpire has finished announcing — the tap is a swing now
G.flatStreak = 0; G.phase = 'rally';
P.x = 0; P.z = 23; P.swingT = -1;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
assert.equal(ball.lastHit, 'P', 'he got a racket on it');
assert.equal(G.flatStreak, 1, 'and the flat return was counted');
assert.ok(zombies.includes(zb), '(the body is untouched by any of this)');
});
test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => {
// The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first
// fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat
// until dead. Oliver hit it: "when trying to serve after i got hurt i get hurt again and again
// until i die".
startMatch();
G.serveNum = 2; // a fault has burned the first serve
resolveInterlude('O'); // the point is lost (double fault, passing shot, whatever)
assert.equal(G.serveNum, 1, 'the NEXT point starts on a first serve');
resolveInterlude('P');
assert.equal(G.serveNum, 1, '...whoever won it');
});
test('SECOND SERVICE: a missed serve in the match faults instead of losing the point', () => {
// Oliver, 2026-08-22: "when serving results in an out there should be a second service... the
// umpire should announce 2nd service and the player must get another chance." The duel and the
// match had no service rule at all — the first serve was judged as an ordinary ground stroke, so
// a serve long or into the net was a point, and in the match a point costs blood.
startMatch();
for (let i = 0; i < 2.6 * 60; i++) update(DT); // the walk-on beat, then his serve
const zb = oppOf();
const pts = score.pts.slice(), hp = G.hp;
zb.x = 3.9; zb.z = 5.5; // it chased the fault out to the wing
const serveOut = () => { // a serve that lands past the far baseline
G.phase = 'rally'; G.wasServe = true;
ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0;
ball.x = 0.4; ball.z = 4; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -20; ball.g = TUNE.gravity;
for (let i = 0; i < 60 && ball.active; i++) update(DT);
};
serveOut();
assert.equal(G.serveNum, 2, 'the first miss buys a second serve');
assert.deepEqual(score.pts, pts, '...and costs no point');
assert.equal(G.hp, hp, '...and no blood');
assert.equal(G.matchFall, null, 'nobody falls over a fault');
assert.equal(zb.z, 1.0, 'the body is back on its receiving mark for the second serve');
assert.equal(zb.x, O.x, '...on the same mark the invisible tennis body serves against');
serveOut(); // the double fault
assert.equal(score.pts[1], pts[1] + 1, 'two misses is the point, theirs');
assert.equal(G.hp, hp - 1, '...and the match scores it in blood, like every other lost point');
assert.equal(G.serveNum, 1, 'the next point starts on a first serve');
});
test('SECOND SERVICE: a serve that lands IN is still played, fault or not', () => {
// The guard is on the OUT branch only — the mode's other rules (the early-decided winner, the
// second bounce) must not learn about serves.
startMatch();
for (let i = 0; i < 2.6 * 60; i++) update(DT);
const pts = score.pts.slice();
G.phase = 'rally'; G.wasServe = true;
ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0;
ball.x = 0.4; ball.z = 8; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -8; ball.g = TUNE.gravity;
for (let i = 0; i < 30 && ball.bounces < 1; i++) update(DT);
assert.equal(ball.bounces, 1, 'setup: it bounced');
assert.ok(inCourt(ball.x, ball.z), '...in court');
assert.equal(G.serveNum, 1, 'a legal serve is no fault');
assert.deepEqual(score.pts, pts, 'and no point either — it is a rally now');
});
test('a duel opens on a FIRST serve, whatever the last one ended on', () => {
// Same shape as the match's death loop, one mode over: nothing in startDuel reset G.serveNum, and
// now that a duel can fault, a fault in one duel would have armed second-serve in the next.
restartRun();
G.mode = 'splatter';
G.serveNum = 2;
startDuel();
assert.equal(G.serveNum, 1, 'the duel is served for the first time');
});
test('a receiver stands BEHIND his baseline, on both sides of the net', () => {
// Oliver: "when the opponent serves the player does not stand at the baseline — but that's how it
// should be." Both marks used to sit over a metre inside the court.
restartRun();
score.server = 'O'; setupPoint();
assert.ok(P.z > COURT.L, 'receiving, the player waits behind his own baseline: z=' + P.z);
assert.ok(O.z < -0.2, '...and the server is behind his: z=' + O.z);
score.server = 'P'; setupPoint();
assert.ok(O.z < 0, 'and mirrored, the far receiver is behind the far baseline: z=' + O.z);
assert.ok(P.z > COURT.L, '...with the server behind his own line');
});
test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
// Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the
// opponent sees the ball really fast — the player barely sees the speed increase." The ball IS
// the same ball on both phones (net.js copies the velocities verbatim); what differs is the
// camera, which compresses your own outgoing ball into the distance and expands theirs as it
// comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's
// LENGTH (render), the crack's pitch, and this.
restartRun(); G.classic = true;
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 1, null, true); // a flush serve: ~49 m/s
assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1)));
const hard = G.shake.mag;
// ...and a slow ball does not. Same call, no timing on it.
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s
assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly');
assert.equal(G.shake, null, 'a slow serve does not kick the camera');
assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')');
});
test('the match brings its own legs: auto-run works even where autoAssist is 0', () => {
// The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a
// racket — the flight recorder showed a swing in every lost point and an x that never moved.
const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop condition
try {
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
G.phase = 'rally';
P.x = 0; P.z = 23;
ball.active = true; ball.lastHit = 'O'; ball.x = -3; ball.z = 14; ball.y = 1.4;
ball.vx = -1; ball.vy = 2; ball.vz = 5; // incoming, wide of him
const x0 = P.x;
for (let i = 0; i < 45; i++) update(DT);
assert.ok(Math.abs(P.x - x0) > 0.5, 'he RUNS for it with no manual input and no coarse pointer: '
+ x0.toFixed(1) + ' -> ' + P.x.toFixed(1));
} finally { TUNE.autoAssist = assist; }
});
test('THE SHORT BALL: the assist runs FORWARD onto a dying return, desktop conditions included', () => {
// Seven of seven recorded losses: a slow return bounced and died at z 18-20 — outside the
// intercept zone, so nothing chased it and it rolled dead at his feet (below legal contact
// height). Doubly broken on desktop: intercept computed reachability with autoAssist (0 there),
// so its answer was always "run home". Both fixed via assistK() + the anyHit candidate.
const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop pane condition
try {
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
G.phase = 'rally';
P.x = 0; P.z = 23;
// a slow short return: incoming, will bounce and die around z ~19
ball.active = true; ball.lastHit = 'O'; ball.x = 1.5; ball.z = 10; ball.y = 1.6;
ball.vx = 0.4; ball.vy = 2.2; ball.vz = 5.5; ball.bounces = 0; ball.g = TUNE.gravity;
const z0 = P.z;
let minGap = 99, minPz = P.z; // deepest he RAN, read during the chase — the point reset
// teleports him home after, so the final position proves nothing
for (let i = 0; i < 2.5 * 60 && ball.active && ball.lastHit === 'O'; i++) {
update(DT);
minPz = Math.min(minPz, P.z);
minGap = Math.min(minGap, Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8));
}
assert.ok(minPz < z0 - 3.0, 'he ran FORWARD to meet it: z ' + z0 + ' -> ' + minPz.toFixed(1));
assert.ok(minGap < 1.6, 'and got the ball into reach (closest ' + minGap.toFixed(1) + ' m)');
} finally { TUNE.autoAssist = assist; }
});
test('the match recovers to CENTRE between shots — a wide return must not strand you', () => {
// The match inherited gore-splatter's depth-only recovery (a sidestep-protection rule) by being
// G.mode 'splatter' — so one wide return pulled you off centre and you STAYED there, and the next
// ball to the opposite corner was unreachable before it was struck. "Every second ball is
// basically unreachable" was this, not CPU strength.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; // headless matchMedia reports desktop: assist off
try {
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
G.phase = 'rally';
P.x = 4.2; P.z = 22.8; // stranded wide by the last return
ball.active = true; ball.lastHit = 'P'; ball.vz = -8; ball.z = 8; ball.y = 1; // your ball, outbound: not chasing
const x0 = P.x;
for (let i = 0; i < 60; i++) update(DT);
assert.ok(Math.abs(P.x) < Math.abs(x0) - 0.5,
'auto-run walks back toward centre: ' + x0.toFixed(1) + ' -> ' + P.x.toFixed(1));
} finally { TUNE.autoAssist = assist; }
});
test('the tennis timing cues belong to CLASSIC tennis too, not just the match', () => {
// Reported three times before it was believed, and it was a one-word gate each time: drawApproachRing
// and drawApexBand both opened with `if (!G.match ...)`, so the mode whose whole pitch is "if the
// core tennis is good everything else is good" was the one mode without the cues that make the
// core tennis readable. Asserted on the SOURCE because these are draw calls with no state to
// observe — what matters is that neither is gated on the match alone ever again.
const src = readFileSync(new URL('./src/render.js', import.meta.url), 'utf8');
for (const fn of ['drawApproachRing', 'drawApexBand']) {
const body = src.slice(src.indexOf('function ' + fn), src.indexOf("function " + fn) + 900);
assert.ok(!/if \(!G\.match \|\|/.test(body), `${fn} must not be gated on the match alone`);
assert.ok(/G\.match \|\| G\.mode === 'tennis'/.test(body), `${fn} must also run in classic tennis`);
}
});
test('the opponent card is a RAMP: it starts easy and only gets harder', () => {
// "the players should start easy and get harder over time." That is a property of the table, not
// of any code, so the table is what gets asserted — a row edited into the wrong slot is otherwise
// invisible until someone plays ten opponents deep.
const { matchFoe, TUNE, LADDER } = cfg;
let lastSpeed = 0, lastWhiff = 1e9;
// read off the table rather than pinned: the ladder went 12 -> 18 on 2026-08-15 and a hardcoded
// count here would simply have stopped testing the six new rungs without failing
for (let n = 1; n <= LADDER; n++) {
const f = matchFoe(n);
const speed = (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (f.speed ?? 1);
const whiff = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, n - 1) * (f.flub ?? 1);
assert.ok(whiff < lastWhiff, `opponent ${n} (${f.short}) must whiff less than the one before`);
lastWhiff = whiff;
// Speed is allowed to ZIGZAG by character — opponent 5 is the RUNNER and opponent 6 paints
// lines instead of sprinting — so this bans a real regression, not a personality. The whiff
// rate above is the dial that must fall every single rung; it is the one the ramp rides on.
if (n > 1) assert.ok(speed > lastSpeed * 0.88,
`opponent ${n} (${f.short}) must not be a big step BACKWARDS in speed`);
lastSpeed = speed;
assert.ok(f.name && f.short && f.line, `opponent ${n} is fully named`);
assert.ok(f.short.length <= 8, `${f.short} fits the board's 8-char slot`);
}
// ...and the opening must actually be gentle: opponent one is the softest thing on the card.
assert.ok((matchFoe(1).speed ?? 1) < (matchFoe(LADDER).speed ?? 1) * 0.6, 'the shambler is no champion');
// every rung that has its own family wears it — the 2026-08-15 six included, whose whole point is
// that the rung and the thing that hunts you in the gore mode are the same person
for (const [n, fam] of [[3, 'sp'], [5, 'rm'], [7, 'ma'], [9, 'sb'], [10, 'sg'], [12, 'cu'],
[13, 'ph'], [14, 'hc'], [17, 'dr'], [18, 'ch']])
assert.equal(matchFoe(n).fam, fam, `rung ${n} wears its own family`);
// ...and every named family is one the preloader actually loads
for (let n = 1; n <= LADDER; n++)
assert.ok(cfg.ZFAMS.includes(matchFoe(n).fam), `rung ${n}'s family is registered in ZFAMS`);
assert.ok(matchFoe(LADDER).h > 1.85, 'the champion stands taller than a stock body');
// the cup's final draws the top rung and nothing else: a band pointing past the ladder would
// wrap through matchFoe's modulo and quietly deal the shambler as THE CHAMPION
const fin = cfg.CUP_ROUNDS[cfg.CUP_ROUNDS.length - 1];
assert.deepEqual(fin.band, [LADDER], 'the final is the top of the ladder');
for (const r of cfg.CUP_ROUNDS) for (const rung of r.band)
assert.ok(rung >= 1 && rung <= LADDER, `${r.name} draws a rung that exists (${rung})`);
});
test('the first opponent cannot paint lines: its returns are width-clamped', () => {
// aimError x (1 - 0.55 quality) scattered returns to +-3.5 m, which an auto-run player cannot
// cover — "still a LOT of unreachable balls". Width is the ladder's third dial now.
assert.ok(TUNE.matchWideBase < 2.5, 'opponent one is kept inside reachable width');
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
});
test('an aimed return can be played OUT, but only by mistiming it', () => {
// The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError
// at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and
// went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in.
// Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same
// quality the scatter shrinks with, so the two can never sum past the line — that sum is the
// whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally.
// The contract is NOT "never out" — that was the overcorrection ("too easy. i must be able to
// play out"). It is: going out is possible and BOUNDED, it gets rarer as timing improves, and a
// perfectly timed flick can never do it. Risk decided by skill rather than by a coin toss.
const line = COURT.W / 2, aimW = TUNE.flickWidth; // doHit's far-court aim width
const pOut = q => {
const reach = aimW * (TUNE.flickReach + (1 - TUNE.flickReach) * q);
const e = TUNE.aimError * (1 - q);
return e <= 0 ? 0 : Math.max(0, Math.min(1, (e - (line - reach)) / (2 * e)));
};
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
// ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the
// worst-case rate was halved off 15%, and this is what stops flickReach drifting back up.
assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%');
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
for (let q = 0.05; q <= 1.0001; q += 0.05)
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);
// ...and it must still be worth flicking: perfect timing reaches the corner.
assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner');
});
test("a flick's LENGTH is its width: the three-lane cliff is gone", () => {
// The gesture used to be read as three LANES off a 0.38 ratio, so two neighbouring swipes differed
// by the whole court — "sometimes applied very strong, sometimes barely does anything", with no
// way to tell which one you had asked for. Length is the strength now, as it always was on serve.
const swipe = (m, fx = 1) => ({ ax: 1, fx, fy: 0, mag: m });
assert.equal(swayOf({ ax: -1 }), -1, 'keys have no length — a held direction is the whole lane');
assert.equal(swayOf(swipe(TUNE.flickMin)), 0, 'a swipe at the flick threshold aims nothing yet');
assert.equal(swayOf(swipe(TUNE.flickFull)), 1, '...and a committed one takes the whole width');
assert.equal(swayOf(swipe(999)), 1, 'over-swiping cannot aim past the corner');
let prev = 0;
for (let m = TUNE.flickMin; m <= TUNE.flickFull; m += 2) {
const v = swayOf(swipe(m));
assert.ok(v >= prev - 1e-9 && v - prev < 0.05, `the sway climbs smoothly at ${m}px, no cliff`);
prev = v;
}
// ...and a diagonal is a diagonal, not a lane vote: under the old ratio this aimed dead centre
assert.ok(Math.abs(swayOf(swipe(TUNE.flickFull, 0.35)) - 0.35) < 1e-9, 'fx carries the aim');
assert.equal(flatCount(3, swipe(TUNE.flickMin + 4)), 4, 'a swipe too short to bend it counts as flat');
assert.equal(flatCount(3, swipe(TUNE.flickFull)), 0, '...and a real one ends the lesson');
});
test('a gentle flick cannot be played out — only a committed one can', () => {
// Oliver: "the number one reason i die is that i myself keep firing balls into the out." Every
// aimed shot took the FULL lane and then +-aimError on top, so whether it sailed was the dice's
// call at any width the player picked. Width follows the swipe now, so the risk band has an edge
// the player can stop short of — and going out becomes a thing you chose, not a thing rolled.
const line = COURT.W / 2;
const worst = s => s * TUNE.flickWidth * TUNE.flickReach + TUNE.aimError; // widest it can land
assert.ok(worst(0.5) < line, 'half a swipe is in at ANY timing');
assert.ok(worst(1) > line, '...and a full one can still sail: the risk is kept, and it is chosen');
// the threshold render colours the live aim ring with is that same arithmetic, not a magic number
const safe = (line - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end');
});
test('an aimed shot keeps its DIRECTION: scatter may push it wider, never back past centre', () => {
// Dial 1 (2026-08-20). At rushed quality the symmetric scatter outweighed the quality-shrunk
// aim, so a mistimed left flick landed "wherever" — usually centre, read as the ball following
// the opponent. The model mirrors doHit's lane path: outward rolls untouched (the chosen
// out-risk keeps its exact tuned shape), inward rolls keep only aimTrust of themselves.
const land = (s, q, u) => { // u in [-1,1]: the raw scatter roll
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
let e = u * TUNE.aimError * (1 - q);
if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust;
return s * TUNE.flickWidth * reach + e;
};
const line = COURT.W / 2;
for (let q = 0; q <= 1; q += 0.1) {
assert.ok(land(0.5, q, -1) > 0, 'a committed right swipe stays right at q=' + q.toFixed(1)
+ ': ' + land(0.5, q, -1).toFixed(2));
assert.ok(land(-0.5, q, 1) < 0, '...and a committed left swipe stays left, mirrored');
}
assert.ok(land(1, 0, 1) > line, 'the outward tail is untouched: a mistimed full swipe can still sail');
assert.ok(land(1, 1, 1) < line, 'and a perfectly timed one still never does');
// the knob relationship the direction guarantee rests on — retuning must keep it
assert.ok(TUNE.aimTrust * TUNE.aimError < 0.5 * TUNE.flickWidth * TUNE.flickReach,
'aimTrust*aimError must stay under a committed swipe\'s worst-case aim');
});
test('an aimed ball never lands on the WRONG SIDE of you', () => {
// The absolute lane's blind spot: a mistimed left flick's target sits only slightly left of
// CENTRE — which is RIGHT of a player standing in the left box, so the ball visibly flew right
// out of a left flick. (The fully relative lane that fixed this harder was feel-vetoed the same
// day: "way too difficult now, I am dying constantly of out".) Model mirrors doHit's lane path.
const land = (px, s, q, u) => {
const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q;
let e = u * TUNE.aimError * (1 - q);
if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust;
let tx = s * TUNE.flickWidth * reach + e;
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, px));
return s < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
};
for (let q = 0; q <= 1; q += 0.25) for (const u of [-1, 0, 1]) for (const s of [-0.2, -0.6, -1]) {
assert.ok(land(-2, s, q, u) <= -2, 'a left flick from the left box never crosses the player: '
+ land(-2, s, q, u).toFixed(2) + ' at s=' + s + ' q=' + q + ' u=' + u);
assert.ok(land(0, s, q, u) <= 0, '...and from centre it never crosses centre');
}
assert.equal(land(-2, -1, 1, 0), -TUNE.flickWidth, 'a perfect corner flick is untouched by the guard');
assert.equal(land(2, -1, 1, 0), -TUNE.flickWidth, 'crosscourt from the right box keeps its full sweep');
// the guard can never conjure an out: the bound is capped inside the line
assert.ok(TUNE.flickWidth < COURT.W / 2, 'flickWidth stays inside the singles line');
assert.ok(Math.abs(Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, -6.5))) <= TUNE.flickWidth,
'a body parked in the alley clamps to the corner, not past the line');
});
test('the flick aims from the LIVE swipe: contact reads G.swipe, no release needed', () => {
// The swing fires the moment the drag crosses flickMin now (input.js), so at contact the finger
// is usually still down and no released flickAim exists yet. The aim has to come from the live
// vector — without this, every crossing-fired flick would land flat and the latency fix (a left
// flick at the service line released too late to aim anything) would have swung at nothing.
startMatch();
G.modal = null; G.modalQ.length = 0;
const arm = () => { // one rally ball, contact at the window's centre
G.interlude.introT = 0; G.phase = 'rally';
P.x = 0; P.z = 22.8; P.swingT = -1; G.placeMark = null;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = 0; ball.y = 0.7; ball.z = P.z - 4.15;
ball.vx = 0; ball.vy = 2.0; ball.vz = 14; ball.g = TUNE.gravity;
keyPressed('Space'); // what the flickMin crossing fires mid-gesture
for (let i = 0; i < 30 && ball.lastHit !== 'P'; i++) update(DT);
assert.equal(ball.lastHit, 'P', 'contact happened');
return G.placeMark;
};
G.swipe = { ax: -1, az: 0, fx: -1, fy: 0, mag: TUNE.flickFull }; // held LEFT, finger still down
const left = arm();
assert.ok(left && left.x < -2, 'the live swipe aimed it left: x=' + (left ? left.x.toFixed(2) : 'none'));
G.swipe = { ax: 1, az: 0, fx: 1, fy: 0, mag: TUNE.flickFull };
const right = arm();
assert.ok(right && right.x > 2, '...and mirrored right: x=' + (right ? right.x.toFixed(2) : 'none'));
G.swipe = null; // bare tap, no gesture at all
assert.equal(arm(), null, 'no swipe and no released flick: a flat return stamps no mark');
});
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
// The beat exists because the wound stage used to POP between points — an arm stopped being there
// and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears
// the stage it has BECOME, and the point is handed back only when it is standing again.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
const zb = zombies.find(z => z.variant === 'r');
assert.equal(racketStage(zb), 0, 'setup: opponent starts unhurt');
// A quarter off a full pool is 0.75 — still stage 0, so point ONE has no limb to explain. The
// stage crosses on the second point (0.5) and the third (0.25); that progression is the beat.
resolveInterlude('P');
assert.equal(G.matchFall, null, 'point one takes a quarter but crosses nothing');
resolveInterlude('P'); // ...this one takes the arm
assert.ok(G.matchFall, 'a stage-crossing point starts the fall');
assert.equal(G.matchFall.from, 0, 'it collapses as the body it still was');
update(DT);
assert.match(zb.showFrame, /fall1$/, 'first frame is the buckle');
assert.ok(!/w\d/.test(zb.showFrame), 'and it is the UNWOUNDED buckle');
// ...and it must LIE THERE a moment: a corpse that touches the ground and is instantly upright
// reads as a stumble, not as something the point did to it. THE RISE ROW IS GONE (2026-08-13,
// Oliver: "remove the rise frame. the opponent respawns anyway") — the beat ends prone and the
// body takes its mark in the wound skin.
let downFrames = 0;
for (let i = 0; i < (FALL_LEN + 1) * 60 && G.matchFall; i++) {
update(DT); if (/fall3$/.test(zb.showFrame || '')) downFrames++;
}
assert.ok(downFrames > 0.3 * 60, 'it stays down for a beat: ' + (downFrames / 60).toFixed(2) + 's');
assert.equal(G.matchFall, null, 'the fall ends on its own clock');
assert.equal(racketStage(zb), 1, '...and it comes back as the one-armed body');
assert.equal(zb.showFrame, null, 'and hands the sprite back to drawZombie');
assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed');
});
test('the fall beats the ball: a passing winner fells the body while the ball still rolls', () => {
// Oliver: "the zombie falls after the ball is lost although we know this earlier — it could
// play out as ball lost BECAUSE of the falling." A winner that bounced in beyond the body's
// provable reach resolves at bounce ONE: the collapse starts, the ball keeps rolling, and the
// decided-latch makes sure the rally can never bank twice.
startMatch();
G.mode = 'splatter'; // the app's intoSplatter() precedes startMatch()
G.interlude.introT = 0;
const zb = oppOf();
zb.hp = 3; // so THIS point crosses a wound stage (3 -> 2 is the arm)
zb.x = -4; zb.z = 2; // parked far from where the winner lands
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
G.phase = 'rally'; G.wasServe = false;
const pts = score.pts[0];
for (let i = 0; i < 40 && !G.matchFall; i++) update(DT);
assert.ok(G.matchFall, 'the collapse starts at the decided moment, not at the dead ball');
assert.ok(ball.active, '...and the ball is still rolling past the falling body');
assert.equal(score.pts[0], pts + 1, 'the point banked at the decision');
for (let i = 0; i < 5 * 60; i++) update(DT); // the ball dies its ordinary death, the fall plays out
assert.equal(score.pts[0], pts + 1, '...and never banked a second time');
assert.ok(!G.match.decided, 'the latch cleared for the next rally');
});
test('an ordinary far winner keeps its ordinary ending, and the match plays on (the soft-lock)', () => {
// The first shipped version of the early fall latched `decided` AFTER resolveInterlude — and on
// a point with no stage change matchPoint re-arms the next rally synchronously, so the latch
// outlived its rally and swallowed every resolution after it: no falls, no points, no serve,
// ever again. Oliver: "the fall never happens, the game does not continue, the ball is gone."
startMatch();
G.mode = 'splatter';
G.interlude.introT = 0;
const zb = oppOf(); // full pool: this point crosses NO stage
zb.x = -4; zb.z = 2;
const launch = () => {
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
G.phase = 'rally'; G.wasServe = false;
};
launch();
const pts = score.pts[0];
for (let i = 0; i < 5 * 60 && score.pts[0] === pts; i++) update(DT);
assert.equal(G.matchFall, null, 'no stage to show: no early fall, the ordinary ending played');
assert.equal(score.pts[0], pts + 1, 'the point banked once');
assert.ok(!G.match.decided, 'and the latch is not left armed');
launch(); // THE NEXT RALLY MUST STILL RESOLVE — the lock test
for (let i = 0; i < 5 * 60 && score.pts[0] === pts + 1; i++) update(DT);
assert.equal(score.pts[0], pts + 2, 'the match plays on');
});
test('the menus own the theme, and hand the court back its silence', () => {
// Oliver, 2026-08-15: "after some games — or returning to the main menu via the close button —
// the background music does not play any more, but you sometimes still hear the court background
// noise." loopBed no-ops once a file is playing, so menuMusic() could only ever START the theme,
// never bring it BACK from the duck start() put it under — and nothing ducked the court's own
// beds at all. The menu was silent and noisy at once, permanently, from the first run onward.
const d = audio.bedTest.ducked, THEME = 'background-music.m4a',
AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a';
audio.menuMusic(); // on the menu
assert.ok(!d.has(THEME), 'the theme plays on the menu');
audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts
assert.ok(d.has(THEME), 'the theme hands the court over');
assert.ok(!d.has(AMB) && !d.has(ZOM), 'and the court gets its own beds');
audio.menuMusic(); // ...and the run ends, however it ended
assert.ok(!d.has(THEME), 'the theme is BACK — this is the bug');
assert.ok(d.has(AMB) && d.has(ZOM), '...and the court does not follow you into the menu');
audio.ambient(); audio.zombieMusic(); // ...and the NEXT run is not played in silence
assert.ok(!d.has(AMB) && !d.has(ZOM), 'a second run sounds like the first');
// ...but the SPLATTER BED IS THE GORE'S ALONE. Every run of every mode starts the ambience, so
// un-ducking both court beds there put the zombie music back on in classic tennis for the rest of
// the session (Oliver: "after playing the splatter gore, the zombie background sound also keeps
// playing in the normal tennis game"). Leaving the court is one event; arriving is not.
audio.menuMusic(); // back to the menu after a gore run
audio.ambient(); // ...and into a game of plain tennis
assert.ok(!d.has(AMB), 'the ambience belongs to every mode');
assert.ok(d.has(ZOM), 'the splatter music does NOT follow you into classic tennis');
audio.menuMusic();
});
test('SPLATTER GORE opens in the gore — no tennis prologue to win first', () => {
// Oliver, 2026-08-15: "it should not have the normal tennis intro anymore, we should just start
// right in splatter mode." The transition is not deleted (forceZombies still plays it) — what is
// gone is it standing between the menu and the mode.
restartRun();
assert.equal(G.mode, 'tennis', 'a fresh run starts where every run starts');
startGore();
assert.equal(G.mode, 'splatter', 'the mode you picked is the mode you get');
assert.ok(G.everTransitioned, '...and the prologue can never fire behind it');
assert.ok(zombies.some(z => z.variant === 'c'), 'the man you came to play is already one of them');
assert.ok(G.hideO, 'so the tennis opponent is gone from the far court');
assert.equal(score.server, 'P', 'and the serve is yours');
startGore();
assert.equal(zombies.filter(z => z.variant === 'c').length, 1, 'a second call is a no-op, not a second corpse');
});
test('the gore economy: barrels stop being income, and the late court keeps getting worse', () => {
// "in splatter gore it now gets too easy to just play through... maybe we should reduce the
// barrels again — pretty early in the game every won game gives a barrel." At 3/8 the gap hit ONE
// at game 16 and stayed, so the back half of a run paid a weapon per game won. The floor is the
// fix, and it is the property worth pinning: a barrel is a reward, never an income.
const gapAt = g => { G.runGames = g; return barrelGap(); };
for (const g of [0, 5, 12, 20, 40, 120])
assert.ok(gapAt(g) >= 2, `game ${g}: a barrel is never one-per-game (gap ${gapAt(g)})`);
assert.ok(gapAt(40) <= gapAt(0), 'the late game still earns them faster than the early one');
assert.equal(gapAt(400), TUNE.barrelFloor, '...down to the floor and no further');
// ...and the pressure keeps climbing where the barrels stop
const capAt = g => { G.runGames = g; return machCap(); };
assert.equal(capAt(0), TUNE.machMax, 'game one gets the old two guns');
assert.ok(capAt(40) > capAt(0), 'a long run faces more of them');
assert.equal(capAt(400), TUNE.machMaxCap, '...to a ceiling, because the court still has to read');
// the two ramps that used to stop dead around game 25 now run past it
assert.ok(1 + Math.min(TUNE.zSpeedMax, 30 * TUNE.zSpeedPerGame)
> 1 + Math.min(TUNE.zSpeedMax, 22 * TUNE.zSpeedPerGame), 'shamblers keep gaining speed past game 22');
assert.equal(Math.min(TUNE.nastyMax, 40 * TUNE.nastyPerGame), 1,
'and by the end every body that walks out is off the advanced roster');
G.runGames = 0;
});
test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.ok(d.cup, 'the cup rides the match machinery');
for (let round = 0; round < CUP_ROUNDS.length; round++) {
const R = CUP_ROUNDS[round];
assert.equal(d.cup.round, round, 'on ' + R.name);
assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')');
const zb = oppOf();
assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round');
for (let g = 1; g <= R.need; g++) {
for (let k = 0; k < 4; k++) resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away
if (g < R.need)
assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on');
}
// the round-winning game arms the KILL FALL: the body drops first, bursts after the hold
assert.ok(G.matchFall && G.matchFall.kill, R.name + ': the kill fall is armed');
for (let i = 0; i < 4 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill');
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
}
assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds');
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
});
test('THE DRAW: a bracket settled on screen is the bracket that is played', () => {
// The screen would be worse than nothing if it dealt a different tournament from the one it just
// showed you settling (main.buildBracket hands its rungs straight to startMatch). The guard is
// the other half: a rung that is not in the round's band falls back to an honest roll rather
// than dealing THE CHAMPION in round one.
const { CUP_ROUNDS, LADDER } = cfg;
const drawn = CUP_ROUNDS.map(r => r.band[r.band.length - 1]);
_startMatch(true, drawn); G.modal = null; G.modalQ.length = 0;
assert.equal(G.match.opp, drawn[0], 'round one walks on the body the screen landed on');
assert.deepEqual(G.match.cup.draw, drawn, 'and the rest of the draw rides with the run');
endMatch();
// a nonsense draw is refused per round, not trusted
_startMatch(true, [LADDER, LADDER, LADDER, LADDER]); G.modal = null; G.modalQ.length = 0;
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp),
'a rung round one cannot contain is rolled instead of dealt');
endMatch();
// ...and no draw at all is the old behaviour, which the #cup back door still uses
_startMatch(true); G.modal = null; G.modalQ.length = 0;
assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp), 'no draw: the round rolls its own');
endMatch();
});
test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => {
// Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while
// matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the
// no-fall path re-armed synchronously, and the after-set latch went stale ("basically like
// freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the
// resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly.
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) resolveInterlude('P'); // round one falls to the ordinary path
G.modal = null; G.modalQ.length = 0;
passMopBeat();
assert.equal(d.cup.round, 1, 'on the quarter-final');
const zb = oppOf();
const launch = () => {
zb.x = -4; zb.z = 2;
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
G.phase = 'rally'; G.wasServe = false;
const before = score.pts[0] + score.games[0] * 10;
for (let i = 0; i < 8 * 60 && score.pts[0] + score.games[0] * 10 === before && !G.matchFall; i++) update(DT);
for (let i = 0; i < 6 * 60 && (G.matchFall || G.modal); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
};
launch(); // 8 -> 7: crosses nothing (the old mispredict)
assert.equal(zb.hp, 7, 'point one chipped the pool');
assert.ok(!d.decided, 'no stale latch after a no-crossing point');
launch(); // 7 -> 6: still nothing
launch(); // 6 -> 5: the arm comes off, mid-flight
assert.ok(!d.decided, 'the fall beat ended and released its latch');
launch(); // the game point, mid-round: same body plays on
assert.ok(zombies.includes(zb), 'game one banked, the quarter-final body stands');
assert.equal(score.games[0], 2, 'the game went up');
launch(); // game two opens: the match is ALIVE
assert.equal(score.pts[0], 1, 'the next rally resolved — no freeze');
});
test('service alternates by game, and the opponent actually serves', () => {
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
// timing is half the sport and it was never asked for.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
assert.equal(score.server, 'P', 'you open the match');
for (let pt = 0; pt < 4; pt++) { // four straight points is the game
resolveInterlude('P');
for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish
}
assert.equal(score.server, 'O', 'their serve next game');
passMopBeat(); // the mop beat (and the boy's exit) come first
// the opponent stands behind ITS baseline to serve, not on the receiving mark
const rz = zombies.find(z => z.variant === 'r');
assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));
// ...and it tosses and strikes on its own, once the walk-on beat is over
let served = false;
for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; }
assert.ok(served, 'the opponent serves without the player touching anything');
});
test('a lost point never fells the opponent', () => {
// The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a
// collapse there would read as the opponent being hurt by winning.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
const hp = G.hp;
resolveInterlude('O');
assert.equal(G.matchFall, null, 'their point starts no fall');
assert.equal(G.hp, hp - 1, '...it draws your blood instead');
});
test('the placement mark fades on its own clock', () => {
// The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the
// state machine owes is that a mark never lingers — a permanent ring would read as a target the
// player must hit rather than feedback on where the last shot went.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
G.placeMark = { x: 2, z: 4, t: 0.8 };
for (let i = 0; i < 60; i++) update(DT);
assert.equal(G.placeMark, null, 'gone within a second');
});
test('REACHABILITY: an auto-run player can cover the first opponent\'s returns', () => {
// The test this issue always deserved and never had — "unreachable balls" came back TWICE because
// the suite only asserted the config dials, never the experience. This plays the match headless
// with the same mediocre autopilot the browser uses (auto-run + a fixed tap window, no placement)
// and asserts that the returns actually present themselves: a ball that never comes within volley
// reach of an auto-run player is unreachable BY CONSTRUCTION, whatever the knobs claim.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const returns = [];
let cur = null;
for (let i = 0; i < 90 * 60 && G.mode !== 'eaten'; i++) {
G.modal = null; G.modalQ.length = 0;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!cur) cur = { minD: 99 };
cur.minD = Math.min(cur.minD, d);
if (d < 2.9 && d > 2.2) keyPressed('Space');
}
if (cur && (!ball.active || ball.lastHit !== 'O')) { returns.push(cur.minD); cur = null; }
update(DT);
}
assert.ok(returns.length >= 10, 'the soak saw a real rally sample: ' + returns.length + ' returns');
const unreachable = returns.filter(d => d > 2.6).length; // never inside volley reach
assert.ok(unreachable / returns.length <= 0.15,
unreachable + ' of ' + returns.length + ' returns never came within reach of an auto-run player');
} finally { TUNE.autoAssist = assist; }
});
test('a served ball with a slide on it is no longer a free kill', () => {
// Oliver: "the moment you serve you can serve hard with a slight slide and almost all of them
// fail to get the ball" — the first ten opponents died to one gesture. Measured before the fix:
// a flush, hard, swayed serve was returned 0 times out of 24 at EVERY rung of the ladder, because
// the receiver waited on the tennis mark (1.3 m off the centre line, covering the T and conceding
// the whole corner) and then chased the ball's LIVE position, which during a serve is still up by
// the server's baseline. Mark, first step and read are all dialled in config; this asserts the
// EXPERIENCE, not the dials, exactly like the reachability soak above.
// A serve into the very corner of the box is deliberately still an ace at the bottom of the
// ladder — the trick that had to die is the one that needed no placement.
const serve = sx => {
freshSplatter();
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
ball.active = true; ball.collected = false; ball.bounces = 0; ball.scored = false;
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.6; ball.vx = ball.vy = ball.vz = 0;
G.wasServe = true;
hitServe(P, 1, { sx, hard: 1 }); // flush timing, full pace, `sx` of slide
for (let i = 0; i < 5 * 60; i++) {
update(DT);
if (ball.lastHit === 'O') return true; // he got a racket on it
if (!ball.active || G.phase !== 'rally') break;
}
return false;
};
// n = 40, NOT 10, and this is the arithmetic to copy for any other rate gate here. The real return
// rate is ~0.88 (measured, 200 trials a seed across eight seeds: 0.85..0.92), so a 10-sample gate
// asking for 6 fails whenever the tail lands — p ~= 0.004, which is one seed in 230 and duly showed
// up as SEED=80. At 40 the same 60% bar sits five standard deviations below the true rate: the gate
// still fails instantly if the receiver regresses toward the 0-in-24 this test was born from, and
// it costs nothing (a returned serve breaks the frame loop early).
let got = 0;
const n = 40;
for (let i = 0; i < n; i++) if (serve(-0.5)) got++;
assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`);
});
test('the match racket is longer, and only the match racket', () => {
assert.ok(TUNE.matchReach > 1.2, 'match contact reach is widened — the recorder showed misses by centimetres');
// the gore modes' contact stays untouched: matchReach only multiplies inside G.match
});
test('the ladder pace dial exists and starts below full pace', () => {
assert.ok(TUNE.matchOppPace < 1, 'opponent one returns slower than the physics maximum');
assert.ok(TUNE.matchOppPacePer > 0, 'and earns pace back per rung');
});
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
// line (COURT.L + 0.3), so the moment the toss went up the game scored "its passing shot ends the
// duel": a point and blood per TAP. serveNum was a red herring; this was the loop Oliver hit twice.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT); // ride out the intro beat — it blocks serving
const hp0 = G.hp, pts0 = score.pts.slice();
ball.lastHit = 'O'; ball.active = false; // the zombie touched the last rally ball
assert.ok(P.z > COURT.L + 0.3, 'setup: the server stands behind the passing-shot line');
keyPressed('Space'); // the toss goes up
assert.equal(G.phase, 'toss', 'the toss is up');
for (let i = 0; i < 40; i++) update(DT); // two thirds of a second of flight
assert.equal(G.hp, hp0, 'no blood for tossing a ball');
assert.deepEqual(score.pts, pts0, 'and no point changes hands');
assert.equal(ball.lastHit, 'P', 'the toss is the server\'s ball');
});
test('every point starts with the receiver on its mark', () => {
startMatch();
const rz = oppOf();
rz.x = 6; rz.z = 12; // stranded mid-court by the last rally
resolveInterlude('P');
assert.ok(Math.abs(rz.x) < 3 && rz.z <= 1.5,
'reset to the receiver spot, not wherever the rally left it: x=' + rz.x.toFixed(1) + ' z=' + rz.z.toFixed(1));
assert.equal(rz.x, O.x, 'on the exact mark the tennis branch computed for the receiver');
});
test('the ladder escalates in speed and reliability, not in hp', () => {
// pure arithmetic on the knobs racketAI reads — pinned so a future tuning pass cannot silently
// flatten the ladder again
assert.ok(TUNE.matchSpeedBase > 1, 'opponent one is already quicker than the duel lumber');
assert.ok(TUNE.matchSpeedPer > 0, 'each opponent is faster');
assert.ok(TUNE.matchFlub < TUNE.racketFlub, 'the match whiffs less than the duel from the start');
assert.ok(TUNE.matchFlubKeep < 1, 'and less again per rung');
});
test('a game won heals, so a clean player can go the distance', () => {
startMatch();
G.hp = 1;
for (let k = 0; k < 4; k++) resolveInterlude('P');
for (let i = 0; i < 5 * 60 && G.matchFall; i++) update(DT); // the heal waits for the burst now
assert.equal(G.hp, Math.min(G.hpMax, 1 + TUNE.matchHeal), 'the game paid its heal');
});
test('regicide: killing the body mid-game wins the game outright', () => {
startMatch();
const zb = oppOf();
resolveInterlude('P'); // 15-0, so the score is mid-game
const games = score.games[0];
zombies.length = 0; // the body is destroyed by a shot, not by the score
resolveInterlude('P'); // killZombie's exit lands here with the court empty
assert.equal(score.games[0], games + 1, 'the game banks');
assert.deepEqual(score.pts, [0, 0], 'the part-played points reset');
passMopBeat();
assert.ok(oppOf(), 'and the next opponent is on');
});
test('the match measures OPPONENTS FELLED, banked as its own kind of run', () => {
localStorage.removeItem('splatterRuns');
startMatch();
for (let g = 0; g < 2; g++) { // two games = two bodies down
for (let k = 0; k < 4; k++) resolveInterlude('P');
passMopBeat(); // each corpse gets its mop beat
}
assert.equal(G.match.opp, 3, 'on the third rung');
G.hp = 1; resolveInterlude('O');
assert.equal(G.mode, 'eaten', 'bled out');
assert.equal(G.match.felled, 2, 'the summary counts bodies, not games');
assert.ok(G.newBest, 'first run of the day is a record');
// a shorter second run must NOT overwrite the mark, and must know what it is chasing
G.mode = 'splatter'; endMatch(); startMatch();
for (let k = 0; k < 4; k++) resolveInterlude('P');
passMopBeat(); // no points settle while the boy owns the court
G.hp = 1; resolveInterlude('O');
assert.equal(G.match.felled, 1);
assert.equal(G.match.best, 2, 'the mark to beat is shown');
assert.ok(!G.newBest, 'and one body does not beat two');
// ...and none of it lands in the gore mode's stats, which is where it all used to go: a felled
// opponent is a kill credited to 'bomb', and three of them are not a massacre.
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
assert.ok(runs.every(r => r.m === 'z'), 'both runs are tagged as match runs');
assert.equal(readStats('all').kills, 0, 'the gore screen shows nothing from a match');
assert.equal(readStats('all', undefined, 'z').longest, 2, '...and the match view knows the best');
G.mode = 'splatter'; endMatch();
});
// ---- classic tennis: the mode where nothing turns ---------------------------------------------
test('classic tennis: a game is not a set, and six with a two-game lead is', () => {
G.mode = 'tennis'; G.classic = true; G.everTransitioned = false;
score.games = [5, 4];
assert.equal(classicGame('P'), false, '5-4 is not a set — advancePoint has not banked the sixth yet');
score.games = [6, 5];
assert.equal(classicGame('P'), false, 'nor is 6-5: the two-game lead is the rule');
assert.deepEqual(G.sets, [0, 0]);
score.games = [7, 5];
assert.equal(classicGame('P'), false, '7-5 IS a set, but not the match');
assert.deepEqual(G.sets, [1, 0], 'the set is banked');
assert.deepEqual(score.games, [0, 0], 'and the games reset under it');
assert.equal(G.done, null, 'one set is not a match');
});
test('the toss strike has stakes: flush is fast, a miss is slow, the old free window is gone', () => {
// the serve-agency pass: apexSnap/apexFall are TUNE knobs and deliberately tight, so timing
// must show up in the ball's actual pace (the fault economy then prices the aimed serve)
G.mode = 'tennis'; G.classic = true; G.match = null; G.interlude = false; G.done = null;
const strike = dy => {
G.phase = 'pretoss'; ball.active = false; score.server = 'P'; P.pose = 'idle';
keyPressed('Space'); // toss
assert.equal(G.phase, 'toss', 'the tap tossed');
ball.y = serveApexY() - dy; ball.vy = 0.1; // park the ball dy below the apex
keyPressed('Space'); // strike
assert.equal(G.phase, 'rally', 'the strike fired');
return Math.hypot(ball.vx, ball.vy, ball.vz);
};
const flush = strike(0);
const oldFree = strike(0.1); // inside the old 0.12 snap, outside the new one
const missed = strike(0.4);
assert.ok(flush > missed * 1.2, `a flush strike is materially faster (${flush.toFixed(1)} vs ${missed.toFixed(1)})`);
assert.ok(oldFree < flush - 0.5, 'the 0.12 m free-perfect window no longer exists');
});
test('classic tennis: the second set ends the run, with no corpse', () => {
G.mode = 'tennis'; G.classic = true;
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14; G.classicWon = 12;
assert.equal(classicGame('P'), true, 'the match is over');
assert.ok(G.done && G.done.won, 'and you won it');
assert.notEqual(G.mode, 'eaten', 'nobody died — this is the other ending');
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
const r = runs[runs.length - 1];
assert.equal(r.m, 'c', 'banked as a classic run');
assert.equal(r.dead, 0, 'a match WON is not a death — that is how wins are counted');
// GAMES YOU WON, not sets: it is what classic's world board ranks, because every win is two sets
// and a board of nothing but 2s is a wall of ties. Nothing ever displayed the old value.
assert.equal(r.s, 12, 'games won ride in the second headline slot');
assert.equal(statsMod.BOARDS.c.score(r), 12, "...and that is the classic board's number");
});
test('classic tennis: losing the match ends it the same way, and counts as a loss', () => {
G.mode = 'tennis'; G.classic = true;
G.sets = [0, 1]; score.games = [0, 6]; G.runGames = 12;
assert.equal(classicGame('O'), true);
assert.ok(G.done && !G.done.won, 'beaten');
const runs = JSON.parse(localStorage.getItem('splatterRuns'));
assert.equal(runs[runs.length - 1].dead, 1, 'a match lost IS the run ending badly');
});
test('classic wears the match feel, and the difficulty table is a ladder', () => {
// THE RALLY MODES SHARE ONE PLAYER FEEL: classic gets the match's assist floor (with matchReach
// and the short-ball chase riding the same rallyFeel() switch) — "in classic tennis I tend to
// miss balls way more often" was these dials being match-only.
G.classic = false; G.match = null;
assert.ok(!rallyFeel(), 'gore alone keeps the old feel');
G.classic = true;
assert.ok(rallyFeel(), 'classic is a rally mode');
assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor');
G.classic = false;
// ...and DIFFS climbs monotonically on every dial. MEDIUM used to be pinned dial-for-dial to the
// shipped TUNE values; that pin is gone (Oliver, 2026-08-15: "medium is too easy") — the middle
// chip is now its own tuning, above what "no difficulty" played like, and HARD stepped back.
// (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach,
// qMin and serve are the dials a player actually reads as skill.)
const [e, m, h] = cfg.DIFFS;
assert.ok(e.flub > m.flub && m.flub > h.flub, 'easier flubs more');
assert.ok(e.quality < m.quality && m.quality < h.quality, 'harder hits better');
assert.ok(e.speed < m.speed && m.speed < h.speed, 'harder runs faster');
assert.ok(e.reaction > m.reaction && m.reaction > h.reaction, 'harder reads the shot sooner');
assert.ok(e.reach < m.reach && m.reach < h.reach, 'harder swings a longer racket');
assert.ok(e.qMin <= m.qMin && m.qMin < h.qMin, 'harder raises the quality floor');
assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better');
assert.ok(e.smart <= m.smart && m.smart < h.smart, 'harder plays the open court');
assert.ok(e.recover <= m.recover && m.recover < h.recover, 'harder recovers to centre faster');
// EASY is a step below the shipped no-difficulty CPU, MEDIUM a step above it — that ordering is
// the point of the retune, so it stays pinned even though the exact numbers are free to move.
assert.ok(e.flub > TUNE.classicFlub && m.flub < TUNE.classicFlub, 'EASY misses more than shipped, MEDIUM less');
assert.ok(e.reaction > TUNE.aiReaction && m.reaction < TUNE.aiReaction, 'MEDIUM reads faster than shipped');
assert.ok(e.reach < TUNE.aiReach && m.reach > TUNE.aiReach, 'MEDIUM out-reaches shipped');
});
// ---- NETPLAY (src/net.js + beats.startNetVs). The shot is the sync unit and THE RECEIVER OF A
// FLIGHT IS ITS ONLY JUDGE — these pin both halves of that rule, with netTest.sent standing in
// for the socket so the wire traffic is a plain array the assertions can read.
function freshNet(side) {
restartRun();
G.classic = true; // netplay rides the classic tennis machinery
assert.ok(startNetVs(), 'net vs takes a clean court');
netTest.sent = [];
netMsg({ t: 'start', side }); // paired: 'A' hosts and serves first
}
test('netplay: the vs match owns the court both ways', () => {
assert.ok(_startMatch(), 'setup: the match takes the court');
assert.ok(!startNetVs(), 'netplay refuses while the match owns it');
endMatch();
assert.ok(startNetVs(), 'and takes it once the match is gone');
assert.ok(!_startMatch(), 'the match refuses while netplay owns the court');
assert.ok(!startDrill(), 'the drill refuses too');
startDuel();
assert.equal(G.interlude, false, 'and no duel can begin');
endNetVs();
assert.equal(G.net, null, 'endNetVs hands the court back');
});
test('netplay: the mirror is a half-turn about the net and its own inverse', () => {
const s = { x: 1.3, y: 2.2, z: 24.3, vx: -3.2, vy: 4.1, vz: -28 };
const m = mirror(s);
assert.ok(s.z > COURT.netZ && m.z < COURT.netZ, 'a near-baseline strike lands on the far half');
assert.ok(m.vz > 0, 'travelling away becomes travelling toward');
const rt = mirror(m);
for (const k of ['x', 'y', 'z', 'vx', 'vy', 'vz'])
assert.ok(Math.abs(rt[k] - s[k]) < 1e-9, `two mirrors are the identity (${k})`);
});
test('netplay: a remote serve arrives mirrored and arms the receiver as its judge', () => {
freshNet('B'); // they host, they serve
assert.equal(score.server, 'O', 'the host serves first on both scoreboards');
// their serve event, in THEIR coordinates: struck from behind their near baseline
netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true });
assert.ok(ball.active && ball.lastHit === 'O', 'their ball, live on this court');
assert.ok(ball.z < COURT.netZ, 'launched from the far half');
assert.ok(ball.vz > 0, 'and travelling toward the receiver');
assert.equal(G.phase, 'rally');
assert.ok(G.wasServe, 'the receiver\'s own service-box machinery is armed');
});
test('netplay: the receiver judges the flight — an untouched winner scores and goes over the wire', () => {
freshNet('B');
// a legal rally ball from their side (their frame): lands mid-court, never swung at
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
for (let i = 0; i < 900 && ball.active; i++) update(DT);
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
assert.deepEqual(score.pts, [0, 1], 'their untouched winner is their point');
const pt = netTest.sent.find(m => m.t === 'pt');
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone, winner relative to sender');
});
test('netplay: an outbound ball is picture — this phone never judges its own shot', () => {
freshNet('A');
G.phase = 'rally';
ball.active = true; ball.collected = false; ball.bounces = 0;
ball.x = 0; ball.y = 1.5; ball.z = 20; ball.vx = 8; ball.vy = 3; ball.vz = -16; ball.g = 9.2;
ball.lastHit = 'P'; G.wasServe = false; // a rally ball flying well OUT — 8 m/s of sideways
const pts = JSON.stringify(score.pts);
for (let i = 0; i < 240; i++) update(DT);
assert.equal(JSON.stringify(score.pts), pts, 'no local verdict on an outbound flight');
assert.ok(!netTest.sent.some(m => m.t === 'pt' || m.t === 'fault'), 'and none was sent');
netMsg({ t: 'pt', win: 'me', reason: 'out' }); // their call arrives instead
assert.deepEqual(score.pts, [0, 1], 'their call, this scoreboard');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
});
test('netplay: the toss travels — the receiver watches the serve coming', () => {
freshNet('A'); // this phone serves: its toss must go over the wire
keyPressed('Space');
assert.equal(G.phase, 'toss', 'the tap tossed');
assert.ok(netTest.sent.some(m => m.t === 'toss'), 'the toss is an event');
freshNet('B'); // ...and on the receiving phone
netMsg({ t: 'toss' });
assert.equal(G.phase, 'toss', 'their toss is a live toss here');
assert.ok(ball.active && ball.lastHit === 'O' && ball.vy > 0, 'a real ball rising over O');
assert.ok(ball.z < COURT.netZ, 'on the far half');
for (let i = 0; i < 300 && G.phase === 'toss'; i++) update(DT); // nobody strikes it
assert.equal(G.phase, 'pretoss', 'an uncaught remote toss falls back by plain physics');
netMsg({ t: 'toss' }); // the re-toss, then the strike over it
netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true });
assert.equal(G.phase, 'rally', 'the strike lands cleanly over the replayed toss');
assert.ok(ball.vz > 0, 'and the serve is inbound');
});
test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s second serve', () => {
freshNet('B'); // they serve — long, out of the box
netMsg({ t: 'shot', x: 0.5, y: 2.6, z: 24.3, vx: 0, vy: 1, vz: -35, g: 9.2, pow: 1, serve: true });
for (let i = 0; i < 300 && ball.active; i++) update(DT);
assert.equal(G.serveNum, 2, 'the receiver\'s copy of the point advances to second serve');
assert.ok(netTest.sent.some(m => m.t === 'fault'), 'the fault went to their phone');
assert.deepEqual(score.pts, [0, 0], 'a first fault is not a point');
freshNet('A'); // ...and on the server phone the same message lands
G.serveNum = 1; G.phase = 'rally'; ball.active = true; ball.lastHit = 'P'; G.wasServe = true;
netMsg({ t: 'fault' });
assert.equal(G.serveNum, 2, 'their call re-armed the second serve');
// THE CALLED BALL PLAYS OUT (2026-08-15). It used to be switched off where it stood — which on
// the striker's phone is a whole court and a latency away from the call, and read as the game
// loop stopping. It is `scored` (nothing it does can move the scoreboard again) and still LIVE.
assert.ok(ball.scored, 'the call is made: this flight can never score again');
assert.ok(ball.active, '...but the ball keeps rolling instead of freezing where the call caught it');
assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back');
const pts = score.pts.slice(), sn = G.serveNum;
for (let i = 0; i < 600 && ball.active; i++) update(DT);
assert.ok(!ball.active, 'and it comes to a natural halt by itself');
assert.deepEqual(score.pts, pts, 'the roll scored nothing on the way');
assert.equal(G.serveNum, sn, '...and faulted nothing either');
});
test('netplay: the host\'s court rides the room, so both phones play the same surface', () => {
const { court, selectCourt } = state;
selectCourt('grass');
freshNet('B'); // ...and the start message says otherwise
netMsg({ t: 'start', side: 'B', court: 'clay' });
assert.equal(state.court.id, 'clay', 'the guest is moved onto the host\'s court');
assert.equal(TUNE.bounceXZ, cfg.COURTS.find(c => c.id === 'clay').bounceXZ,
'...and onto its physics, not just its picture');
netMsg({ t: 'start', side: 'B' }); // an old build sends no court: keep what we have
assert.equal(state.court.id, 'clay', 'a start without a court changes nothing');
selectCourt('grass');
});
test('netplay: the scoreboards reconcile, so one lost verdict cannot lock the court', () => {
// THE BUG (Oliver, over cellular): "both players stand on the same side and nobody can serve."
// setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so two sims
// a point apart place both players on the same half and each waits for the other. The host's
// pretoss stream carries its scoreboard; the guest adopts it, mirrored.
freshNet('B'); // we are the guest: the host is the authority
score.pts = [2, 1]; score.server = 'P'; // ...and we have drifted a point away from it
const myX = P.x;
netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [2, 3] }); // their P serves, their 2 to our 3
assert.equal(score.server, 'O', 'the server is taken from the host, mirrored');
assert.deepEqual(score.pts, [3, 2], '...and so are the points');
assert.notEqual(P.x, myX, 'the point is re-armed, so both bodies stand where the new score puts them');
// an ordinary position frame carries no scoreboard and must never touch one
score.pts = [1, 1];
netMsg({ t: 'pos', x: 0, z: 1, m: 0 });
assert.deepEqual(score.pts, [1, 1], 'a plain pos frame is picture only');
// ...and a live rally is never reconciled underneath itself
G.phase = 'rally';
netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [3, 3] });
assert.deepEqual(score.pts, [1, 1], 'mid-rally the scoreboard is left alone');
// only the HOST publishes it: two authorities is the drift, not the cure
freshNet('B'); netTest.sent = []; G.phase = 'pretoss';
netTick(0.2);
assert.ok(netTest.sent.some(m => m.t === 'pos'), 'the guest still streams its position');
assert.ok(!netTest.sent.some(m => m.srv), '...but never a scoreboard');
freshNet('A'); netTest.sent = []; G.phase = 'pretoss';
netTick(0.2);
assert.ok(netTest.sent.some(m => m.t === 'pos' && m.srv), 'the host publishes its scoreboard');
});
// ---- NETPLAY DEATH MATCH: the host's pick rides the room's start message, and the match
// machinery is driven by the same receiver-is-judge verdicts classic uses — routed through
// resolveInterlude instead of pointTo, so wounds, blood, the fall and the burst all come along.
function freshNetDeath(side) {
restartRun();
G.classic = true; // the waiting court, exactly as the menu builds it
assert.ok(startNetVs(), 'net vs takes a clean court');
netTest.sent = [];
netMsg({ t: 'start', side, death: true });
}
test('netplay death match: the host\'s pick rebuilds the court on pairing — friend, not roster body', () => {
freshNetDeath('B');
assert.ok(G.net && G.net.ready && G.net.death, 'the wire survives the mode swap');
assert.equal(G.mode, 'splatter');
assert.ok(G.match && G.interlude, 'the match machinery owns the court');
assert.ok(!zombies.some(z => z.variant === 'r'), 'NO roster body — the friend IS the opponent');
assert.equal(G.hideO, false, 'the living opponent is the picture');
assert.equal(G.card, null, 'and no SHAMBLER walk-on card');
assert.equal(score.server, 'O', 'the host serves first here too');
assert.equal(G.modal, null, 'no popup may eat the first remote serve');
});
test('netplay death match: THE BURST IS THE DEATH — a won game is only a game', () => {
// Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner
// already at 0:40 — the winner sees the zombie explode but the player on the other side is not
// dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight.
freshNetDeath('A');
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
assert.equal(score.pts[0], 1, 'the point banks');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
const blood = G.hp;
netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me
assert.equal(G.hp, blood - 1, 'their point draws my blood');
// THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game
const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length;
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... )
O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says');
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 });
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.ok(onHim() > 0, '...and one a point from death bleeds hard');
score.pts = [3, 0];
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
assert.equal(score.games[0], 1, 'game banked');
assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing');
assert.ok(!G.done, '...and the match is not over');
for (let i = 0; i < 60 * 6; i++) update(DT);
assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on');
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again');
});
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
freshNetDeath('B');
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
const blood = G.hp;
for (let i = 0; i < 900 && ball.active; i++) update(DT);
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
assert.equal(G.hp, blood - 1, 'their untouched winner draws my blood');
const pt = netTest.sent.find(m => m.t === 'pt');
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone');
});
test('netplay death match: bleeding out is said on the wire, and the other phone wins on it', () => {
freshNetDeath('B');
G.hp = 1;
netMsg({ t: 'pt', win: 'me' }); // the point that empties me
assert.equal(G.mode, 'eaten', 'my run ends as every match death does');
assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud');
freshNetDeath('A'); // ...and the winner's phone, receiving it
score.games[0] = 3;
const before = parts.length;
netMsg({ t: 'dead' });
assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart');
assert.ok(parts.length > before, '...and the gore is real');
assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug');
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst');
assert.equal(G.match.felled, 3, 'felled = the games won on the way');
});
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {
assert.ok(_startMatch(), 'solo match on a clean court');
G.modal = null; G.modalQ.length = 0;
const rz = zombies.find(z => z.variant === 'r');
G.matchFall = { t: 0.3, from: 1, vx: 0, x0: rz.x, kill: true }; // landed, prone, pre-burst
const before = parts.length;
for (let i = 0; i < 20; i++) update(DT);
assert.ok(parts.length > before, 'the prone hold gushes (gushProne), where bleed() is suppressed');
});
// stats.js ships its own fixture-based check — the period filter, the mode split and the name
// normalisation that has to agree with server.py. It was exported and never called by anything.
test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });
// ---- THE PLAYER'S COLOURS. The feature's one real claim is that the kit can move without dragging
// the skin with it, and the whole claim lives in tintPx. These are the colours MEASURED off
// p_idle1.png (the four biggest opaque populations), not invented ones — if the art is ever redrawn
// into a palette these buckets do not separate, this is the test that says so.
const { tintPx } = await import('./src/render.js');
const SHORTS = [22, 120, 186], SHORTS_DK = [0, 91, 159]; // kit blue, lit and in shadow
const SKINPX = [214, 149, 111], SHIRT = [214, 213, 200]; // forearm, and the shirt white
const dom = ([r, g, b]) => r > g && r > b ? 'r' : g > b ? 'g' : 'b';
test('kit and skin are separate buckets: recolouring one leaves the other alone', () => {
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
assert.equal(asDrawn.h, undefined, 'SKINS[0] is the "as drawn" entry');
// the kit moves, in the light AND in the shadow — a bucket that only catches the lit ramp step
// leaves the folds behind in the old colour, which is the failure that would actually be visible
assert.equal(dom(tintPx(...SHORTS, red, asDrawn)), 'r', 'lit shorts turn red');
assert.equal(dom(tintPx(...SHORTS_DK, red, asDrawn)), 'r', 'shadowed shorts turn red too');
// ...and the skin does not, which is the question the whole design turns on
assert.equal(tintPx(...SKINPX, red, asDrawn), null, 'skin is untouched by a kit change');
assert.equal(tintPx(...SHIRT, red, asDrawn), null, 'the shirt white is in neither bucket');
});
test('skin recolours by lightness, and stays a skin tone rather than rotating to green', () => {
const asDrawn = cfg.KITS[0], deep = cfg.SKINS.find(s => s.id === 'deep');
const out = tintPx(...SKINPX, asDrawn, deep);
assert.equal(dom(out), 'r', 'a darker tone is still red-dominant, not hue-rotated off the band');
assert.ok(out[0] < SKINPX[0], 'DEEP is genuinely darker than the drawn tan');
assert.equal(tintPx(...SHORTS, asDrawn, deep), null, 'the kit is untouched by a skin change');
});
test('the shading survives the swap: the ramp keeps its order and its spread', () => {
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
const lit = tintPx(...SHORTS, red, asDrawn), dk = tintPx(...SHORTS_DK, red, asDrawn);
const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
assert.ok(lum(lit) > lum(dk), 'the lit step stays lighter than the shadowed one');
// hue rotation keeps lightness, so the gap between the two ramp steps must survive within rounding
assert.ok(Math.abs((lum(lit) - lum(dk)) - (lum(SHORTS) - lum(SHORTS_DK))) < 12,
'the ramp keeps its spread — a flattened one would read as a painted-on patch');
});
// THE TWO NEIGHBOURS THE SKIN BUCKET MUST NOT EAT. Both of these shipped broken once — the shirt
// came out mud-spattered, and then the death frames' blood came out brown, which on this game is
// the worst bug in the file. Colours measured off p_run1/p_smash (the shirt) and p_die4 (the pool).
const SHIRT_WARM = [243, 236, 224], SHIRT_W2 = [240, 232, 224]; // warm HIGHLIGHTS, not neutral
const BLOOD = [150, 20, 25], POOL = [170, 15, 20], BLOOD_DK = [92, 10, 14];
test('the shirt stays white — its warm highlights are not skin', () => {
for (const sk of cfg.SKINS.slice(1)) {
assert.equal(tintPx(...SHIRT_WARM, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
assert.equal(tintPx(...SHIRT_W2, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
}
});
test('BLOOD STAYS BLOOD — no skin pick may turn the death frames to mud', () => {
for (const sk of cfg.SKINS.slice(1)) {
for (const [nm, c] of [['spray', BLOOD], ['pool', POOL], ['dried', BLOOD_DK]])
assert.equal(tintPx(...c, cfg.KITS[0], sk), null, `${sk.id} leaves the ${nm} red`);
}
// ...and it survives a kit change too, which is the other half of the same claim
for (const k of cfg.KITS.slice(1))
assert.equal(tintPx(...POOL, k, cfg.SKINS[0]), null, `${k.id} leaves the pool red`);
});
// A WHITE KIT IS WASHED, NOT DYED. Rotating a near-neutral does nothing, and the only saturated
// blue on her sheets is HER HAIR — same hue as his shorts, so a dye turned it green. Colours
// measured off p_idle1_f.
const HER_SKIRT = [208, 216, 224], HER_BRIGHT = [240, 248, 248]; // cool-cast whites
const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220
test('a white kit takes a wash: her whites colour, her hair and skin do not', () => {
assert.equal(cfg.TOURS.find(t => t.id === 'women').kit, 'wash');
assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, 'dye');
const red = cfg.KITS.find(k => k.id === 'red');
for (const c of [HER_SKIRT, HER_BRIGHT]) {
const out = tintPx(...c, red, cfg.SKINS[0], 'wash');
assert.ok(out, 'her whites take the colour');
assert.equal(dom(out), 'r', 'CLARET reads as rose on her');
// a WASH, not a dye: it must stay pale, or she is wearing a block of poster paint
assert.ok(Math.min(...out) > 150, `${c} stays a pastel, not a solid (${out})`);
}
// the trap this whole mode exists to avoid, pinned from both sides
assert.ok(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'dye'), 'her hair IS in the dye arc — the trap');
assert.equal(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'wash'), null, 'a wash cannot reach it');
assert.equal(tintPx(...SKINPX, red, cfg.SKINS[0], 'wash'), null, 'nor her skin');
});
test('the wash leaves the shading: her folds stay in order', () => {
const red = cfg.KITS.find(k => k.id === 'red');
const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
const lit = tintPx(...HER_BRIGHT, red, cfg.SKINS[0], 'wash');
const shade = tintPx(...HER_SKIRT, red, cfg.SKINS[0], 'wash');
assert.ok(lum(lit) > lum(shade), 'the lit fold stays lighter than the shaded one');
});
// NETPLAY: the friend across the net wears their own pick, on the friend's OWN sheets — n_*, the
// player's kit drawn from the front (assets/_frontroll.py). The claim this pins is that he needs no
// arc of his own any more: every colour below is measured off n_idle1.png / n_idle1_f.png, and they
// fall into the SAME two buckets the player's back-view sheets do.
//
// The old netplay art was the roster opponent's red, on a third arc (`TINT.oKit`, 345->15). Both
// halves of why that had to go are pinned here: on his sheet the only thing in that arc was the
// SHIRT, so his pick moved a different garment than yours; on hers the arc also held her skin.
test('the friend across the net rides the PLAYER arcs: his shorts dye, her whites wash, skin holds', () => {
const HIS_SHORTS = [24, 112, 176], HIS_SHIRT = [224, 216, 208], HIS_SKIN = [232, 136, 104];
const HER_KIT = [240, 240, 248], HER_SKIN = [240, 168, 136], HIS_SHOE = [176, 176, 184];
const green = cfg.KITS.find(k => k.id === 'green');
const out = tintPx(...HIS_SHORTS, green, cfg.SKINS[0], 'dye', cfg.TINT.kit);
assert.ok(out && dom(out) === 'g', 'his blue shorts take the pick — the same garment yours moves');
assert.equal(tintPx(...HIS_SHIRT, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
'his white shirt stays white on a dye tour');
assert.equal(tintPx(...HIS_SHOE, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
'and his shoe grey is under the kit floor, blue-ish hue or not');
assert.ok(dom(tintPx(...HER_KIT, green, cfg.SKINS[0], 'wash')) === 'g',
'her whole white kit takes the wash — top and skirt together, which is the fix');
for (const [nm, c] of [['his', HIS_SKIN], ['hers', HER_SKIN]])
assert.equal(tintPx(...c, green, cfg.SKINS[0], nm === 'his' ? 'dye' : 'wash', cfg.TINT.kit), null,
`${nm} skin is left alone — the old red arc is what used to claim it`);
});
// The look crosses the wire ONCE, on pairing — neither phone can change its pick mid-match, because
// the setup screen is only reachable between runs.
test('netplay: your look goes over the wire on pairing, and theirs comes back', () => {
state.selectKit('green'); state.selectSkin('deep');
freshNet('A');
const look = netTest.sent.find(m => m.t === 'look');
assert.ok(look, 'pairing announces this phone\'s look');
assert.equal(look.k, 'green'); assert.equal(look.s, 'deep');
assert.equal(look.u, state.tour.id, "...and WHICH BODY it plays in — the tour is part of the look");
netMsg({ t: 'look', k: 'red', s: 'pale', u: 'women' });
assert.equal(G.net.look.kit.id, 'red', 'their kit is what the opponent is drawn in');
assert.equal(G.net.look.skin.id, 'pale');
assert.equal(G.net.look.tour.id, 'women', 'and their tour, whatever THIS phone picked');
// an older build, or a colour this one has never heard of, must not blank the opponent out
netMsg({ t: 'look', k: 'chartreuse', s: undefined, u: 'mixed doubles' });
assert.equal(G.net.look.kit.id, cfg.KITS[0].id, 'an unknown colour falls back to as-drawn');
assert.equal(G.net.look.skin.id, cfg.SKINS[0].id);
assert.equal(G.net.look.tour.id, state.tour.id, 'and an unknown tour falls back to OUR tour');
state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found
});
// THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard.
// It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving
// end (the same tidyName the world board and server.py's clean() agree on), never as sent.
test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => {
freshNet('A');
const look = netTest.sent.find(m => m.t === 'look');
assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers');
netMsg({ t: 'look', k: 'red', s: 'pale', n: ' <b>bob</b>ranathan ' });
assert.equal(G.net.look.name, 'BBOBBRAN', 'upper, stripped and cut to the eight-character slot');
netMsg({ t: 'look', k: 'red', s: 'pale' }); // an older build sends no name at all
assert.equal(G.net.look.name, 'FRIEND', '...and an unnamed opponent is FRIEND, never CPU');
});
test('every kit and skin in the tables is a live, distinct pick', () => {
for (const table of [cfg.KITS, cfg.SKINS]) {
assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');
for (const e of table) {
assert.ok(/^#[0-9a-f]{6}$/i.test(e.sw), `${e.id} has a swatch colour for the menu`);
assert.ok(e.name, `${e.id} is labelled`);
if (e.h != null) assert.ok(e.h >= 0 && e.h < 360, `${e.id}'s hue is a degree`);
}
}
// and the "as drawn" head of each table must actually be a no-op, or the default player is tinted
assert.equal(tintPx(...SHORTS, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
assert.equal(tintPx(...SKINPX, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
});
test('haptics: a blast that kills a dozen bodies is felt twice, not thirteen times', () => {
// The restraint rule is the whole feature: a hand that buzzes on every body in a bomb radius stops
// reading any of them. The gap lives in haptics.js so no call site has to know about it, and this
// is what proves it — the funnel (killZombie) is deliberately called once per kill.
const buzz = globalThis.hapticLog; buzz.length = 0; // the web fallback; no Capacitor bridge here
for (const k in hapticTest.at) delete hapticTest.at[k]; // a fresh hand
for (let i = 0; i < 12; i++) spawnZombie(0.3 * i - 1.8, 12, 'a');
explode(0, 12);
assert.equal(zombies.length, 0, 'the blast cleared its radius (or this proves nothing)');
assert.equal(buzz.length, 2, 'one heavy for the blast, one medium for the kills — not one per body');
});
test('the stand reads the SIZE of the moment, and a small cheer inside a big one cannot demote it', () => {
// crowd.pop is what decides whether the wave rolls and how many cameras go off (render.js), and
// every one of its inputs is a vol somebody passed to cheer(). The trap it is written against:
// a won rally landing in the middle of a match-point ovation must not drop the stand back to a
// polite clap, because cheer() is called from a dozen places and they overlap constantly.
const { crowd } = state;
crowd.cheer = crowd.pop = crowd.burst = 0;
cheer(0.8, 0.25); // a rally
assert.equal(crowd.pop, 0.25);
assert.ok(crowd.burst >= 1, 'even a small moment gets a shutter or two');
cheer(4, 1); // ...and then the match
assert.equal(crowd.pop, 1, 'the bigger moment owns the stand');
const queued = crowd.burst;
cheer(0.8, 0.25); // a rally INSIDE the ovation
assert.equal(crowd.pop, 1, 'a small cheer inside a live one may not demote it');
assert.ok(crowd.burst > queued, 'it still adds shutters, at the big moment\'s rate');
crowd.cheer = 0; // the ovation ends
cheer(0.8, 0.25);
assert.equal(crowd.pop, 0.25, 'once the stand has sat down, the next moment sets its own size');
crowd.cheer = crowd.pop = crowd.burst = 0;
});
// ---- 2026-08-28: the walk-on modal, the training court, and THE READ ---------------------------
test('the walk-on is a MODAL the player taps away, and it holds the world', () => {
// Oliver: "die Intros bitte als richtiges Modal, das man wegtappen muss — you can actually read
// it without time pressure, and tap it away fast when you know it." The timed 4 s card is gone.
G.seen.match = true; // the rules popup is not the subject here
assert.ok(_startMatch());
assert.ok(G.modal, 'the opponent announces as a modal, not a band card');
assert.equal(G.modal.title, cfg.matchFoe(1).name, 'it names the body');
assert.equal(G.modal.icon, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head');
const z0 = oppOf().z, intro0 = G.interlude.introT;
for (let i = 0; i < 60; i++) update(DT);
assert.equal(oppOf().z, z0, 'the frozen court holds while it is read');
assert.equal(G.interlude.introT, intro0, '...and the intro beat holds with it, however long that takes');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE TRAINING COURT: the rung is parked, nothing banks, and bleeding out is a modal', () => {
assert.ok(_startMatch(false, null, 5), 'training rides the match machinery');
G.modal = null; G.modalQ.length = 0;
const d = G.match;
assert.ok(d.train, 'flagged as the training court');
assert.equal(d.opp, 5, 'the picked rung is the one that walks on');
const runGames0 = G.runGames;
G.interlude.introT = 0;
G.hp = 3; // a scuffed pool, to prove the reset
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
passMopBeat(); // the kill fall, the mop, the SAME body walking on
assert.equal(G.match.opp, 5, 'a felled body comes back as itself — the ladder never advances');
assert.equal(G.runGames, runGames0, 'no ladder metric moves on the training court');
assert.equal(G.hp, G.hpMax, 'every rep starts whole — the reset, without the trainer ceremony');
// ...and running out of blood is a popup and a fresh pool, never the death screen
G.interlude.introT = 0;
G.hp = 1;
resolveInterlude('O');
assert.notEqual(G.mode, 'eaten', 'the training court has no death in it');
assert.ok(G.modal && G.modal.title === 'IT BLED YOU OUT', 'bleeding out announces itself');
assert.equal(G.hp, G.hpMax, '...and hands the pool back');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE READ: a habit is learned by repetition and a break wrong-foots the racket', () => {
// The twist for the post-COACH wall ("very fast, barely make mistakes"): repeat a lane and the
// opponent anticipates it, break an established habit and it is wrong-footed for that ball.
G.seen.match = true;
_startMatch(); G.modal = null; G.modalQ.length = 0;
const zb = oppOf(), d = G.match;
d.opp = cfg.LADDER; zb.foe = cfg.matchFoe(cfg.LADDER); // the champion's dials, live
G.interlude.introT = 0;
const hit = fx => { // one real player swing, aimed by a committed flick
G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingCool = 999; // it must not return the rig's ball
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
G.swipe = { ax: Math.sign(fx), fx, fy: 0, mag: TUNE.flickFull };
keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null;
assert.equal(ball.lastHit, 'P', 'the rig landed a swing');
ball.active = false; // the rally is not the subject
};
for (let i = 0; i < TUNE.readHabit; i++) hit(-1);
assert.equal(d.laneRun, TUNE.readHabit, 'a run of the same lane goes on the books');
assert.ok(!d.surprised && !d.expected, 'the habit itself surprises nobody');
hit(-1); // one more of the same: the anticipated ball
assert.ok(d.expected && !d.surprised, 'the repeated ball is anticipated — spam feeds the machine');
hit(1); // the break
assert.ok(d.surprised && !d.expected, 'the break wrong-foots him for this one flight');
assert.equal(d.laneRun, 1, '...and the new lane starts its own book');
// ...and the sprint dies with the chase: the rig just deactivated the ball, so one tick later
// the body carries no velocity — a stale vx kept the speed ghosts on a zombie that had already
// stopped ("teilweise bleiben die doppelten ghosts länger sichtbar")
update(DT);
assert.equal(zb.vx || 0, 0, 'no chase, no velocity — the ghost trail cannot outlive the sprint');
G.modal = null; G.modalQ.length = 0;
endMatch();
// the dials must MATTER at the top and vanish at the bottom — pinned so they cannot drift:
const spd = n => (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (cfg.matchFoe(n).speed ?? 1);
assert.ok(spd(cfg.LADDER) * (1 - TUNE.surpSlow) < spd(14),
'a wrong-footed champion runs slower than the head coach at full stride');
const champFlub = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, cfg.LADDER - 1) * cfg.matchFoe(cfg.LADDER).flub;
assert.ok(champFlub < 0.02, '(the champion barely misses on his own — the wall being answered)');
// 0.15, not 0.25: the same-day retune ("die Gegner sind jetzt etwas zuu einfach") took a third
// off every surprise dial — the window must stay REAL, but it is an opening, not a free point
assert.ok(champFlub + TUNE.surpFlub > 0.15, 'the surprise opens a real whiff window in that racket');
assert.ok(TUNE.readAnticipate <= 1, 'anticipation never reads better than perfect');
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report