// 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'; // ---- 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 } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.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 } = 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'); 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 assert.ok(!zombies.includes(zb), 'the game-winning point IS the kill'); const next = oppOf(); assert.ok(next && next !== zb, 'a fresh opponent shambled on'); 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('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 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 <= 10; 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(10).speed ?? 1) * 0.6, 'the shambler is no champion'); }); 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 before it rises: a corpse that touches the ground and is // instantly upright reads as a stumble, not as something the point did to it. let downFrames = 0; for (let i = 0; i < FALL_LEN * 60 && G.matchFall && !/rise$/.test(zb.showFrame || ''); 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.match(zb.showFrame, /w1rise$/, 'it drags itself up as the one-armed body'); // ...and the point comes back only once it is standing for (let i = 0; i < FALL_LEN * 60 && G.matchFall; i++) update(DT); assert.equal(G.matchFall, null, 'the fall ends on its own clock'); 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('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'); // 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'); 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'); 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 k = 0; k < 8; k++) resolveInterlude('P'); // two games = two bodies down 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'); 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('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'); }); // 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