Files
splatter-tennis/test.js
T
wuwiandClaude Fable 5 4c47295a73 The friend across the net is no shambler: he just bleeds, and then he bursts
Two retest findings, one root: the death match borrowed the roster's
body. Gone — nothing spawns, no walk-on card, the living opponent is the
whole picture. His damage reads as blood (bleedO: an anchor per point
taken this game, love to game point, thin drops as ever), the game
point bursts him where he stands, and IT RISES AGAIN brings him back
clean. The heal beat became a card in netplay — a modal freezes this
world while the other phone's keeps moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:10:50 +02:00

1359 lines
75 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() {} });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = 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 } = 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 } = 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, racketStage } = cfg;
const { read: readStats, check: statsCheck } = await import('./src/stats.js');
const DT = 1 / 60;
function freshSplatter() {
restartRun();
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
}
let passed = 0;
function test(name, fn) {
freshSplatter();
fn();
passed++;
console.log(' ok - ' + name);
}
// ---- the contract -----------------------------------------------------------
test('sanity: a horde starts and owns the court in rally phase', () => {
forceZombies();
assert.ok(G.horde, 'horde should start on a clean court');
assert.equal(G.phase, 'rally', 'horde steering requires rally phase');
});
test('a duel refuses to start while a horde is live', () => {
forceZombies();
startDuel();
assert.equal(G.interlude, false, 'startDuel must refuse during a horde');
});
test('a horde refuses to start while a duel is live', () => {
startDuel();
assert.ok(G.interlude, 'duel should start on a clean court');
forceZombies();
assert.equal(G.horde, null, 'startHorde must refuse during a duel');
});
test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => {
// A game won during the set card left clearing+afterClear armed while pendingHorde cashed:
spawnZombie(0, 5, 'a');
G.clearing = true; G.afterClear = 'game'; G.duelDue = true;
forceZombies();
assert.ok(G.horde, 'setup: horde live over a latched clear');
zombies.length = 0; // the gun mows the court down to an empty frame between spawns
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; }
assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde');
assert.ok(G.horde, 'the horde must keep running');
assert.ok(G.clearing, 'the clear waits for the horde to end');
// ...and once the horde expires, the parked clear advances and the duel fires — sequentially.
for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; }
assert.equal(G.horde, null, 'horde over');
for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; }
assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde');
});
test('kills mid-duel do not score: resolveInterlude is the only exit', () => {
startDuel();
const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]);
for (let i = 0; i < 20; i++) splatPoint('P');
splatPoint('O');
assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before,
'splatPoint must be a no-op during the duel');
resolveInterlude('P');
assert.equal(G.interlude, false, 'the duel still resolves normally');
});
test('invariant net: an injected duel-inside-horde is repaired within one frame', () => {
forceZombies();
G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state
G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving
update(DT);
assert.equal(G.interlude, false, 'watchdog must drop the impossible duel');
assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it');
assert.equal(G.phase, 'rally', 'and hand the finger back to the player');
assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log');
});
test('a pending horde defers while a duel runs, then fires after it', () => {
startDuel();
G.interlude.introT = 0;
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold while the duel owns the court');
assert.ok(G.pendingHorde, 'and stay armed');
resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires
for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s
assert.ok(G.horde, 'the set still gets its horde, after the duel');
});
// ---- regressions mined from the project log (each of these shipped broken once) ----------------
test('a pending horde never pours onto a boss stage', () => {
// commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the
// boss fight. The latch must hold while the boss owns the court, and fire after.
startBossFight(3);
assert.ok(G.bossFight, 'setup: boss on court');
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the latch must hold during the boss fight');
assert.ok(G.pendingHorde, 'and stay armed');
});
test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => {
// endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the
// EPIC card before it drew a frame. The handback runs first now and the boss card wins.
startBossFight(3);
G.afterDuel = 'set'; G.seen.horde = true;
zombies.length = 0;
endBossFight();
assert.equal(G.bossFight, 0, 'fight over');
assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card');
assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde');
for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; }
assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it');
});
test('mgT counts down in every phase and releases the serve lock', () => {
// commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase
// mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock.
G.mgT = 0.5; G.gunId = 'gun';
assert.equal(G.phase, 'pretoss', 'setup: not in rally');
for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; }
assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally');
assert.equal(G.phase, 'pretoss', 'and hand back the serve loop');
});
test('a won duel pays out: the tin and the trainer', () => {
// commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel.
G.hp = G.hpMax - 3;
startDuel();
G.interlude.introT = 0;
const rackBefore = G.rack.length + (G.temp ? 1 : 0);
resolveInterlude('P');
const rackAfter = G.rack.length + (G.temp ? 1 : 0);
assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon');
assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel');
});
test('a lost duel banks their game — and their set — without a horde', () => {
score.games[1] = 5;
startDuel();
G.interlude.introT = 0;
resolveInterlude('O');
assert.equal(G.sets[1], 1, 'their 6th game banks their set');
assert.deepEqual(score.games, [0, 0], 'games reset');
assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge');
assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde');
});
// ---- the announcements: nothing that changes the control mode may arrive unannounced -----------
test('a set warns THEY KEEP COMING before the wave lands', () => {
G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement');
assert.ok(G.hordeWarn > 0, 'the breather is running');
assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so');
for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second
assert.equal(G.horde, null, 'the breather actually holds');
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.ok(G.horde, 'and the wave lands when it runs out');
});
test('a mode that steals the court during the warning re-arms the horde', () => {
G.pendingHorde = true; G.seen.horde = true;
G.phase = 'over'; G.overT = 0.01;
update(DT);
assert.ok(G.hordeWarn > 0, 'setup: warning running');
startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here
G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping)
for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT);
assert.equal(G.horde, null, 'the wave must not land inside the duel');
assert.ok(G.pendingHorde, 'the set is still owed its horde');
});
test('a chapter boss announces itself and hands the court back on its own', () => {
startBossFight(2);
assert.ok(G.modal, 'the walk-on is an overlay, not a band card');
assert.equal(G.modal.title, bossArt(2).name, 'it names the boss');
assert.deepEqual(G.modal.body, ['ENTERED THE COURT']);
for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT);
assert.ok(G.modal, 'and holds the frozen court while the player reads it');
for (let i = 0; i < 20 && G.modal; i++) update(DT);
assert.equal(G.modal, null, 'then lets go without waiting for a tap');
assert.ok(G.bossFight, 'the fight is on');
});
test('a burst fires through the beat that follows the kill that emptied the court', () => {
// The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the
// court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent
// exactly when it had killed everything. Its OWN clock is the only thing that ends it.
G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0;
G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst
pellets.length = 0;
for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill
assert.equal(G.phase, 'over', 'setup: the beat is still running');
assert.ok(pellets.length > 0, 'the burst keeps firing through it');
assert.ok(G.mgT > 0, 'and its window is still open');
});
// ---- the 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); }
};
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 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('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 } = cfg;
let lastSpeed = 0, lastWhiff = 1e9;
for (let n = 1; n <= 12; n++) { // twelve rungs since 2026-08-13 (champion caps 12)
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(12).speed ?? 1) * 0.6, 'the shambler is no champion');
// the four 2026-08-13 families wear their own bodies, and the big two carry real stature
for (const [n, fam] of [[5, 'ma'], [8, 'ph'], [11, 'dr'], [12, 'ch']])
assert.equal(matchFoe(n).fam, fam, `rung ${n} wears its own family`);
assert.ok(matchFoe(12).h > 1.85, 'the champion stands taller than a stock body');
});
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)');
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 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 TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => {
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
assert.ok(d.cup, 'the cup rides the match machinery');
for (let round = 0; round < CUP_ROUNDS.length; round++) {
const R = CUP_ROUNDS[round];
assert.equal(d.cup.round, round, 'on ' + R.name);
assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')');
const zb = oppOf();
assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round');
for (let g = 1; g <= R.need; g++) {
for (let k = 0; k < 4; k++) resolveInterlude('P');
G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away
if (g < R.need)
assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on');
}
// the round-winning game arms the KILL FALL: the body drops first, bursts after the hold
assert.ok(G.matchFall && G.matchFall.kill, R.name + ': the kill fall is armed');
for (let i = 0; i < 4 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill');
if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on
}
assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds');
assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run');
});
test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => {
// Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while
// matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the
// no-fall path re-armed synchronously, and the after-set latch went stale ("basically like
// freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the
// resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly.
const { CUP_ROUNDS } = cfg;
_startMatch(true); G.modal = null; G.modalQ.length = 0;
G.mode = 'splatter';
const d = G.match;
G.interlude.introT = 0;
for (let k = 0; k < 4; k++) resolveInterlude('P'); // round one falls to the ordinary path
G.modal = null; G.modalQ.length = 0;
passMopBeat();
assert.equal(d.cup.round, 1, 'on the quarter-final');
const zb = oppOf();
const launch = () => {
zb.x = -4; zb.z = 2;
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false;
ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity;
G.phase = 'rally'; G.wasServe = false;
const before = score.pts[0] + score.games[0] * 10;
for (let i = 0; i < 8 * 60 && score.pts[0] + score.games[0] * 10 === before && !G.matchFall; i++) update(DT);
for (let i = 0; i < 6 * 60 && (G.matchFall || G.modal); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
};
launch(); // 8 -> 7: crosses nothing (the old mispredict)
assert.equal(zb.hp, 7, 'point one chipped the pool');
assert.ok(!d.decided, 'no stale latch after a no-crossing point');
launch(); // 7 -> 6: still nothing
launch(); // 6 -> 5: the arm comes off, mid-flight
assert.ok(!d.decided, 'the fall beat ended and released its latch');
launch(); // the game point, mid-round: same body plays on
assert.ok(zombies.includes(zb), 'game one banked, the quarter-final body stands');
assert.equal(score.games[0], 2, 'the game went up');
launch(); // game two opens: the match is ALIVE
assert.equal(score.pts[0], 1, 'the next rally resolved — no freeze');
});
test('service alternates by game, and the opponent actually serves', () => {
// Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls
// advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return
// timing is half the sport and it was never asked for.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
assert.equal(score.server, 'P', 'you open the match');
for (let pt = 0; pt < 4; pt++) { // four straight points is the game
resolveInterlude('P');
for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish
}
assert.equal(score.server, 'O', 'their serve next game');
passMopBeat(); // the mop beat (and the boy's exit) come first
// the opponent stands behind ITS baseline to serve, not on the receiving mark
const rz = zombies.find(z => z.variant === 'r');
assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));
// ...and it tosses and strikes on its own, once the walk-on beat is over
let served = false;
for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; }
assert.ok(served, 'the opponent serves without the player touching anything');
});
test('a lost point never fells the opponent', () => {
// The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a
// collapse there would read as the opponent being hurt by winning.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
const hp = G.hp;
resolveInterlude('O');
assert.equal(G.matchFall, null, 'their point starts no fall');
assert.equal(G.hp, hp - 1, '...it draws your blood instead');
});
test('the placement mark fades on its own clock', () => {
// The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the
// state machine owes is that a mark never lingers — a permanent ring would read as a target the
// player must hit rather than feedback on where the last shot went.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT);
G.placeMark = { x: 2, z: 4, t: 0.8 };
for (let i = 0; i < 60; i++) update(DT);
assert.equal(G.placeMark, null, 'gone within a second');
});
test('REACHABILITY: an auto-run player can cover the first opponent\'s returns', () => {
// The test this issue always deserved and never had — "unreachable balls" came back TWICE because
// the suite only asserted the config dials, never the experience. This plays the match headless
// with the same mediocre autopilot the browser uses (auto-run + a fixed tap window, no placement)
// and asserts that the returns actually present themselves: a ball that never comes within volley
// reach of an auto-run player is unreachable BY CONSTRUCTION, whatever the knobs claim.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const returns = [];
let cur = null;
for (let i = 0; i < 90 * 60 && G.mode !== 'eaten'; i++) {
G.modal = null; G.modalQ.length = 0;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!cur) cur = { minD: 99 };
cur.minD = Math.min(cur.minD, d);
if (d < 2.9 && d > 2.2) keyPressed('Space');
}
if (cur && (!ball.active || ball.lastHit !== 'O')) { returns.push(cur.minD); cur = null; }
update(DT);
}
assert.ok(returns.length >= 10, 'the soak saw a real rally sample: ' + returns.length + ' returns');
const unreachable = returns.filter(d => d > 2.6).length; // never inside volley reach
assert.ok(unreachable / returns.length <= 0.15,
unreachable + ' of ' + returns.length + ' returns never came within reach of an auto-run player');
} finally { TUNE.autoAssist = assist; }
});
test('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;
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');
assert.equal(r.s, 2, 'sets won ride in the second headline slot');
});
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, with MEDIUM pinned dial-for-dial to the
// shipped TUNE values so the middle chip can never drift from what "no difficulty" played like.
// (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');
assert.equal(m.smart, 0, 'MEDIUM aims at random, as the shipped classic did');
assert.equal(m.recover, 0.5, 'MEDIUM keeps the shipped recovery jog');
assert.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub');
assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax');
assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone');
assert.equal(m.qMin, TUNE.aiQualityMin, 'MEDIUM floor IS aiQualityMin');
assert.equal(m.reaction, TUNE.aiReaction, 'MEDIUM reaction IS aiReaction');
assert.equal(m.reach, TUNE.aiReach, 'MEDIUM reach IS aiReach');
assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone');
});
// ---- 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');
assert.ok(!ball.active, 'the dead serve is off the court');
assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back');
});
// ---- 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: their lost points read as BLOOD, and the game bursts the body', () => {
freshNetDeath('A');
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp');
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');
score.pts = [3, 0]; // game point against them: the flow at full ramp
const before = parts.length;
for (let i = 0; i < 30; i++) update(DT);
assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)');
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
assert.equal(score.games[0], 1, 'game banked');
assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body');
assert.equal(G.hideO, true, 'the burst took the body');
for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT);
assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again');
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
});
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;
netMsg({ t: 'dead' });
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending');
assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body');
});
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'); });
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report