Files
splatter-tennis/test.js
T
wuwiandClaude Opus 5.5 d110e12321 Bring the swing log to every rally mode, and time the gold ring
Oliver wants to hand over a log of why he missed balls. It existed behind
a scoreboard tap but was gated on G.mode === 'tennis', so the Climb, the
cup and training (which run as 'splatter') logged nothing. It now writes
in every rally mode: hits with shot, tap age and any quality cap, misses
with the new let-it-bounce reason, and a WON/LOST line naming why each
point went. Tapping the open log copies all 200 entries with a header.

Its first catch: the gold ring went gold at a distance, which is too
early for the slow balls steps 1-3 brought in. It is now timed off the
ball's real path to his reach (against where his run is taking him):
gold means touch now. ringTime 0 restores the old ring. v365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:41:05 +02:00

3285 lines
188 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 { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, 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, netAgain } = 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, hurtPlayer, firePellet } = 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 prof = await import('./src/profile.js');
const { advancePoint } = await import('./src/gameplay.js');
const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60;
function freshSplatter() {
restartRun();
// THE LEAGUES: a won cup PROMOTES the profile (by design), which would leak league 2 into every
// test after the tournament walkthrough — each test opens in league 1 with no ceremony pending.
// XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from
// earlier tests would silently buff every later one.
prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo();
// THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly
// the way the promotion did — the training court reads `out`, and a stale one reorders its list
prof.prof.cups = {}; prof.prof.at = {}; prof.prof.out = 0;
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 LEAGUES: every league's cup draws only from its own band, its final is the band's top
// rung and nothing else, and the five bands tile the whole ladder with no gap and no overlap
let expectLo = 1;
for (const l of cfg.LEAGUES) {
const [lo, hi] = l.band;
assert.equal(lo, expectLo, `${l.name} starts where the league below ended`);
expectLo = hi + 1;
const R = cfg.cupRoundsFor(l.id);
assert.deepEqual(R[R.length - 1].band, [hi], `${l.name}'s final is its top rung`);
assert.equal(R.length, hi - lo + 1, `${l.name} plays one round per rung`);
for (const r of R) for (const rung of r.band)
assert.ok(rung >= lo && rung <= hi, `${l.name} ${r.name} stays in its band (${rung})`);
for (const r of R.slice(0, -1))
assert.ok(!r.band.includes(hi), `${l.name}'s top rung is only ever the final`);
assert.ok(cfg.COURTS.some(c => c.id === l.court), `${l.name}'s court exists`);
}
assert.equal(expectLo, LADDER + 1, 'the five bands cover the ladder exactly');
});
test('league 5 has no speed dip below league 4, including serve and wrong-footed chases', () => {
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const measure = (n, serve, surprised) => {
G.match.opp = n; G.match.surprised = surprised; G.match.expected = false;
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = serve;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
return Math.hypot(zb.vx, zb.vz);
};
for (const serve of [false, true]) for (const surprised of [false, true]) {
const fourth = cfg.leagueOf(4).band, fifth = cfg.leagueOf(5).band;
let floor = 0;
for (let n = fourth[0]; n <= fourth[1]; n++) floor = Math.max(floor, measure(n, serve, surprised));
// since the legs have a knee (gameplay.knee) the late ladder runs at ~6-7.5 m/s, not 7-15
assert.ok(floor > TUNE.legKnee * 0.8, 'the rig measures a real full-speed chase: ' + floor.toFixed(2));
for (let n = fifth[0]; n <= fifth[1]; n++) {
const speed = measure(n, serve, surprised);
assert.ok(speed >= floor, `${cfg.matchFoe(n).short}: ${speed} must clear ${floor} (serve ${serve}, surprised ${surprised})`);
}
}
endMatch();
});
test('league 5 sustains strong returns and hunts the open court; league 4 stays unchanged', () => {
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
for (let n = 1; n <= cfg.leagueOf(4).band[1]; n++) {
assert.equal(cfg.matchFoe(n).smart, undefined, 'no new aim bonus in earlier leagues');
assert.equal(cfg.matchFoe(n).qMax, undefined, 'no new quality bonus in earlier leagues');
assert.equal(cfg.matchFoe(n).qMin, undefined, 'no new quality floor in earlier leagues');
}
for (const [n, smart, floor, cap] of [[15, 0, 0.45, 0.55], [16, 0.5, 0.62, 0.76], [17, 0.7, 0.68, 0.83], [18, 0.85, 0.74, 0.90]]) {
G.match.opp = n; zb.foe = cfg.matchFoe(n);
let hits = 0, open = 0, maxQ = 0, sumQ = 0;
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
G.match.surprised = G.match.expected = false;
P.x = i % 2 ? 3 : -3; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent
hits++;
assert.ok(ball.pow >= floor && ball.pow <= cap, `${n}: quality ${ball.pow} within its band`);
sumQ += ball.pow;
maxQ = Math.max(maxQ, ball.pow);
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
const landingX = ball.x + ball.vx * t;
// At q >= .45 the corner's inward scatter stays beyond 1.35; centre scatter stays inside.
if (landingX * Math.sign(P.x) < -1.35) open++;
ball.active = false;
}
assert.ok(hits > 1100, `${n}: enough actual returns (${hits})`);
assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`);
assert.ok(Math.abs(sumQ / hits - (floor + cap) / 2) < 0.01, `${n}: normal returns sustain the stronger quality`);
const expected = 1 / 3 + 2 / 3 * smart;
assert.ok(Math.abs(open / hits - expected) < 0.05,
`${n}: open-court share ${open / hits} should be near ${expected}`);
}
endMatch();
});
test('phone winners: the Champion approaches and returns the recorded short, low balls', () => {
// Cup points 21/22 from Oliver's v351 phone log. Both were untouched second-bounce winners:
// one landed near the net, the other bounced below the AI's old 24 cm contact floor.
const cases = [
{ opponent: [1.626, 5.485], player: [-1.035, 14.522], hp: 3,
pos: [-0.535, 1.194, 14.222], velocity: [1.467, 1.063, -7.611], g: 9.614 },
{ opponent: [2.676, 5.485], player: [-2.749, 19.533], hp: 2,
pos: [-2.249, 0.404, 19.233], velocity: [0.597, 3.678, -14.681], g: 9.698 },
];
const flub = TUNE.matchFlub;
TUNE.matchFlub = 0; // isolate movement/contact; reliability and surprise rates have their own tests
try {
for (const hz of [30, 60, 120]) for (const c of cases) {
freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(29);
startMatch(); G.interlude.introT = 0; G.match.opp = 18;
const rz = oppOf();
Object.assign(rz, { x: c.opponent[0], z: c.opponent[1], hp: c.hp, hpMax: 8,
foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 });
Object.assign(P, { x: c.player[0], z: c.player[1], swingT: -1 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.05,
x: c.pos[0], y: c.pos[1], z: c.pos[2], vx: c.velocity[0], vy: c.velocity[1], vz: c.velocity[2], g: c.g });
for (let i = 0; i < hz * 3 && ball.active && ball.lastHit === 'P' && G.phase === 'rally'; i++) {
update(1 / hz);
assert.ok(rz.z <= COURT.netZ - 1.2, 'the approach stays on its own side');
}
assert.equal(ball.lastHit, 'O', `${hz}Hz: the reachable phone winner is returned`);
assert.deepEqual(score.pts, [0, 0], 'the rally continues instead of gifting a point');
}
} finally { TUNE.matchFlub = flub; }
});
test('match low contact: scoop rising balls, reject falling balls and contact across the net', () => {
const flub = TUNE.matchFlub;
TUNE.matchFlub = 0;
try {
for (const [z, y, vy, returns] of [[5, 0.12, 1.5, true], [5, 0.12, -1, false],
[COURT.netZ + 0.1, 1, 0, false]]) {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 0, z: Math.min(z, COURT.netZ - 1.2), swingT: -1, swingCool: 0 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
x: 0, y, z, vx: 0, vy, vz: -1, g: TUNE.gravity });
update(DT);
assert.equal(ball.lastHit === 'O', returns, `contact at z=${z}, y=${y}, vy=${vy}`);
}
} finally { TUNE.matchFlub = flub; }
});
test('match recovery: finish the serve, recover during the outbound rally, hold on dead balls', () => {
startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 3, z: -0.5, serveHold: 0.4 });
G.phase = 'rally';
Object.assign(ball, { active: true, lastHit: 'O', bounces: 0,
x: 0, y: 2, z: 14, vx: 0, vy: 0, vz: 1 });
update(DT);
assert.deepEqual([rz.x, rz.z], [3, -0.5], 'serve follow-through holds its mark');
rz.serveHold = 0;
update(DT);
// THE COURT HAS ITS LENGTH: the baseliner's spot is BEHIND the line now (rallyBack), not 2.5 m in
assert.ok(rz.x < 3 && rz.z < -0.5 && rz.z >= -TUNE.rallyBack, 'recovers towards centre, to its spot behind the baseline');
const recoverySpeed = Math.hypot(rz.vx, rz.vz);
ball.lastHit = 'P'; ball.vz = -1;
update(DT);
assert.ok(Math.abs(Math.hypot(rz.vx, rz.vz) - recoverySpeed * 2) < 1e-9,
'recovery uses half chase speed, leaving room for wrong-footing');
for (const dead of ['inactive', 'scored', 'pretoss']) {
ball.lastHit = 'O'; ball.vz = 1; ball.active = dead !== 'inactive'; ball.scored = dead === 'scored';
G.phase = dead === 'pretoss' ? 'pretoss' : 'rally';
const pos = [rz.x, rz.z];
update(DT);
assert.deepEqual([rz.x, rz.z], pos, `${dead}: no recovery outside a live flight`);
assert.deepEqual([rz.vx, rz.vz], [0, 0], 'no stale sprint trail');
}
});
test('the approaching Champion can still be passed into the open back corner', () => {
startMatch(); G.interlude.introT = 0; G.match.opp = 18;
const rz = oppOf();
Object.assign(rz, { x: 3, z: 10, foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0, pow: 0.8,
x: -3.5, y: 0.3, z: 1.5, vx: 0, vy: -2, vz: -4, g: TUNE.gravity });
for (let i = 0; i < 60 && score.pts[0] === 0; i++) update(DT);
assert.deepEqual(score.pts, [1, 0], 'a legal passing shot beats a Champion stranded at the net');
});
// ---- THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT") ----------------------
// Oliver: "Aufschlag, dann schnell vor ans Netz ... ein sehr schneller Ballwechsel" — every match had
// become a volley exchange at the T. Measured: the rally ball landed ON the service line, the
// opponent stood on the bounce and volleyed 90-100% of everything from opponent 6 up, and the
// auto-run chased the earliest contact, ~0.7 s per shot. These pin the shape that replaced it.
test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not volleyed at the T', () => {
// the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
// a BASELINER (THE LINE JUDGE): since the styles (step 2) a net rusher volleys on his way in by
// design, so the baseline shape is asserted against a body whose game is the baseline
startMatch(); G.interlude.introT = 0; G.match.opp = 11;
assert.equal(cfg.matchFoe(11).style, 'baseliner', 'the subject plays from the back');
oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const hits = [];
let prev = ball.lastHit;
for (let i = 0; i < 120 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject
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 (d < 2.9 && d > 2.2) keyPressed('Space');
}
const b0 = ball.bounces;
update(DT);
if (ball.active && ball.lastHit !== prev && !G.wasServe) {
const ch = ball.lastHit === 'P' ? P : oppOf();
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT });
}
prev = ball.active ? ball.lastHit : null;
}
const back = hits.filter(h => h.back);
assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts');
// the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of
// net contacts follows its timing; the back-court volley share below is the guard against the old T
assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`);
const volleys = back.filter(h => h.volley).length;
assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
const gaps = [];
for (let k = 1; k < hits.length; k++) if (hits[k].back && hits[k - 1].back && hits[k].t - hits[k - 1].t < 3)
gaps.push(hits[k].t - hits[k - 1].t);
gaps.sort((a, b) => a - b);
assert.ok(gaps[gaps.length >> 1] >= 0.75, 'a baseline exchange leaves time to play: median '
+ gaps[gaps.length >> 1].toFixed(2) + ' s per shot');
} finally { TUNE.autoAssist = assist; }
});
test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobody approaches a man at the net', () => {
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
// the opponent's answer to one ball from the player's baseline, landing `depth` m inside its line
const play = (depth, pace, from) => {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 0, z: -TUNE.rallyBack, swingT: -1, swingCool: 0, serveHold: 0 });
Object.assign(P, { x: 0, z: 24, swingT: -1 });
G.phase = 'rally'; G.wasServe = false;
const v = solveShot(0, 1, 22.5, 0.5, depth, pace);
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.5, rx: null, from,
x: 0, y: 1, z: 22.5, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
let bounced = null, at = null;
for (let i = 0; i < 4 * 60 && ball.lastHit === 'P' && ball.active; i++) { bounced = ball.bounces; update(DT); }
assert.equal(ball.lastHit, 'O', `depth ${depth}: the opponent returns it`);
at = rz.z;
for (let i = 0; i < 30; i++) update(DT); // its own ball is in the air: where does it go now?
return { bounced, at, then: rz.z, approach: ball.approach };
};
try {
const deep = play(2.4, 24, 12);
assert.equal(deep.bounced, 1, 'a deep ball is played after the bounce');
assert.ok(deep.at < 1, 'from the baseline, not the T: met at z ' + deep.at.toFixed(2));
assert.equal(deep.approach, null, 'and it is no approach');
assert.ok(deep.then < 1, 'he stays back: ' + deep.then.toFixed(2));
const short = play(6.4, 15, 12);
assert.equal(short.approach, 'O', 'a short ball taken is an approach');
assert.ok(short.at > 2, 'he ran in for it: met at z ' + short.at.toFixed(2));
assert.ok(short.then > short.at + 0.5, 'and follows it in: ' + short.at.toFixed(2) + ' -> ' + short.then.toFixed(2));
const pass = play(6.4, 15, 3);
assert.equal(pass.approach, null, 'against a man at the net the same ball is a pass, never an approach');
} finally { TUNE.matchFlub = flub; }
});
test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => {
// Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen".
// A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the
// groundstroke window that took a tap made exactly on the gold ring from q 0.82 to 0.09, and at
// 150 ms a third of the balls were simply missed. keyPressed's `lead` starts the swing clock at
// touch-down; this is the ring-tapping player, with a real finger's 120 ms of tap on top.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const LAG = 7; // frames: ~120 ms between the finger landing and it lifting
let fire = -1, armed = false, incoming = 0, returned = 0, sumQ = 0;
for (let i = 0; i < 90 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
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' && ball.vz > 0 && !armed) {
if (ringCue()?.gold) { armed = true; incoming++; fire = i + LAG; } // the finger LANDS on gold and lifts LAG later
}
if (i === fire) keyPressed('Space', LAG / 60); // the lift: the touch's age rides along
const before = ball.lastHit;
update(DT);
if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; }
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
}
assert.ok(incoming >= 30, 'a real sample: ' + incoming);
assert.ok(returned / incoming >= 0.9, `a gold-ring tap returns the ball: ${returned}/${incoming}`);
assert.ok(sumQ / returned >= 0.6, 'and returns it cleanly: mean q ' + (sumQ / returned).toFixed(2));
} finally { TUNE.autoAssist = assist; }
});
// ---- THE SOFT BALL (step 3, 2026-10-14): the down-flick is soft, its length is how far ----------------
// One player shot, struck from the baseline with the swing clock put exactly where trySwing scores it:
// q = 1 at the window's centre, `late` seconds off it for a mistimed one. The opponent is parked out of
// play so the flight is the shot's own. Returns where it landed, how high it flew, its spin, and the
// gap between its first and second bounce.
function softShot(mag, late = 0, rzZ = -0.6) {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 3, z: rzZ, swingCool: 999, swingT: -1 });
G.phase = 'rally'; G.wasServe = false;
Object.assign(P, { x: 0, z: 24, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT + late });
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false },
spin: 1, soft: false, x: 0.4, y: 1.0, z: 23.6, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity });
G.swipe = { ax: 0, az: 1, fx: 0, fy: 1, mag };
update(DT); G.swipe = null;
assert.equal(ball.lastHit, 'P', 'the swing connects');
const q = ball.pow, spin = ball.spin, land = predictLanding();
let apex = ball.y, first = null, atNetMan = null;
const netMan = COURT.netZ - TUNE.netStand;
for (let i = 0; i < 6 * 60 && ball.active && ball.bounces < 2; i++) {
update(DT); apex = Math.max(apex, ball.y);
if (atNetMan === null && ball.z <= netMan) atNetMan = ball.y;
if (ball.bounces === 1 && !first) first = ball.z;
}
return { q, spin, depth: land.z, apex, atNetMan, roll: first === null ? null : Math.abs(ball.z - first), rz };
}
const winLevelNow = () => _winLevel();
test('THE SOFT BALL: a short down-flick is a dying drop, a long one an attacking lob over the net man', () => {
const drop = softShot(TUNE.flickMin + 2), lob = softShot(TUNE.flickFull + 20);
// Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob" — a NATURAL short flick (a thumb
// travels 40-80 px) must already be the drop, not a floater on the way to a lob
const flick = softShot(TUNE.dropMax - 15);
assert.ok(Math.abs(flick.depth - drop.depth) < 0.3 && flick.spin === drop.spin, 'a short natural flick is the drop');
assert.ok(drop.q > 0.95 && lob.q > 0.95, 'both struck flush');
assert.ok(Math.abs(drop.depth - (COURT.netZ - TUNE.dropNet)) < 0.7, 'the drop lands just over the net: z ' + drop.depth.toFixed(2));
// (dropApex is the ask; from the baseline the net-clearance loop lofts it a little, which is right)
assert.ok(drop.apex < lob.apex - 1.5, `on a low arc: ${drop.apex.toFixed(2)} m against the lob's ${lob.apex.toFixed(2)}`);
assert.ok(drop.spin < 0.5 && drop.roll < 2.5, `and it DIES: spin ${drop.spin.toFixed(2)}, ${drop.roll.toFixed(2)} m between its bounces`);
assert.ok(Math.abs(lob.depth - TUNE.lobDeep) < 0.8, 'the lob lands deep: z ' + lob.depth.toFixed(2));
assert.ok(lob.apex > TUNE.lobApex - 0.6 && lob.spin === 1, 'high, and with no backspin: ' + lob.apex.toFixed(2) + ' m');
// ...over a man at the net: where he stands it must be out of his racket's reach
assert.ok(lob.atNetMan > CONTACT.maxY + 0.5, `over the net man's racket: ${lob.atNetMan.toFixed(2)} m at his depth`);
});
test('a mistimed soft ball drifts toward the sitter — the easy ball, never an out', () => {
const clean = softShot(TUNE.flickFull + 20), rushed = softShot(TUNE.flickFull + 20, 0.07);
assert.ok(rushed.q < 0.3, 'the rushed one really is mistimed: q ' + rushed.q.toFixed(2));
assert.ok(Math.abs(rushed.depth - TUNE.softSitter) < Math.abs(clean.depth - TUNE.softSitter),
`it falls short of the corner: z ${clean.depth.toFixed(2)} -> ${rushed.depth.toFixed(2)}`);
assert.ok(rushed.depth > 0 && rushed.depth < COURT.netZ, 'and stays in the court');
});
test('a soft ball is DISGUISED: the opponent stands reading it, but runs at once for a drive', () => {
const moved = (soft) => {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const rz = oppOf();
Object.assign(rz, { x: 0, z: -0.6, swingCool: 999, swingT: -1 });
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 0;
const v = solveShot(0, 1, 23, 2.5, soft ? 9.5 : 3, soft ? 12 : 25);
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: soft ? 0.3 : 1, soft,
x: 0, y: 1, z: 23, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
for (let i = 0; i < Math.round((TUNE.softRead - 0.05) * 60); i++) update(DT);
return Math.hypot(rz.x, rz.z + 0.6);
};
assert.ok(moved(false) > 0.5, 'a drive: he is already on his way');
assert.ok(moved(true) < 0.05, 'a drop: he has not read it yet');
});
test('a LOBBED net man who runs it down only gets a defensive ball back', () => {
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
try {
for (const lob of [false, true]) {
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = 18;
const rz = oppOf();
Object.assign(rz, { x: 0, z: 3, swingCool: 0, swingT: -1, foe: cfg.matchFoe(18) });
G.phase = 'rally'; G.wasServe = false; G.aiReactT = 1;
Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 1, spin: 1, soft: lob,
rx: { net: false, short: false, lob }, x: 0, y: 1.2, z: 3.6, vx: 0, vy: -1, vz: -4, g: TUNE.gravity });
update(DT);
assert.equal(ball.lastHit, 'O', 'he gets to it');
if (lob) assert.ok(ball.pow <= TUNE.lowVolleyQ + 1e-9, 'run down from a lob: q ' + ball.pow.toFixed(2));
else assert.ok(ball.pow > TUNE.lowVolleyQ, 'the same ball unlobbed is a full reply: q ' + ball.pow.toFixed(2));
}
} finally { TUNE.matchFlub = flub; }
});
// ---- THE STYLES (step 2, 2026-10-14): how a body plays is a row of dials --------------------------
// 400 returns off one body, the player parked at `pz`: what share came back soft, and the spins.
function styleReturns(rung, pz, aiZ = 2) {
freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = rung;
const zb = oppOf(); zb.foe = cfg.matchFoe(rung);
const out = { n: 0, soft: 0, lob: 0, drop: 0, spins: new Set() };
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
try {
for (let i = 0; i < 400; i++) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = pz; P.swingT = -1;
zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false,
x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
out.n++;
if (ball.soft) { out.soft++; if (ball.spin < 1) out.drop++; else out.lob++; }
else out.spins.add(Math.round(ball.spin * 100) / 100);
ball.active = false;
}
} finally { TUNE.matchFlub = flub; }
return out;
}
test('THE STYLES: every opponent has one, and the walk-on says which', () => {
for (let n = 1; n <= cfg.LADDER; n++) {
const f = cfg.matchFoe(n), st = cfg.STYLES[f.style];
assert.ok(st, `${f.short} plays a known style (${f.style})`);
assert.ok(st.tag.length <= 38, `${f.style}'s tag fits the modal's one line: ${st.tag}`);
}
startMatch();
const m = G.modal || G.modalQ.find(q => q.foe);
assert.ok(!m || !m.foe || m.foe.style === cfg.STYLES[cfg.matchFoe(1).style].tag, 'the walk-on carries the tag');
});
test('a PUSHER moonballs as a habit and lobs a man at the net; a BIG HITTER carries topspin', () => {
const pusher = cfg.STYLES[cfg.matchFoe(1).style];
assert.equal(cfg.matchFoe(1).style, 'pusher');
const back = styleReturns(1, 24);
assert.ok(Math.abs(back.soft / back.n - pusher.loft) < 0.08, `moonballs ${back.soft}/${back.n} vs loft ${pusher.loft}`);
assert.equal(back.drop, 0, 'a pusher does not drop');
const net = styleReturns(1, COURT.netZ + TUNE.netStand);
assert.ok(Math.abs(net.lob / net.n - pusher.lob) < 0.08, `lobs at the net man ${net.lob}/${net.n} vs ${pusher.lob}`);
const power = styleReturns(5, 24);
assert.equal(cfg.matchFoe(5).style, 'power');
assert.deepEqual([...power.spins], [cfg.STYLES.power.spin], 'every big-hitter rally ball kicks');
assert.equal(power.soft, 0, 'and none of them floats');
});
test('a TOUCH player drops you from inside when you hang back — and never from his own baseline', () => {
assert.equal(cfg.matchFoe(7).style, 'touch');
const inside = styleReturns(7, COURT.L + 0.5, 4), deep = styleReturns(7, COURT.L + 0.5, -0.5);
assert.ok(Math.abs(inside.drop / inside.n - cfg.STYLES.touch.drop) < 0.08, `drops from inside ${inside.drop}/${inside.n}`);
assert.equal(deep.drop, 0, 'from behind his own line a drop is no option');
});
test('a NET RUSHER serves and volleys: the serve is his approach and he sprints in behind it', () => {
assert.equal(cfg.matchFoe(15).style, 'rusher');
startMatch(); G.interlude.introT = 0; G.match.opp = 15;
const zb = oppOf(); zb.foe = cfg.matchFoe(15);
score.server = 'O'; setupPoint();
Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 });
// the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in
let served = -1, z0 = null, appr = null, zMax = -99, rushed = false, sprint = 0;
for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) {
G.modal = null; G.modalQ.length = 0;
const zPrev = zb.z;
update(DT);
if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; }
if (served >= 0) { zMax = Math.max(zMax, zb.z); rushed ||= !!zb.rushing; sprint = Math.max(sprint, (zb.z - zPrev) / DT); }
}
assert.ok(served >= 0, 'he served');
assert.equal(appr, 'O', 'the serve is an approach');
assert.ok(rushed && zMax > z0 + 1.2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`);
// ...at a SPRINT, not the half-pace walk an ordinary recovery uses
assert.ok(sprint > 0.8 * cfg.TUNE.racketSpeed * 1.25, 'he sprints in: ' + sprint.toFixed(2) + ' m/s');
});
test('LOBBED at the net, the player backpedals too, and only blocks it back', () => {
freshSplatter(); startMatch(); G.interlude.introT = 0;
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
const zb = oppOf(); zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = false;
Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 });
const v = solveShot(0, 1, 2, 0, COURT.L - 2, 9);
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true,
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
update(DT);
assert.ok(ball.rx && ball.rx.lob && !ball.rx.net, 'the plan turns: it is a lob, he runs it down');
} finally { TUNE.autoAssist = assist; }
});
test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => {
// Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht
// bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb
// and the cup logged nothing at all.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
startMatch(); G.interlude.introT = 0;
for (let i = 0; i < 2.2 * 60; i++) update(DT);
G.missLog.length = 0;
const zb = oppOf(); zb.swingCool = 999;
const incoming = () => {
G.phase = 'rally'; G.wasServe = false; G.swung = false; G.rallyNear = null;
const v = solveShot(0, 1, 2, 0.5, 21, 20);
Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false,
x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity });
};
incoming();
keyPressed('Space', 0.1); // far too early: the ball has barely left
for (let i = 0; i < 30; i++) update(DT);
const miss = G.missLog.find(e => e.result === 'MISS');
assert.ok(miss && /tap too early/.test(miss.reason) && /tap 100ms/.test(miss.reason), 'the whiff is explained: ' + (miss && miss.reason));
for (let i = 0; i < 4 * 60 && G.phase === 'rally'; i++) update(DT);
const lost = G.missLog.find(e => e.result === 'LOST');
assert.ok(lost, 'the point it cost is logged: ' + JSON.stringify(G.missLog.map(e => e.result)));
// a clean hit carries its shot and its tap
for (let i = 0; i < 4 * 60 && G.phase !== 'pretoss'; i++) update(DT);
incoming(); G.missLog.length = 0;
let lift = -1;
for (let i = 0; i < 3 * 60 && ball.lastHit === 'O'; i++) {
if (lift < 0 && ringCue()?.gold) lift = i + 6; // the finger lands on gold...
if (i === lift) keyPressed('Space', 0.1); // ...and lifts 100 ms later
update(DT);
}
const hit = G.missLog.find(e => e.result === 'HIT');
assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason));
} finally { TUNE.autoAssist = assist; }
});
test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => {
// the only out a rally ball may find is wide — depth must never put one past the baseline
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const sample = n => {
G.match.opp = n; zb.foe = cfg.matchFoe(n);
const depths = [];
for (let i = 0; i < 400; i++) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = 24; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests)
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
depths.push(COURT.L - (ball.z + ball.vz * t));
ball.active = false;
}
return depths;
};
const weak = sample(1), strong = sample(18);
// solveShot aims at ty 0.08 (the ball on the grass), so the true bounce is a few dm past the mark
for (const d of [...weak, ...strong]) assert.ok(d >= TUNE.rallyDeep - 0.5, 'never long, never near the line: ' + d.toFixed(2));
const mean = a => a.reduce((s, x) => s + x, 0) / a.length;
assert.ok(mean(weak) > mean(strong) + 0.4, `weaker timing sits up shorter: ${mean(weak).toFixed(2)} vs ${mean(strong).toFixed(2)} m inside`);
endMatch();
});
test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => {
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const measure = (n, mode) => {
G.match.opp = n; G.match.train = mode === 'train';
G.match.cup = mode === 'cup' ? { round: 0, rounds: [{ need: 1 }] } : null;
G.match.surprised = G.match.expected = false;
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
const chase = Math.hypot(zb.vx, zb.vz);
let hits = 0, pace = 0, paced = 0, width = 0;
const seed = _seed;
_seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
// a style's soft ball takes its pace from its arc on purpose, so the pace dial reads rally balls
hits++; if (!ball.soft) { pace += Math.hypot(ball.vx, ball.vz); paced++; }
const t = (ball.vy + Math.sqrt(ball.vy ** 2 + 2 * ball.g * ball.y)) / ball.g;
width = Math.max(width, Math.abs(ball.x + ball.vx * t));
ball.active = false;
}
_seed = seed;
return { chase, hits, pace: pace / paced, width };
};
let previous = 0;
for (let n = 1; n <= 8; n++) {
const cup = measure(n, 'cup'), train = measure(n, 'train'), climb = measure(n, 'climb');
const same = (a, b, why) => {
assert.equal(a.chase, b.chase, why + ': chase');
assert.ok(Math.abs(a.pace / b.pace - 1) < 0.005, why + ': return pace');
assert.ok(Math.abs(a.width - b.width) < 0.12, why + ': width ' + a.width + ' / ' + b.width);
assert.ok(Math.abs(a.hits - b.hits) < 40, why + ': whiff rate');
};
same(train, cup, `${n}: the training rep matches its cup opponent`);
assert.ok(climb.chase > previous, `${n}: the arcade's early chase curve keeps rising`);
previous = climb.chase;
if (n < 8) assert.ok(climb.chase > cup.chase, `${n}: less opening assistance in arcade`);
else same(climb, cup, 'opponent eight rejoins the original curve');
if (n === 1) {
assert.ok(climb.chase / cup.chase > 1.3, 'the opening opponent covers materially more ground');
assert.ok(climb.pace / cup.pace > 1.2, 'its actual returns arrive faster');
assert.ok(climb.width / cup.width > 1.4, 'its actual returns use more of the court');
assert.ok(climb.hits > cup.hits + 40, 'it gives away fewer returns');
assert.ok(climb.width < 2.5, 'opening returns still leave a reachable margin');
}
}
G.match.cup = null; G.match.train = false;
endMatch();
});
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', () => {
_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');
const CUP_ROUNDS = d.cup.rounds; // THE LEAGUES: the run carries its own rounds
const dealt = [];
for (let round = 0; round < CUP_ROUNDS.length; round++) {
const R = CUP_ROUNDS[round];
assert.ok(!dealt.includes(d.opp), R.name + ' never re-deals a fallen body');
dealt.push(d.opp);
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, 'every round felled a body, banked as a match run');
// THE LEAGUES: this cup was league one's, so winning it IS the promotion
assert.equal(prof.prof.lg, 2, 'the cup is the promotion gate');
const pr = prof.takePromo();
assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play');
});
// ---- THE SEASON: the tournament is the story, so the club has to remember the attempts --------
test('THE SEASON: the fixture names the league, its rounds and the body that opens it', () => {
for (const l of cfg.LEAGUES) {
const fx = cfg.cupFixture(l.id), R = cfg.cupRoundsFor(l.id);
assert.equal(fx.rung, R[0].band[0], l.name + ': the fixture opens on round one\'s rung');
assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body');
assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE',
l.name + ': the title carries the league');
// the text reduction moved the round COUNT off the button and into the modal's sub line
assert.ok(fx.line.includes(R[0].name) && fx.line.includes(fx.foe.name),
l.name + ': the line names the first round and the body in it');
assert.ok(fx.sub.includes(String(R.length)) && fx.sub.includes(String(l.id)),
l.name + ': the sub carries the league and how many rounds it runs');
}
// the champion has nothing above him: his fixture is a defence, not a promotion
assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE');
// ...and a record reads in the rounds' own words
const R3 = cfg.cupRoundsFor(3);
assert.equal(cfg.cupRoundName(3, 1), R3[0].name, 'one round reached is round one');
assert.equal(cfg.cupRoundName(3, R3.length), 'THE FINAL', 'the last round is the final');
});
test('THE SEASON: a cup exit is recorded, forward only, and names who put you out', () => {
prof.prof.lg = 3;
_startMatch(true); // league 3's cup, round one
G.modal = null; G.modalQ.length = 0;
G.match.cup.round = 2; // deep: the semi-final
const rung = G.match.cup.rung;
killPlayer();
assert.equal(prof.cupBest(3), 3, 'the round you were in is the round you reached');
assert.equal(prof.prof.out, rung, 'the body holding the racket is the one that put you out');
assert.equal(prof.cupBest(1), 0, 'and it is that league\'s record, not the club\'s');
// a shallower attempt afterwards leaves the record standing — and still updates the eliminator
restartRun();
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
prof.prof.lg = 3;
_startMatch(true);
G.modal = null; G.modalQ.length = 0;
G.match.cup.round = 0;
const rung2 = G.match.cup.rung;
killPlayer();
assert.equal(prof.cupBest(3), 3, 'a worse run never erases a better one');
assert.equal(prof.prof.out, rung2, 'but the LAST body to end an attempt is always the offer');
// the training court is not an attempt and writes nothing
const out = prof.prof.out;
restartRun();
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
_startMatch(false, null, 2);
G.modal = null; G.modalQ.length = 0;
killPlayer();
assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt');
});
test('THE CUP BOARD: the league ranks, the level it was taken at breaks the tie', () => {
// the inscription is written at the promotion and never again — a league is taken once
prof.prof.lg = 2; prof.prof.xp = prof.xpFor(9);
prof.recordCupWin(1, prof.levelAt(prof.prof.xp));
assert.equal(prof.cupAt(1), 9, 'the level that won the cup is the one inscribed');
prof.recordCupWin(1, 3);
assert.equal(prof.cupAt(1), 9, '...and a later write never overwrites it');
assert.equal(prof.cupAt(2), 0, 'a league not yet taken carries no inscription');
// the board itself: league is the score, the level the tie-break, and it is the ONE board whose
// second column is better LOW (server.py's rank_key is the other half of this)
const B = statsMod.BOARDS[statsMod.MODE_CUP];
assert.equal(B.score({ k: 4, g: 22 }), 4, 'the league ranks');
assert.equal(B.sub({ k: 4, g: 22 }), 22, 'the level rides along');
assert.ok(B.subLow, 'and lower is better on it');
});
test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => {
// the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad
// roll paired the strongest challenger with the shortest round, inverting the tuned curve
for (const l of cfg.LEAGUES) {
const R = cfg.cupRoundsFor(l.id);
const order = R.map(r => (assert.equal(r.band.length, 1, 'one settled body per round'), r.band[0]));
for (let i = 1; i < order.length; i++)
assert.ok(order[i] > order[i - 1], `${l.name}: round ${i + 1} is the harder body`);
assert.equal(order[0], l.band[0], `${l.name} opens on its easiest`);
assert.equal(order[order.length - 1], l.band[1], `${l.name} ends on its final`);
}
// ...and the run reads that order off exactly
_startMatch(true); G.modal = null; G.modalQ.length = 0;
assert.equal(G.match.opp, 1, 'round one walks on the easiest body');
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.
_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');
});
// THE FRIEND'S RACKET (Oliver, 2026-09-11). Every other opponent is simulated locally and cracks
// through gameplay's own strike line; the friend's swing runs on nobody's phone but theirs, so the
// only place the sound can come from is the shot message — and only AFTER the ball it is pitched
// off has been written.
test('netplay: the friend\'s strike makes a noise, pitched off the ball it just sent', () => {
freshNet('B');
audio.sfxTest.log = [];
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 });
assert.ok(audio.sfxTest.log.includes('tennis-opponent'), 'the far racket cracks on the wire event');
audio.sfxTest.log = null;
});
// ---- OPEN 13: the four missing sounds (2026-09-23). Synthesized, so the ear is the phone's job;
// what the suite pins is that each one fires at its EVENT, and that fire stays one clock, not a
// sound per body.
test('the machine squeaks, fire crackles and catches, the gun cracks, the boss stomps and growls', () => {
const heard = n => audio.sfxTest.log.includes(n);
freshSplatter();
audio.sfxTest.log = [];
spawnZombie(0, 8, 'm');
const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99;
// keep it ROLLING for the whole window: one squeak per grind tick is a 60% roll, and a single 3 m
// trip is only ~5 ticks — about 1 seed in 100 heard nothing (seed 31337 found it)
for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { if (m.rollTo === undefined) m.rollTo = m.x > 0 ? -3 : 3; update(DT); G.hp = 9; }
assert.ok(heard('squeak'), 'a rolling machine announces itself');
freshSplatter();
audio.sfxTest.log = [];
for (let k = 0; k < 8; k++) spawnZombie(-1.4 + k * 0.4, 6, 'a');
zombies[0].burnT = 3;
update(DT);
assert.ok(heard('crackle'), 'a burning body crackles');
assert.equal(audio.sfxTest.log.filter(n => n === 'whump').length, 1, 'an epidemic lights the row, and says so ONCE');
const lit = zombies.filter(z => z.burnT > 0).length;
audio.sfxTest.log = [];
for (let i = 0; i < 60; i++) update(DT);
const crackles = audio.sfxTest.log.filter(n => n === 'crackle').length;
assert.ok(lit >= 3 && crackles >= 3 && crackles <= 16, `one court clock, not one per body: ${crackles} crackles/s for ${lit} lit`);
audio.sfxTest.log = [];
for (let k = 0; k < 4; k++) firePellet();
assert.equal(audio.sfxTest.log.filter(n => n === 'gunshot').length, 2, 'the gun cracks every other round');
freshSplatter();
startBossFight(1);
audio.sfxTest.log = [];
for (let i = 0; i < 60 * 30 && !(heard('stomp') && heard('growl')); i++) { update(DT); G.hp = 9; }
assert.ok(heard('stomp'), 'the boss has feet');
assert.ok(heard('growl'), 'and a throat: the wind-up draws breath');
audio.sfxTest.log = null;
});
// ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the
// same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run
// under the decisions the room already carries. The desync to guard against is the two courts
// coming up different — so these assert the whole handover, from both seats.
test('netplay rematch: one frame resets both phones onto the same fresh court', () => {
freshNetDeath('A'); // the host: my serve, death match
score.games = [2, 1]; score.pts = [3, 0]; G.hp = 2;
netMsg({ t: 'dead' }); // their blood ran out: the win ending, burst and all
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
assert.ok(G.done && G.hideO, 'setup: the summary is up over a burst friend');
netTest.sent = [];
assert.ok(netAgain(), 'the line is up: the rematch takes it');
assert.ok(netTest.sent.some(m => m.t === 'again'), 'and the friend is told');
assert.ok(G.net && G.net.ready && G.net.death, 'THE PAIRING SURVIVES THE RESET — same room, same mode');
assert.ok(!G.done && G.mode === 'splatter' && G.match, 'the match machinery owns the court again');
assert.deepEqual([score.games, score.pts], [[0, 0], [0, 0]], 'nothing carries over');
assert.equal(G.hp, G.hpMax, 'full blood');
assert.equal(G.hideO, false, 'and the friend is standing');
assert.equal(score.server, 'P', 'the host still serves first, on both scoreboards');
assert.ok(!zombies.some(z => z.variant === 'r'), 'still no roster body — the friend IS the opponent');
assert.equal(G.modal, null, 'and no DEATH MATCH explainer may eat the first serve');
// the other seat — the one that LOST, which is the phone most likely to ask for another: their
// frame arrives and does the same thing, WITHOUT echoing one back
freshNetDeath('B');
G.hp = 1; netMsg({ t: 'pt', win: 'me' });
assert.equal(G.mode, 'eaten', 'setup: bled out, on the death screen');
netTest.sent = [];
netMsg({ t: 'again' });
assert.ok(G.mode === 'splatter' && G.match && G.net.ready, 'their tap restarts this phone too');
assert.equal(G.hp, G.hpMax, '...back on its feet with a full pool');
assert.equal(score.server, 'O', 'the host serves here as well — the two courts agree');
assert.ok(!netTest.sent.some(m => m.t === 'again'), 'an applied rematch does not echo back out');
// BOTH TAPPED AT ONCE: the second frame must not restart a court that is already fresh
netTest.sent = [];
score.pts = [2, 0];
netMsg({ t: 'again' });
assert.deepEqual(score.pts, [2, 0], 'the echo inside the window is swallowed');
});
test('netplay rematch: a dead line refuses, so the summary falls through to the clubhouse', () => {
freshNetDeath('A');
netMsg({ t: 'bye' }); // they left: netDead clears ready
assert.equal(netAgain(), false, 'no socket, no rematch');
endNetVs();
assert.equal(netAgain(), false, 'and none off a court netplay does not own');
});
// classic netplay has no G.match to rebuild — what it must NOT lose is G.classic itself, which the
// menu set on pairing and which resetState wipes (a rematch that came up in gore mode is the bug)
test('netplay rematch: the classic duel comes back as classic tennis', () => {
freshNet('B');
G.sets = [0, 1]; score.games = [1, 4];
netAgain();
assert.equal(G.classic, true, 'the same game is on the court');
assert.ok(!G.match && G.mode === 'tennis', '...and the death match was never dealt into it');
assert.deepEqual(score.games, [0, 0], 'a fresh match');
assert.equal(score.server, 'O', 'the host serves first');
});
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.foe.head, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head (the zoned foe layout)');
assert.ok(G.modal.foe.lvl >= 1 && G.modal.foe.mine >= 1, '...and carries the level pair');
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');
});
// ---- THE LEAGUES, phase 1: the profile clock -------------------------------
// The plan of record is PROJECT.md "THE LEAGUES". These pin the three promises phase 1 makes:
// the curve is fast-early/slow-late and capped, the award funnel pays every mode and mutes every
// no-stakes mode, and the run delta survives nothing it should not.
test('PROFILE: the level curve is monotonic, fast early, capped', () => {
let prev = -1;
for (let l = 1; l <= prof.LEVEL_CAP; l++) {
const x = prof.xpFor(l);
assert.ok(x > prev, 'xp thresholds strictly climb');
prev = x;
}
assert.ok(prof.xpFor(2) <= 120, 'level 2 lands inside the first minutes — the hook');
assert.ok(prof.xpFor(prof.LEVEL_CAP) > 50000, 'the cap is a long way off — levelling is not easy');
assert.equal(prof.levelAt(0), 1, 'everyone starts at level 1');
assert.equal(prof.levelAt(prof.xpFor(7)), 7, 'levelAt inverts xpFor on the threshold');
assert.equal(prof.progress(prof.xpFor(prof.LEVEL_CAP) + 999).frac, 1, 'the cap holds at a full bar');
// steps GROW: each level costs more than the one before (fast early IS slow late)
const step = l => prof.xpFor(l + 1) - prof.xpFor(l);
assert.ok(step(40) > step(10) && step(10) > step(2), 'the ladder steepens as it climbs');
});
test('PROFILE: award pays through the one point funnel, and mutes every no-stakes mode', () => {
const xp0 = prof.prof.xp;
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'a rally point pays through advancePoint');
advancePoint('O');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'the opponent\'s point pays you nothing');
// a won game pays the game on top of its final point (3 more points win it from 1-0)
score.pts = [3, 0];
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + 2 * prof.XP.point + prof.XP.game, 'the game lands with its last point');
// every no-stakes mode is muted INSIDE award — a call site cannot forget the guard
for (const flag of ['drill', 'bench', 'testMode']) {
const before = prof.prof.xp;
G[flag] = true;
assert.equal(prof.award('kill'), 0, flag + ' pays nothing');
assert.equal(prof.prof.xp, before, flag + ' moved no xp');
G[flag] = false;
}
G.match = { train: true };
assert.equal(prof.award('point'), prof.XP.point / 4, 'the training court pays a QUARTER — reps count');
G.match = null;
assert.equal(prof.award('point'), prof.XP.point, '...and the real court pays full');
});
test('PROFILE: the run delta survives the death screen and resets with the next run', () => {
prof.beginRunXp();
const from = prof.runDelta().from;
prof.award('kill'); prof.award('point');
const d = prof.runDelta();
assert.equal(d.xp, prof.XP.kill + prof.XP.point, 'the delta is the run\'s own earnings');
assert.equal(d.from, from, 'the starting level was recorded at the run start');
restartRun(); // the tap off the death screen
assert.equal(prof.runDelta().xp, 0, 'the next run opens with a clean delta');
assert.ok(prof.prof.xp > 0, '...while the banked xp survives, which is the whole point');
});
test('PROFILE: zombie names fit the board and its charset, and the profile round-trips', () => {
for (let k = 0; k < 60; k++) {
const n = prof.zombieName();
assert.ok(n.length >= 3 && n.length <= 12, 'a name fits the 12-char board limit: ' + n);
assert.equal(n, statsMod.tidyName(n), 'a rolled name is already board-legal: ' + n);
}
prof.prof.lg = 3; prof.save();
assert.ok(prof.prof.xp >= 0 && prof.prof.lg === 3, 'the profile holds what was written');
prof.prof.lg = 1; prof.save();
});
test('THE CLIMB: the arcade runs the whole earned ladder from the bottom and clamps at your top', () => {
prof.prof.lg = 2;
assert.ok(_startMatch(), 'the arcade starts');
G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
assert.equal(G.league, 2, 'the run wears the profile league');
const d = G.match;
assert.equal(d.opp, 1, 'the climb opens at the very bottom of the ladder');
for (let b = 0; b < 8; b++) {
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
passMopBeat();
}
assert.equal(d.opp, 8, "the ladder clamps at your league's top — the cup is the only way up");
assert.equal(d.felled, 8, 'every felled body counts, clamp or no clamp');
// ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and
// read its intro modal instead of wiping it
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
let saw = null;
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !saw; i++) {
update(DT);
if (G.modal && G.modal.body) saw = G.modal.body.join(' | ');
}
assert.ok(saw && saw.includes('THE CUP IS THE WAY UP'),
'the repeat walk-on carries the promotion lesson: ' + saw);
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE LEAGUES: the top league crowns a champion instead of promoting', () => {
prof.prof.lg = 5;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.equal(d.cup.rounds.length, 3, 'a three-rung league plays three rounds');
for (let round = 0; round < d.cup.rounds.length; round++) {
const R = d.cup.rounds[round];
G.interlude.introT = 0;
for (let g = 1; g <= R.need; g++)
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
if (round < d.cup.rounds.length - 1) passMopBeat();
}
assert.ok(G.done && G.done.cup, 'the trophy screen holds');
assert.equal(prof.prof.lg, 5, 'there is no league six');
assert.equal(prof.prof.ch, 1, 'the profile is crowned');
const pr = prof.takePromo();
assert.ok(pr && pr.champion, 'the ceremony parked is the champion one');
});
test("THE LEAGUES: the pace tier follows the OPPONENT's territory, and nothing else", () => {
G.match = { opp: 1 };
assert.equal(leaguePace(), 1, 'the bottom of the ladder IS the shipped game');
G.match.opp = 4;
assert.equal(leaguePace(), 1, '...flat inside a band: league one all plays league-one tempo');
G.match.opp = 18;
assert.ok(leaguePace() > 1.2, "the champion's territory is a visibly faster world");
const climbs = [1, 5, 9, 13, 16].map(o => { G.match.opp = o; return leaguePace(); });
for (let i = 1; i < climbs.length; i++)
assert.ok(climbs[i] > climbs[i - 1], 'each band border pulls the tempo up');
G.net = { on: true };
assert.equal(leaguePace(), 1, 'netplay is a human and exempt');
G.net = null; G.match = null;
assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director');
});
test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => {
G.match = null;
G.runGames = 0;
assert.equal(duelSkill(), 0, 'a fresh run duels the shipped opponent');
G.runGames = Math.round(TUNE.duelRampGames / 2);
const mid = duelSkill();
G.runGames = TUNE.duelRampGames + 10;
assert.ok(mid > 0 && mid < 1, 'the ramp is a ramp');
assert.equal(duelSkill(), 1, '...and it caps');
G.match = { opp: 5 };
assert.equal(duelSkill(), 0, 'the match opponent has its own ladder — the ramp is duel-only');
G.match = null;
// the dial relationships the ramp promises: a full-skill duel still misses and still misreads
assert.ok(TUNE.racketFlub * (1 - TUNE.duelFlubCut) > 0.05, 'a maxed duel still whiffs sometimes');
assert.ok(TUNE.duelReadMax < 1, 'a maxed duel can still be wrong-footed');
assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality');
});
test('THE WEAPON LICENCE: the league licenses, the run paces — tins and barrels agree, league 5 pays double', () => {
G.runGames = 0;
prof.prof.lg = 5;
assert.ok(!tinLadder().includes('laser'), 'even the top league waits for the run to reach lasers');
G.runGames = 12;
prof.prof.lg = 1;
assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'),
'league one NEVER sees a laser, however deep the run — that was the barrel complaint');
prof.prof.lg = 3;
assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'),
'league three licenses the laser, not the rocket');
prof.prof.lg = 4;
assert.ok(tinLadder().includes('rocket'), 'league four licenses the rocket');
G.runGames = 0;
// the double tin: an empty rack, one delivery, twice the stock at league five
const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; };
G.rack.length = 0; G.temp = null; G.deliveries = 0;
prof.prof.lg = 5;
deliverTin();
const first = G.rack[0];
assert.equal(first.n, cfg.WEAPONS[first.id].tin * 2, 'a league-five duel pays a double tin');
G.rack.length = 0; G.temp = null; G.deliveries = 0;
prof.prof.lg = 1;
deliverTin();
assert.equal(G.rack[0].n, cfg.WEAPONS[G.rack[0].id].tin, 'league one pays the shipped tin');
});
test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is the shipped game', () => {
const xp0 = prof.prof.xp;
prof.prof.xp = 0;
const b1 = prof.bonus();
assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1, run: 1, window: 1 }, 'level one changes nothing');
prof.prof.xp = prof.xpFor(prof.LEVEL_CAP);
const bMax = prof.bonus();
assert.ok(bMax.melee < 1 && bMax.melee >= 0.7, 'the swish floor drops, boundedly');
assert.ok(bMax.parry > 1 && bMax.parry <= 1.3, 'the parry reaches further, boundedly');
assert.ok(bMax.grace > 1 && bMax.grace <= 1.4, 'the grace stretches, boundedly');
assert.ok(bMax.run > 1 && bMax.run <= 1.15, 'the legs quicken, boundedly');
assert.ok(bMax.window > 1 && bMax.window <= 1.2, 'the window widens, boundedly');
// FRONT-LOADED: level 20 must already carry more than half the cap's gift (Oliver plays there)
prof.prof.xp = prof.xpFor(20);
assert.ok((prof.bonus().run - 1) / (bMax.run - 1) > 0.5, 'the curve pays on the way up');
prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); // back to the cap for the grace-landing check below
// ...and the grace actually lands on the player: hurtPlayer writes the scaled window
G.mode = 'splatter'; G.hurtT = 0; G.dodgeI = 0; G.hp = 4;
hurtPlayer(1);
assert.ok(G.hurtT > TUNE.hurtGrace * 1.3, 'a maxed profile\'s grace window is visibly longer');
prof.prof.xp = xp0;
});
test('THE LEVEL IS YOU, in the league: legs and window grow with xp — baseline, classic and netplay untouched', () => {
const legLevel = _legLevel, winLevel = _winLevel, assistK = _assistK;
prof.prof.xp = 0;
G.match = { opp: 5 };
const legs0 = assistK();
assert.equal(legLevel(), 1, 'level one IS the shipped game');
assert.equal(winLevel(), 1, '...window too');
prof.prof.xp = prof.xpFor(20);
assert.ok(legLevel() > 1.05 && assistK() > legs0, 'level 20 legs are visibly quicker in the league');
assert.ok(winLevel() > 1.05, '...and the contact window visibly wider');
G.net = { on: true };
assert.equal(legLevel(), 1, 'netplay is a fair human match — no level legs');
assert.equal(winLevel(), 1, '...and no level window');
G.net = null; G.match = null; G.classic = true;
assert.equal(legLevel(), 1, 'classic keeps its own difficulty picker');
G.classic = false;
prof.prof.xp = 0;
});
test('THE PEER GIFT: cup and its training rep ease from fifteen levels down to parity', () => {
const peerK = _peerK, legLevel = _legLevel, winLevel = _winLevel;
G.match = { opp: 15, cup: {} }; // displayed level 41
prof.prof.xp = prof.xpFor(25);
assert.equal(peerK(), 0, 'sixteen levels down gets no gift and no penalty');
const legs25 = legLevel(), win25 = winLevel();
prof.prof.xp = prof.xpFor(36);
assert.ok(peerK() > 0.6 && peerK() < 1, 'closing most of the gap earns most of the gift');
prof.prof.xp = prof.xpFor(41);
assert.equal(peerK(), 1, 'parity earns the full gift');
assert.ok(legLevel() / legs25 > 1.1, 'level 41 has materially more reachability than level 25');
assert.ok(TUNE.swingWindow * (winLevel() - win25) > 0.025, 'the normal contact window gains over 25ms');
G.match = { opp: 15 }; // arcade: fixed comparison
assert.equal(peerK(), 0, 'arcade does not soften with opponent parity');
G.match = { opp: 15, train: true };
assert.equal(peerK(), 1, 'the exact training rep feels like the cup match');
G.net = { on: true };
assert.equal(peerK(), 0, 'netplay never reads profile advantage');
G.net = null; G.match = null; prof.prof.xp = 0;
});
const recorder = await import('./src/recorder.js');
const { fault: recordFault } = await import('./src/gameplay.js');
test('PLAYTEST: recording the level-26 cup changes neither gameplay nor its random stream', () => {
const play = enabled => {
recorder.setRecording(false);
_seed = 77123;
freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(26);
P.dustAcc = 0; // cosmetic distance accumulator survives resetState's Object.assign
if (enabled) recorder.beginRecording({ build: 'test' });
_startMatch(true);
for (let i = 0; i < 180 * 60 && G.mode !== 'eaten' && !G.done; i++) {
G.modal = null; G.modalQ.length = 0;
if (G.phase === 'pretoss' && !(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 (d < 2.9 && d > 2.2) keyPressed('Space');
}
update(DT);
}
return JSON.stringify({ seed: _seed, mode: G.mode, hp: G.hp, score, player: [P.x, P.z],
ball: [ball.x, ball.y, ball.z, ball.lastHit], opponent: G.match.opp });
};
assert.equal(play(true), play(false), 'passive instrumentation leaves the real simulation identical');
const saved = JSON.parse(recorder.recordingJSON());
assert.equal(saved.events[0].level, 26);
assert.equal(saved.events[0].mode, 'cup');
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'P' && e.kind === 'serve'));
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'O'), 'real opponent returns are captured');
const points = saved.events.filter(e => e.type === 'point');
assert.ok(points.length > 0, 'the long baseline rallies reached a scored point');
assert.ok(points.every(e => e.reason !== 'unspecified' && e.foe && e.state.games && e.run));
assert.ok(points.some(e => e.tail.length > 10), 'the point carries live movement frames');
assert.ok(points.every(e => e.tail.length <= 90), 'trajectory storage is bounded');
});
test('PLAYTEST: faults, decided points and fatal losses retain their exact scoring context', () => {
recorder.beginRecording({ build: 'test' });
startMatch(); G.interlude.introT = 0;
recordFault(); recordFault();
let data = JSON.parse(recorder.recordingJSON());
assert.equal(data.events.filter(e => e.type === 'fault').length, 2);
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]), [['O', 'double_fault']]);
G.match.decided = true; G.matchEarly = false;
resolveInterlude('P', 'double_bounce');
assert.equal(recorder.recordingInfo().points, 1, 'a spent flight cannot record a second verdict');
G.matchEarly = true;
resolveInterlude('P', 'unreachable_in_flight');
G.matchEarly = false; G.match.decided = false; G.hp = 1;
resolveInterlude('O', 'passed_player'); update(DT);
data = JSON.parse(recorder.recordingJSON());
assert.equal(data.points, 3);
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]),
[['O', 'double_fault'], ['P', 'unreachable_in_flight'], ['O', 'passed_player']]);
assert.equal(data.events.filter(e => e.type === 'point').at(-1).state.hp, 1, 'fatal point recorded before death mutates state');
assert.equal(data.events.at(-1).outcome, 'lost');
assert.ok(store.get('splatter-playtest-v1').includes('passed_player'), 'complete points persist on the phone');
recorder.setRecording(false);
});
test('PLAYTEST: storage failure and the recording limit cannot interfere with scoring', () => {
recorder.beginRecording(); startMatch();
const write = localStorage.setItem;
try {
localStorage.setItem = () => { throw Error('quota'); };
resolveInterlude('O', 'passed_player');
assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'the game still scores when storage is unavailable');
assert.ok(recorder.recordingInfo().warning.includes('Export'));
assert.ok(recorder.recordingJSON().includes('passed_player'), 'in-memory export still works');
} finally { localStorage.setItem = write; }
recorder.recordEvent('test_limit', { payload: 'x'.repeat(1500001) });
assert.ok(recorder.recordingInfo().full && !recorder.recordingInfo().active);
const previous = recorder.recordingInfo().events;
resolveInterlude('O', 'passed_player');
assert.equal(recorder.recordingInfo().events, previous, 'a full recording stops without changing the game');
assert.ok(recorder.recordingJSON().length < 1530000, 'oversized data never enters the export');
recorder.beginRecording(); recorder.setRecording(false);
});
test('PLAYTEST: Gore and netplay are excluded', () => {
recorder.beginRecording();
const before = recorder.recordingInfo().events;
recorder.recordEvent('shot');
assert.equal(recorder.recordingInfo().events, before, 'no match, no recorded play');
startMatch(); G.net = { on: true };
const matchEvents = recorder.recordingInfo().events;
recorder.recordEvent('shot'); recorder.recordFrame(DT);
assert.equal(recorder.recordingInfo().events, matchEvents, 'no network player data');
G.net = null;
recorder.setRecording(false);
});
const recoveredRecorder = await import('./src/recorder.js?reload-check');
test('PLAYTEST: reloading restores saved events without silently starting recording', () => {
assert.ok(recoveredRecorder.recordingInfo().events > 0);
assert.equal(recoveredRecorder.recordingInfo().active, false);
assert.equal(recoveredRecorder.recordingInfo().events, recorder.recordingInfo().events);
});
// File sharing is asynchronous; exercise the actual exporter with browser APIs stubbed, just
// like the game's platform above. Cancellation keeps the data; an unsupported share downloads.
{
const create = document.createElement, share = navigator.share, canShare = navigator.canShare;
let file, downloads = 0, anchor;
try {
navigator.canShare = ({ files }) => { file = files[0]; return true; };
navigator.share = async () => {};
assert.equal(await recorder.exportRecording(), 'shared');
assert.ok(file.name.endsWith('.json'));
assert.equal(JSON.parse(await file.text()).schema, 1);
navigator.share = async () => { throw Object.assign(Error('cancelled'), { name: 'AbortError' }); };
assert.equal(await recorder.exportRecording(), 'cancelled');
document.createElement = () => (anchor = { click() { downloads++; }, remove() {} });
navigator.share = async () => { throw Error('share unavailable'); };
assert.equal(await recorder.exportRecording(), 'download');
assert.equal(downloads, 1);
assert.ok(anchor.download.endsWith('.json') && anchor.href.startsWith('blob:'));
navigator.canShare = () => false;
assert.equal(await recorder.exportRecording(), 'download');
assert.equal(downloads, 2);
assert.ok(recorder.recordingInfo().events > 0, 'export never deletes the saved recording');
} finally {
document.createElement = create; navigator.share = share; navigator.canShare = canShare;
}
passed++; console.log(' ok - PLAYTEST: file sharing, cancellation and download fallback preserve the export');
}
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report