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