// Headless state-machine tests: `node test.js`. // The game's modules are imported for real; only the platform (DOM, WebAudio, storage) is stubbed. // These pin the court-ownership contract: horde, sudden-death duel, boss and changeover are // EXCLUSIVE control modes — no interleaving of beats may ever let two of them run at once. // Born from three field freezes: the last one was a duel starting inside a live horde // (pretoss = no steering, horde = no serving, spawns keep coming → unrecoverable). import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; // ---- DETERMINISM. This suite is a Dockerfile build stage: a red run blocks the deploy, so a test // that fails one run in five is worse than no gate at all — it trains you to re-run it. Two of the // drill tests did exactly that (they asserted on a random batch composition and on a snapshot of a // population that legitimately empties), and the pipeline caught what 60 local runs had not. // // The game leans on Math.random() everywhere — jitter, variants, lanes, spawn gaps — so the fix is // to make the STREAM reproducible rather than to strip randomness out of the game. Fixed seed by // default, and `SEED=1234 node test.js` re-rolls it: run the suite over a spread of seeds when you // touch anything probabilistic, and any failure you see is one you can reproduce exactly. // // The cost to know about: under one seed a guarded test can silently take its early-out every time // and quietly stop testing anything. If you add a `return` for an awkward deal, fuzz a few seeds and // make sure the body actually runs. let _seed = (Number(process.env.SEED) || 20260811) >>> 0; Math.random = () => { // xorshift32 — small, fast, good enough for jitter _seed ^= _seed << 13; _seed >>>= 0; _seed ^= _seed >> 17; _seed ^= _seed << 5; _seed >>>= 0; return _seed / 4294967296; }; // ---- platform stubs, installed before the game modules load ---------------- const absorb = new Proxy(function () {}, { get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb), set: () => true, apply: () => absorb, construct: () => absorb, }); const fakeCtx = () => new Proxy({}, { get: (t, p) => { if (p === 'canvas') return fakeCanvas(); if (p === 'measureText') return () => ({ width: 0 }); if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h }); if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} }); return () => undefined; }, set: () => true, }); function fakeCanvas() { return { width: 270, height: 480, style: {}, getContext: () => fakeCtx(), addEventListener() {}, removeEventListener() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }), toDataURL: () => '', }; } const store = new Map(); globalThis.localStorage = { getItem: k => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), }; globalThis.document = { getElementById: () => fakeCanvas(), createElement: () => fakeCanvas(), addEventListener() {}, removeEventListener() {}, body: { style: {}, appendChild() {} }, }; globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional) globalThis.AudioContext = function () { return absorb; }; globalThis.webkitAudioContext = globalThis.AudioContext; globalThis.matchMedia = () => ({ matches: false, addEventListener() {} }); // haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL // navigator and it is getter-only, so the stub has to be defined over it rather than assigned. globalThis.hapticLog = []; Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } }); globalThis.addEventListener = globalThis.addEventListener || (() => {}); globalThis.removeEventListener = globalThis.removeEventListener || (() => {}); globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} }; globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting globalThis.requestAnimationFrame = () => 0; globalThis.devicePixelRatio = 2; globalThis.innerWidth = 390; globalThis.innerHeight = 844; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs, startStory, startStoryChapter } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // player would, so the tests step a live court const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; const { zombies, spawnZombie, pellets, parts, explode, hurtPlayer, firePellet } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const cfg = await import('./src/config.js'); const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg; const statsMod = await import('./src/stats.js'); const audio = await import('./src/audio.js'); const { hapticTest } = await import('./src/haptics.js'); const prof = await import('./src/profile.js'); const { advancePoint } = await import('./src/gameplay.js'); const { read: readStats, check: statsCheck } = statsMod; const DT = 1 / 60; function freshSplatter() { restartRun(); // THE LEAGUES: a won cup PROMOTES the profile (by design), which would leak league 2 into every // test after the tournament walkthrough — each test opens in league 1 with no ceremony pending. // XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from // earlier tests would silently buff every later one. prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo(); // THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly // the way the promotion did — the training court reads `out`, and a stale one reorders its list prof.prof.cups = {}; prof.prof.at = {}; prof.prof.out = 0; prof.prof.story = prof.freshStory(); // THE STORY's place leaks between tests the same way G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); } let passed = 0; function test(name, fn) { freshSplatter(); fn(); passed++; console.log(' ok - ' + name); } // ---- the contract ----------------------------------------------------------- test('sanity: a horde starts and owns the court in rally phase', () => { forceZombies(); assert.ok(G.horde, 'horde should start on a clean court'); assert.equal(G.phase, 'rally', 'horde steering requires rally phase'); }); test('a duel refuses to start while a horde is live', () => { forceZombies(); startDuel(); assert.equal(G.interlude, false, 'startDuel must refuse during a horde'); }); test('a horde refuses to start while a duel is live', () => { startDuel(); assert.ok(G.interlude, 'duel should start on a clean court'); forceZombies(); assert.equal(G.horde, null, 'startHorde must refuse during a duel'); }); test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => { // A game won during the set card left clearing+afterClear armed while pendingHorde cashed: spawnZombie(0, 5, 'a'); G.clearing = true; G.afterClear = 'game'; G.duelDue = true; forceZombies(); assert.ok(G.horde, 'setup: horde live over a latched clear'); zombies.length = 0; // the gun mows the court down to an empty frame between spawns for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde'); assert.ok(G.horde, 'the horde must keep running'); assert.ok(G.clearing, 'the clear waits for the horde to end'); // ...and once the horde expires, the parked clear advances and the duel fires — sequentially. for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; } assert.equal(G.horde, null, 'horde over'); for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; } assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde'); }); test('kills mid-duel do not score: resolveInterlude is the only exit', () => { startDuel(); const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]); for (let i = 0; i < 20; i++) splatPoint('P'); splatPoint('O'); assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before, 'splatPoint must be a no-op during the duel'); resolveInterlude('P'); assert.equal(G.interlude, false, 'the duel still resolves normally'); }); test('invariant net: an injected duel-inside-horde is repaired within one frame', () => { forceZombies(); G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving update(DT); assert.equal(G.interlude, false, 'watchdog must drop the impossible duel'); assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it'); assert.equal(G.phase, 'rally', 'and hand the finger back to the player'); assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log'); }); test('a pending horde defers while a duel runs, then fires after it', () => { startDuel(); G.interlude.introT = 0; G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the latch must hold while the duel owns the court'); assert.ok(G.pendingHorde, 'and stay armed'); resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s assert.ok(G.horde, 'the set still gets its horde, after the duel'); }); // ---- regressions mined from the project log (each of these shipped broken once) ---------------- test('a pending horde never pours onto a boss stage', () => { // commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the // boss fight. The latch must hold while the boss owns the court, and fire after. startBossFight(3); assert.ok(G.bossFight, 'setup: boss on court'); G.pendingHorde = true; G.seen.horde = true; G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the latch must hold during the boss fight'); assert.ok(G.pendingHorde, 'and stay armed'); }); test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => { // endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the // EPIC card before it drew a frame. The handback runs first now and the boss card wins. startBossFight(3); G.afterDuel = 'set'; G.seen.horde = true; zombies.length = 0; endBossFight(); assert.equal(G.bossFight, 0, 'fight over'); assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card'); assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde'); for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; } assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it'); }); test('mgT counts down in every phase and releases the serve lock', () => { // commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase // mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock. G.mgT = 0.5; G.gunId = 'gun'; assert.equal(G.phase, 'pretoss', 'setup: not in rally'); for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; } assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally'); assert.equal(G.phase, 'pretoss', 'and hand back the serve loop'); }); test('a won duel pays out: the tin and the trainer', () => { // commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel. G.hp = G.hpMax - 3; startDuel(); G.interlude.introT = 0; const rackBefore = G.rack.length + (G.temp ? 1 : 0); resolveInterlude('P'); const rackAfter = G.rack.length + (G.temp ? 1 : 0); assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon'); assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel'); }); test('a lost duel banks their game — and their set — without a horde', () => { score.games[1] = 5; startDuel(); G.interlude.introT = 0; resolveInterlude('O'); assert.equal(G.sets[1], 1, 'their 6th game banks their set'); assert.deepEqual(score.games, [0, 0], 'games reset'); assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge'); assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde'); }); // ---- the announcements: nothing that changes the control mode may arrive unannounced ----------- test('a set warns THEY KEEP COMING before the wave lands', () => { G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement'); assert.ok(G.hordeWarn > 0, 'the breather is running'); assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so'); for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second assert.equal(G.horde, null, 'the breather actually holds'); for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); assert.ok(G.horde, 'and the wave lands when it runs out'); }); test('a mode that steals the court during the warning re-arms the horde', () => { G.pendingHorde = true; G.seen.horde = true; G.phase = 'over'; G.overT = 0.01; update(DT); assert.ok(G.hordeWarn > 0, 'setup: warning running'); startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping) for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); assert.equal(G.horde, null, 'the wave must not land inside the duel'); assert.ok(G.pendingHorde, 'the set is still owed its horde'); }); test('a chapter boss announces itself and hands the court back on its own', () => { startBossFight(2); assert.ok(G.modal, 'the walk-on is an overlay, not a band card'); assert.equal(G.modal.title, bossArt(2).name, 'it names the boss'); assert.deepEqual(G.modal.body, ['ENTERED THE COURT']); for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT); assert.ok(G.modal, 'and holds the frozen court while the player reads it'); for (let i = 0; i < 20 && G.modal; i++) update(DT); assert.equal(G.modal, null, 'then lets go without waiting for a tap'); assert.ok(G.bossFight, 'the fight is on'); }); test('a burst fires through the beat that follows the kill that emptied the court', () => { // The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the // court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent // exactly when it had killed everything. Its OWN clock is the only thing that ends it. G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0; G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst pellets.length = 0; for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill assert.equal(G.phase, 'over', 'setup: the beat is still running'); assert.ok(pellets.length > 0, 'the burst keeps firing through it'); assert.ok(G.mgT > 0, 'and its window is still open'); }); // ---- the triage drill: one rule, and every part of it pinned -------------------------------- // The rule: always kill the body that would reach you first (arrival = distance / speed down a held // lane). Wounds are free, the slack forgives near-ties, the streak is the punishment and the payout. function drillPlaying(seconds = 1) { startDrill(); for (let i = 0; i < seconds * 60; i++) update(DT); } // kill a specific body through the shared funnel, via the ball's contact test const drillKillBody = zb => { for (let i = 0; i < 60 && zombies.includes(zb); i++) { ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0; update(DT); } assert.ok(!zombies.includes(zb), 'the body should be down'); }; // a hand-built queue with KNOWN arrivals, so a test judges against arithmetic rather than luck: // far-but-fast arrives at t=4, near-but-slow at t=8, dawdler at t=16 function riggedQueue() { startDrill(); zombies.length = 0; G.drill.spawnT = 999; // hold the stream while the fixture stands const rig = []; for (const [z, v] of [[COURT.L - 8, 2.0], [COURT.L - 4, 0.5], [COURT.L - 16, 1.0]]) { spawnZombie(0, z, 'a'); const zb = zombies[zombies.length - 1]; zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1; rig.push(zb); } return rig; // [first, second, third] by arrival: 4 s, 8 s, 16 s } test('a drill owns the court: every other mode refuses while it runs', () => { assert.ok(startDrill(), 'a drill starts on a clean court'); startDuel(); assert.equal(G.interlude, false, 'startDuel must refuse during a drill'); forceZombies(); assert.equal(G.horde, null, 'startHorde must refuse during a drill'); startBossFight(1); assert.equal(G.bossFight, 0, 'startBossFight must refuse during a drill'); assert.equal(startDrill(), false, 'and a drill must refuse a second drill'); }); test('a drill refuses to start while another mode owns the court', () => { startDuel(); assert.ok(G.interlude, 'setup: the duel has the court'); assert.equal(startDrill(), false, 'startDrill must refuse during a duel'); assert.equal(G.drill, null, 'and leave no half-built state behind'); }); test('the queue ranks by ARRIVAL, not distance: far-but-fast outranks near-but-slow', () => { const [fast, slow] = riggedQueue(); const q = drillQueue(); assert.equal(q[0], fast, 'the 2.0 m/s body 8 m out arrives first (4 s vs 8 s)'); assert.equal(q[1], slow, 'the 0.5 m/s body 4 m out is second'); assert.ok(arrival(q[0]) < arrival(q[1]) && arrival(q[1]) < arrival(q[2]), 'arrivals are ordered'); }); test('killing the queue head grows the streak and banks points', () => { const [fast] = riggedQueue(); const s0 = G.drill.score; drillKillBody(fast); assert.equal(G.drill.streak, 1, 'a correct kill grows the streak'); assert.equal(G.drill.faults, 0, 'and is no fault'); assert.ok(G.drill.score > s0, 'and banks points'); }); test('killing out of turn is a fault, resets the streak, and sweeps nothing', () => { const rig = riggedQueue(); G.drill.streak = 7; G.drill.score = 500; drillKillBody(rig[2]); // the 16 s dawdler, with a 4 s body still walking assert.equal(G.drill.faults, 1, 'a misread faults'); assert.equal(G.drill.streak, 0, 'and the streak dies'); assert.equal(G.drill.score, 500, 'the banked score is untouched'); assert.ok(zombies.includes(rig[0]) && zombies.includes(rig[1]), 'the court is NOT swept — the queue keeps walking'); }); test('the slack forgives a near-tie', () => { startDrill(); zombies.length = 0; G.drill.spawnT = 999; for (const dz of [6, 6.3]) { // two bodies arriving ~0.3 s apart at 1 m/s spawnZombie(dz > 6 ? 2 : -2, COURT.L - dz, 'a'); const zb = zombies[zombies.length - 1]; zb.drill = true; zb.pts = 20; zb.v = 1.0; zb.hp = zb.hpMax = 1; } const second = zombies[zombies.length - 1]; drillKillBody(second); // kill #2 of a 0.3 s tie: inside drillSlack 0.9 assert.equal(G.drill.faults, 0, 'inside the slack, either of a near-tie is legal'); assert.equal(G.drill.streak, 1, 'and it counts'); }); test('wounds are free: softening a back-of-queue heavy is never judged', () => { startDrill(); zombies.length = 0; G.drill.spawnT = 999; spawnZombie(0, COURT.L - 3, 'a'); // the urgent one, 3 s out const urgent = zombies[zombies.length - 1]; urgent.drill = true; urgent.pts = 20; urgent.v = 1.0; urgent.hp = urgent.hpMax = 1; spawnZombie(2, COURT.L - 18, 'g'); // a brute at the back, 5 hp const brute = zombies[zombies.length - 1]; brute.drill = true; brute.pts = 80; brute.v = 1.0; ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; ball.x = brute.x; ball.z = brute.z; ball.vx = ball.vy = ball.vz = 0; update(DT); assert.ok(zombies.includes(brute), 'setup: the brute survived the hit'); assert.ok(brute.hp < brute.hpMax, 'setup: but is wounded'); assert.equal(G.drill.faults, 0, 'a wound out of turn is NOT a fault — only the kill is judged'); }); test('the multiplier steps with the streak and caps', () => { startDrill(); const d = G.drill; d.streak = 0; assert.equal(drillMult(d), 1); d.streak = TUNE.drillMultEvery; assert.equal(drillMult(d), 2); d.streak = TUNE.drillMultEvery * 9; assert.equal(drillMult(d), TUNE.drillMultCap, 'capped'); }); test('the best score survives in localStorage', () => { startDrill(); const [fast] = riggedQueue(); G.drill.score = 777; G.drill.best = 0; drillKillBody(fast); // any correct kill banks through bankBest assert.ok(G.drill.best >= 777, 'the run became the best'); assert.ok(Number(localStorage.getItem('drillBest')) >= 777, 'and it is written down'); }); test('the stream keeps arriving, capped and spaced', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; try { startDrill(); const seeded = zombies.length; let peak = 0; for (let i = 0; i < 18 * 60; i++) { update(DT); peak = Math.max(peak, zombies.length); } assert.ok(peak > seeded, 'bodies keep arriving: peak ' + peak); assert.ok(peak <= TUNE.drillMaxOnCourt, 'never past the court cap'); for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++) assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8, 'two bodies ended up on top of each other'); } finally { TUNE.eatenR = eaten; } }); test('being caught sweeps the court and kills the streak, but the score survives', () => { drillPlaying(); G.drill.score = 900; G.drill.streak = 6; killPlayer(); assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); assert.equal(G.drill.lost, 1, 'it is a loss'); assert.equal(G.drill.streak, 0, 'the streak dies'); assert.equal(G.drill.score, 900, 'the score does not'); assert.equal(zombies.length, 0, 'the court is swept'); assert.equal(ball.active, false, 'and the finishing ball is spent — the court refills up-court'); for (let i = 0; i < 10 * 60; i++) update(DT); assert.ok(zombies.length > 0, 'then the stream simply resumes'); }); test('a body that walks past you counts as caught, never as a free escape', () => { drillPlaying(); const runaway = zombies.find(z => z.drill); assert.ok(runaway, 'setup: the stream is on court'); runaway.x = P.x + 5; runaway.z = COURT.L + 1; const lost = G.drill.lost; update(DT); assert.equal(G.drill.lost, lost + 1, 'it costs the point'); assert.ok(!zombies.includes(runaway), 'and it is gone'); }); test('drillOneShot is a switch, and OFF means the roster HP is intact', () => { startDrill(); for (let i = 0; i < 30 * 60 && !zombies.some(z => 'gkhde'.includes(z.variant)); i++) update(DT); const heavy = zombies.find(z => 'gkhde'.includes(z.variant)); if (!heavy) return; // this seed dealt only shamblers in the window if (TUNE.drillOneShot >= 0.5) assert.equal(heavy.hp, 1, 'one-shot on: everything is glass'); else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp'); }); test('the rule announces itself once, and the markers are the legal SET', () => { _startDrill(); // the raw one: we WANT the modal this time assert.ok(G.modal, 'first drill of a run: the rule gets its one sentence'); assert.equal(G.modal.title, 'CLEARANCE DRILL'); G.modal = null; endDrill(); _startDrill(); assert.equal(G.modal, null, 'second drill of the same run: no re-teaching'); G.modal = null; // the legal set: everything within the slack of the head, and never anything beyond it zombies.length = 0; G.drill.spawnT = 999; for (const [dz, v] of [[6, 1.0], [6.5, 1.0], [16, 1.0]]) { // 6 s, 6.5 s, 16 s out spawnZombie(0, COURT.L - dz, 'a'); const zb = zombies[zombies.length - 1]; zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1; } const legal = drillLegal(); assert.equal(legal.length, 2, 'the 0.5 s tie is IN the marker set (slack ' + TUNE.drillSlack + ')'); assert.ok(!legal.some(z => Math.abs(arrival(z) - 16) < 0.5), 'the 16 s body is not'); }); test('endDrill hands the court back', () => { drillPlaying(); endDrill(); assert.equal(G.drill, null); assert.equal(zombies.length, 0, 'the drill takes its bodies with it'); assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again'); }); // ---- zombie tennis: one game per opponent, blood only ------------------------------------------ const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; }; const oppOf = () => zombies.find(z => z.variant === 'r'); // a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on — plus // the boy's walk-off hold (he must LEAVE the court) and possibly the WOUNDS TREATED popup, which // freezes the world until tapped. Tests that play across a game boundary step through all of it, // dismissing popups the way a player would. const passMopBeat = () => { // ...stopping at the walk-on: past it, the next opponent PLAYS // the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); } // the walk-on is a MODAL now (2026-08-28) and it arrives WITH the opponent — tap it away too, // or every step after this one runs against a frozen world G.modal = null; G.modalQ.length = 0; }; test('a match owns the court like every other mode', () => { assert.ok(startMatch(), 'a match starts on a clean court'); assert.ok(G.interlude, 'and runs on a permanent interlude — that is the design'); startDuel(); // interlude guard catches it forceZombies(); assert.equal(G.horde, null, 'no horde into a match'); startBossFight(1); assert.equal(G.bossFight, 0, 'no boss into a match'); assert.equal(startDrill(), false, 'no drill into a match'); assert.equal(startMatch(), false, 'no second match'); endMatch(); assert.ok(startDrill(), 'endMatch hands the court back'); }); test('a point won tears a wound stage off the body; only the game point fells it', () => { startMatch(); const zb = oppOf(); assert.equal(zb.hpMax, TUNE.matchOppHp, 'opponent one carries the base pool'); for (let k = 1; k <= 3; k++) { resolveInterlude('P'); assert.ok(zombies.includes(zb), 'point ' + k + ': still standing'); assert.ok(zb.hp < zb.hpMax, '...and visibly wounded (' + zb.hp + '/' + zb.hpMax + ')'); } assert.equal(zb.hp, 1, 'three points in: one stage left'); resolveInterlude('P'); // the game point // THE KILL FALL: the game point fells the body but no longer bursts it on the same frame — // the collapse plays, the prone hold holds, and only then the explosion (Oliver: "it would be // better if the explosion would happen after the fall"). assert.ok(zombies.includes(zb), 'the game point drops the body — the burst waits for the fall'); assert.ok(G.matchFall && G.matchFall.kill, 'the kill fall is armed'); assert.ok(G.interlude && G.interlude.introT > 0, 'serving is barred while it plays'); for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT); assert.ok(!zombies.includes(zb), 'the fallen body bursts after the prone hold'); assert.ok(!(G.match.mopT > 0), '...and the finish still waits for the smoke to be seen'); for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !(G.match.mopT > 0); i++) update(DT); // THE MOP BEAT: the corpse's gore holds the court first — no fresh body until the boy has had // his time (TUNE.matchMopT, skipped only on a clean court; the explode() above guarantees gore). assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court'); assert.ok(G.match.mopT > 0, 'the beat is armed'); assert.ok(G.interlude && G.interlude.introT > 0, 'and serving is barred for its whole length'); // ...and the beat now also WAITS FOR THE BOY TO LEAVE the court before the walk-on for (let i = 0; i < (TUNE.matchMopT + 2) * 10 && G.boy && Math.abs(G.boy.x) < 6.5; i++) update(0.1); assert.ok(!oppOf(), 'still no opponent while the boy is on the court'); passMopBeat(); const next = oppOf(); assert.ok(next && next !== zb, 'a fresh opponent shambled on after the mop beat'); assert.equal(next.hpMax, TUNE.matchOppHp, 'same flat pool — the ladder escalates in speed/flub, not hp'); assert.equal(G.runGames, 1, 'the ladder banked the game'); assert.deepEqual(score.pts, [0, 0], 'and the points reset'); }); test('the WOUNDS TREATED card lands after the explosion, never on top of it', () => { startMatch(); const zb = oppOf(); G.hp = G.hpMax - 2; // something for the trainer to treat for (let k = 0; k < 4; k++) resolveInterlude('P'); // a straight game assert.ok(!G.modal, 'no card while the body is still falling'); for (let i = 0; i < (0.3 + TUNE.matchBoomHold) * 60 + 5 && zombies.includes(zb); i++) update(DT); assert.ok(!zombies.includes(zb) && !G.modal, 'no card on the burst frame either'); for (let i = 0; i < (TUNE.matchBoomCard + 0.5) * 60 && !G.modal; i++) update(DT); assert.ok(G.modal && G.modal.title === 'WOUNDS TREATED', 'the trainer card follows the burst'); }); test('their points draw blood from the MATCH pool, and running dry is the run', () => { startMatch(); assert.equal(G.hp, TUNE.matchPlayerHp, 'the match brings its own, deeper pool'); resolveInterlude('O'); assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'a point costs one'); G.hp = 1; resolveInterlude('O'); assert.equal(G.mode, 'eaten', 'blood only — and it runs out the way it always does'); }); test('a match ball never wounds the body: the score is the only way through', () => { startMatch(); const zb = oppOf(); // a full-power ball straight into the opponent — the regicide path, now closed for matches ball.active = true; ball.lastHit = 'P'; ball.y = 1.2; ball.pow = 1; ball.bounces = 0; ball.x = zb.x; ball.z = zb.z; ball.vx = 0; ball.vy = 0; ball.vz = -1; for (let i = 0; i < 5; i++) update(DT); assert.ok(zombies.includes(zb), 'still standing'); assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match'); }); test('THE WOUNDS PLAY: a wounded opponent covers less and whiffs more', () => { startMatch(); const zb = oppOf(); const w = f => matchWound({ hp: zb.hpMax * f, hpMax: zb.hpMax }); assert.deepEqual(w(1), { reach: 1, flub: 1 }, 'an unhurt body plays exactly as it always did'); const arm = w(0.5), head = w(0.25); // one point in is still stage 0 — a quarter is 0.75 assert.ok(head.reach < arm.reach && arm.reach < 1, 'reach falls with every stage it loses'); assert.ok(head.flub > arm.flub && arm.flub > 1, '...and the whiff rate climbs with it'); assert.ok(head.reach > 0.85 && head.flub < 1.5, 'and it stays TEXTURE under the ladder, not a second difficulty curve: ' + JSON.stringify(head)); // ...and it is wired to the racket, not just to the table: a gap the whole body covers and the // headless one cannot. ONE frame, so its own chase (~0.06 m) can never close the 0.19 m gap. const swingsAt = (hp, d) => { zb.hp = hp; zb.swingCool = 0; zb.swingT = -1; ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.pow = 0; ball.x = zb.x + d; ball.z = zb.z; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = -1; // incoming, or racketAI ignores it entirely update(DT); return zb.swingCool > 0; // a whiff arms the cooldown too — this is REACH }; const gap = TUNE.aiReach * (1 + head.reach) / 2; // between the two reaches assert.ok(swingsAt(zb.hpMax, gap), 'the whole body reaches ' + gap.toFixed(2) + ' m'); assert.ok(!swingsAt(1, gap), '...and the headless one does not'); }); test('the FLICK LESSON arms on a run of aimless returns and dies on an aimed one', () => { assert.equal(flatCount(0, { ax: 0, az: 0 }), 1, 'a flat return is evidence'); assert.equal(flatCount(3, { ax: 0, az: -1 }), 4, '...and so is a depth-only one: this teaches WIDTH'); assert.equal(flatCount(9, { ax: -1, az: 0 }), 0, 'one aimed return and the lesson is over'); assert.equal(flatCount(9, null), 10, 'no aim at all is the flattest shot there is'); assert.ok(TUNE.flickHintAfter > 2, 'hitting flat is legal — it takes a RUN of them to nag'); // the wiring: a real match return with nothing on it counts itself startMatch(); const zb = oppOf(); G.interlude.introT = 0; // the umpire has finished announcing — the tap is a swing now G.flatStreak = 0; G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1; ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; keyPressed('Space'); for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT); assert.equal(ball.lastHit, 'P', 'he got a racket on it'); assert.equal(G.flatStreak, 1, 'and the flat return was counted'); assert.ok(zombies.includes(zb), '(the body is untouched by any of this)'); }); test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => { // The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first // fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat // until dead. Oliver hit it: "when trying to serve after i got hurt i get hurt again and again // until i die". startMatch(); G.serveNum = 2; // a fault has burned the first serve resolveInterlude('O'); // the point is lost (double fault, passing shot, whatever) assert.equal(G.serveNum, 1, 'the NEXT point starts on a first serve'); resolveInterlude('P'); assert.equal(G.serveNum, 1, '...whoever won it'); }); test('SECOND SERVICE: a missed serve in the match faults instead of losing the point', () => { // Oliver, 2026-08-22: "when serving results in an out there should be a second service... the // umpire should announce 2nd service and the player must get another chance." The duel and the // match had no service rule at all — the first serve was judged as an ordinary ground stroke, so // a serve long or into the net was a point, and in the match a point costs blood. startMatch(); for (let i = 0; i < 2.6 * 60; i++) update(DT); // the walk-on beat, then his serve const zb = oppOf(); const pts = score.pts.slice(), hp = G.hp; zb.x = 3.9; zb.z = 5.5; // it chased the fault out to the wing const serveOut = () => { // a serve that lands past the far baseline G.phase = 'rally'; G.wasServe = true; ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0; ball.x = 0.4; ball.z = 4; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -20; ball.g = TUNE.gravity; for (let i = 0; i < 60 && ball.active; i++) update(DT); }; serveOut(); assert.equal(G.serveNum, 2, 'the first miss buys a second serve'); assert.deepEqual(score.pts, pts, '...and costs no point'); assert.equal(G.hp, hp, '...and no blood'); assert.equal(G.matchFall, null, 'nobody falls over a fault'); assert.equal(zb.z, 1.0, 'the body is back on its receiving mark for the second serve'); assert.equal(zb.x, O.x, '...on the same mark the invisible tennis body serves against'); serveOut(); // the double fault assert.equal(score.pts[1], pts[1] + 1, 'two misses is the point, theirs'); assert.equal(G.hp, hp - 1, '...and the match scores it in blood, like every other lost point'); assert.equal(G.serveNum, 1, 'the next point starts on a first serve'); }); test('SECOND SERVICE: a serve that lands IN is still played, fault or not', () => { // The guard is on the OUT branch only — the mode's other rules (the early-decided winner, the // second bounce) must not learn about serves. startMatch(); for (let i = 0; i < 2.6 * 60; i++) update(DT); const pts = score.pts.slice(); G.phase = 'rally'; G.wasServe = true; ball.active = true; ball.scored = false; ball.lastHit = 'P'; ball.bounces = 0; ball.x = 0.4; ball.z = 8; ball.y = 0.5; ball.vx = 0; ball.vy = -1; ball.vz = -8; ball.g = TUNE.gravity; for (let i = 0; i < 30 && ball.bounces < 1; i++) update(DT); assert.equal(ball.bounces, 1, 'setup: it bounced'); assert.ok(inCourt(ball.x, ball.z), '...in court'); assert.equal(G.serveNum, 1, 'a legal serve is no fault'); assert.deepEqual(score.pts, pts, 'and no point either — it is a rally now'); }); test('a duel opens on a FIRST serve, whatever the last one ended on', () => { // Same shape as the match's death loop, one mode over: nothing in startDuel reset G.serveNum, and // now that a duel can fault, a fault in one duel would have armed second-serve in the next. restartRun(); G.mode = 'splatter'; G.serveNum = 2; startDuel(); assert.equal(G.serveNum, 1, 'the duel is served for the first time'); }); test('a receiver stands BEHIND his baseline, on both sides of the net', () => { // Oliver: "when the opponent serves the player does not stand at the baseline — but that's how it // should be." Both marks used to sit over a metre inside the court. restartRun(); score.server = 'O'; setupPoint(); assert.ok(P.z > COURT.L, 'receiving, the player waits behind his own baseline: z=' + P.z); assert.ok(O.z < -0.2, '...and the server is behind his: z=' + O.z); score.server = 'P'; setupPoint(); assert.ok(O.z < 0, 'and mirrored, the far receiver is behind the far baseline: z=' + O.z); assert.ok(P.z > COURT.L, '...with the server behind his own line'); }); test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => { // Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the // opponent sees the ball really fast — the player barely sees the speed increase." The ball IS // the same ball on both phones (net.js copies the velocities verbatim); what differs is the // camera, which compresses your own outgoing ball into the distance and expands theirs as it // comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's // LENGTH (render), the crack's pitch, and this. restartRun(); G.classic = true; score.server = 'P'; setupPoint(); ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0; G.shake = null; hitServe(P, 1, null, true); // a flush serve: ~49 m/s assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1))); const hard = G.shake.mag; // ...and a slow ball does not. Same call, no timing on it. score.server = 'P'; setupPoint(); ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0; G.shake = null; hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly'); assert.equal(G.shake, null, 'a slow serve does not kick the camera'); assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')'); }); test('the match brings its own legs: auto-run works even where autoAssist is 0', () => { // The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a // racket — the flight recorder showed a swing in every lost point and an x that never moved. const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop condition try { startMatch(); for (let i = 0; i < 2.2 * 60; i++) update(DT); G.phase = 'rally'; P.x = 0; P.z = 23; ball.active = true; ball.lastHit = 'O'; ball.x = -3; ball.z = 14; ball.y = 1.4; ball.vx = -1; ball.vy = 2; ball.vz = 5; // incoming, wide of him const x0 = P.x; for (let i = 0; i < 45; i++) update(DT); assert.ok(Math.abs(P.x - x0) > 0.5, 'he RUNS for it with no manual input and no coarse pointer: ' + x0.toFixed(1) + ' -> ' + P.x.toFixed(1)); } finally { TUNE.autoAssist = assist; } }); test('THE SHORT BALL: the assist runs FORWARD onto a dying return, desktop conditions included', () => { // Seven of seven recorded losses: a slow return bounced and died at z 18-20 — outside the // intercept zone, so nothing chased it and it rolled dead at his feet (below legal contact // height). Doubly broken on desktop: intercept computed reachability with autoAssist (0 there), // so its answer was always "run home". Both fixed via assistK() + the anyHit candidate. const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop pane condition try { startMatch(); for (let i = 0; i < 2.2 * 60; i++) update(DT); G.phase = 'rally'; P.x = 0; P.z = 23; // a slow short return: incoming, will bounce and die around z ~19 ball.active = true; ball.lastHit = 'O'; ball.x = 1.5; ball.z = 10; ball.y = 1.6; ball.vx = 0.4; ball.vy = 2.2; ball.vz = 5.5; ball.bounces = 0; ball.g = TUNE.gravity; const z0 = P.z; let minGap = 99, minPz = P.z; // deepest he RAN, read during the chase — the point reset // teleports him home after, so the final position proves nothing for (let i = 0; i < 2.5 * 60 && ball.active && ball.lastHit === 'O'; i++) { update(DT); minPz = Math.min(minPz, P.z); minGap = Math.min(minGap, Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8)); } assert.ok(minPz < z0 - 3.0, 'he ran FORWARD to meet it: z ' + z0 + ' -> ' + minPz.toFixed(1)); assert.ok(minGap < 1.6, 'and got the ball into reach (closest ' + minGap.toFixed(1) + ' m)'); } finally { TUNE.autoAssist = assist; } }); test('the match recovers to CENTRE between shots — a wide return must not strand you', () => { // The match inherited gore-splatter's depth-only recovery (a sidestep-protection rule) by being // G.mode 'splatter' — so one wide return pulled you off centre and you STAYED there, and the next // ball to the opposite corner was unreachable before it was struck. "Every second ball is // basically unreachable" was this, not CPU strength. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; // headless matchMedia reports desktop: assist off try { startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); G.phase = 'rally'; P.x = 4.2; P.z = 22.8; // stranded wide by the last return ball.active = true; ball.lastHit = 'P'; ball.vz = -8; ball.z = 8; ball.y = 1; // your ball, outbound: not chasing const x0 = P.x; for (let i = 0; i < 60; i++) update(DT); assert.ok(Math.abs(P.x) < Math.abs(x0) - 0.5, 'auto-run walks back toward centre: ' + x0.toFixed(1) + ' -> ' + P.x.toFixed(1)); } finally { TUNE.autoAssist = assist; } }); test('the tennis timing cues belong to CLASSIC tennis too, not just the match', () => { // Reported three times before it was believed, and it was a one-word gate each time: drawApproachRing // and drawApexBand both opened with `if (!G.match ...)`, so the mode whose whole pitch is "if the // core tennis is good everything else is good" was the one mode without the cues that make the // core tennis readable. Asserted on the SOURCE because these are draw calls with no state to // observe — what matters is that neither is gated on the match alone ever again. const src = readFileSync(new URL('./src/render.js', import.meta.url), 'utf8'); for (const fn of ['drawApproachRing', 'drawApexBand']) { const body = src.slice(src.indexOf('function ' + fn), src.indexOf("function " + fn) + 900); assert.ok(!/if \(!G\.match \|\|/.test(body), `${fn} must not be gated on the match alone`); assert.ok(/G\.match \|\| G\.mode === 'tennis'/.test(body), `${fn} must also run in classic tennis`); } }); test('the opponent card is a RAMP: it starts easy and only gets harder', () => { // "the players should start easy and get harder over time." That is a property of the table, not // of any code, so the table is what gets asserted — a row edited into the wrong slot is otherwise // invisible until someone plays ten opponents deep. const { matchFoe, TUNE, LADDER } = cfg; let lastSpeed = 0, lastWhiff = 1e9; // read off the table rather than pinned: the ladder went 12 -> 18 on 2026-08-15 and a hardcoded // count here would simply have stopped testing the six new rungs without failing for (let n = 1; n <= LADDER; n++) { const f = matchFoe(n); const speed = (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (f.speed ?? 1); const whiff = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, n - 1) * (f.flub ?? 1); assert.ok(whiff < lastWhiff, `opponent ${n} (${f.short}) must whiff less than the one before`); lastWhiff = whiff; // Speed is allowed to ZIGZAG by character — opponent 5 is the RUNNER and opponent 6 paints // lines instead of sprinting — so this bans a real regression, not a personality. The whiff // rate above is the dial that must fall every single rung; it is the one the ramp rides on. if (n > 1) assert.ok(speed > lastSpeed * 0.88, `opponent ${n} (${f.short}) must not be a big step BACKWARDS in speed`); lastSpeed = speed; assert.ok(f.name && f.short && f.line, `opponent ${n} is fully named`); assert.ok(f.short.length <= 8, `${f.short} fits the board's 8-char slot`); } // ...and the opening must actually be gentle: opponent one is the softest thing on the card. assert.ok((matchFoe(1).speed ?? 1) < (matchFoe(LADDER).speed ?? 1) * 0.6, 'the shambler is no champion'); // every rung that has its own family wears it — the 2026-08-15 six included, whose whole point is // that the rung and the thing that hunts you in the gore mode are the same person for (const [n, fam] of [[3, 'sp'], [5, 'rm'], [7, 'ma'], [9, 'sb'], [10, 'sg'], [12, 'cu'], [13, 'ph'], [14, 'hc'], [17, 'dr'], [18, 'ch']]) assert.equal(matchFoe(n).fam, fam, `rung ${n} wears its own family`); // ...and every named family is one the preloader actually loads for (let n = 1; n <= LADDER; n++) assert.ok(cfg.ZFAMS.includes(matchFoe(n).fam), `rung ${n}'s family is registered in ZFAMS`); assert.ok(matchFoe(LADDER).h > 1.85, 'the champion stands taller than a stock body'); // THE LEAGUES: every league's cup draws only from its own band, its final is the band's top // rung and nothing else, and the five bands tile the whole ladder with no gap and no overlap let expectLo = 1; for (const l of cfg.LEAGUES) { const [lo, hi] = l.band; assert.equal(lo, expectLo, `${l.name} starts where the league below ended`); expectLo = hi + 1; const R = cfg.cupRoundsFor(l.id); assert.deepEqual(R[R.length - 1].band, [hi], `${l.name}'s final is its top rung`); assert.equal(R.length, hi - lo + 1, `${l.name} plays one round per rung`); for (const r of R) for (const rung of r.band) assert.ok(rung >= lo && rung <= hi, `${l.name} ${r.name} stays in its band (${rung})`); for (const r of R.slice(0, -1)) assert.ok(!r.band.includes(hi), `${l.name}'s top rung is only ever the final`); assert.ok(cfg.COURTS.some(c => c.id === l.court), `${l.name}'s court exists`); } assert.equal(expectLo, LADDER + 1, 'the five bands cover the ladder exactly'); }); test('league 5 has no speed dip below league 4, including serve and wrong-footed chases', () => { startMatch(); G.interlude.introT = 0; const zb = oppOf(); const measure = (n, serve, surprised) => { G.match.opp = n; G.match.surprised = surprised; G.match.expected = false; zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999; G.phase = 'rally'; G.wasServe = serve; Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0, x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 }); update(DT); return Math.hypot(zb.vx, zb.vz); }; for (const serve of [false, true]) for (const surprised of [false, true]) { const fourth = cfg.leagueOf(4).band, fifth = cfg.leagueOf(5).band; let floor = 0; for (let n = fourth[0]; n <= fourth[1]; n++) floor = Math.max(floor, measure(n, serve, surprised)); // since the legs have a knee (gameplay.knee) the late ladder runs at ~6-7.5 m/s, not 7-15 assert.ok(floor > TUNE.legKnee * 0.8, 'the rig measures a real full-speed chase: ' + floor.toFixed(2)); for (let n = fifth[0]; n <= fifth[1]; n++) { const speed = measure(n, serve, surprised); assert.ok(speed >= floor, `${cfg.matchFoe(n).short}: ${speed} must clear ${floor} (serve ${serve}, surprised ${surprised})`); } } endMatch(); }); test('league 5 sustains strong returns and hunts the open court; league 4 stays unchanged', () => { startMatch(); G.interlude.introT = 0; const zb = oppOf(); for (let n = 1; n <= cfg.leagueOf(4).band[1]; n++) { assert.equal(cfg.matchFoe(n).smart, undefined, 'no new aim bonus in earlier leagues'); assert.equal(cfg.matchFoe(n).qMax, undefined, 'no new quality bonus in earlier leagues'); assert.equal(cfg.matchFoe(n).qMin, undefined, 'no new quality floor in earlier leagues'); } for (const [n, smart, floor, cap] of [[15, 0, 0.45, 0.55], [16, 0.5, 0.62, 0.76], [17, 0.7, 0.68, 0.83], [18, 0.85, 0.74, 0.90]]) { G.match.opp = n; zb.foe = cfg.matchFoe(n); let hits = 0, open = 0, maxQ = 0, sumQ = 0; for (let i = 0; i < 1200; i++) { G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; P.x = i % 2 ? 3 : -3; P.z = 23; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, // a good ball: no put-away x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent hits++; assert.ok(ball.pow >= floor && ball.pow <= cap, `${n}: quality ${ball.pow} within its band`); sumQ += ball.pow; maxQ = Math.max(maxQ, ball.pow); const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; const landingX = ball.x + ball.vx * t; // At q >= .45 the corner's inward scatter stays beyond 1.35; centre scatter stays inside. if (landingX * Math.sign(P.x) < -1.35) open++; ball.active = false; } assert.ok(hits > 1100, `${n}: enough actual returns (${hits})`); assert.ok(maxQ > cap - 0.002, `${n}: the live return reaches its cap (${maxQ})`); assert.ok(Math.abs(sumQ / hits - (floor + cap) / 2) < 0.01, `${n}: normal returns sustain the stronger quality`); const expected = 1 / 3 + 2 / 3 * smart; assert.ok(Math.abs(open / hits - expected) < 0.05, `${n}: open-court share ${open / hits} should be near ${expected}`); } endMatch(); }); test('phone winners: the Champion approaches and returns the recorded short, low balls', () => { // Cup points 21/22 from Oliver's v351 phone log. Both were untouched second-bounce winners: // one landed near the net, the other bounced below the AI's old 24 cm contact floor. const cases = [ { opponent: [1.626, 5.485], player: [-1.035, 14.522], hp: 3, pos: [-0.535, 1.194, 14.222], velocity: [1.467, 1.063, -7.611], g: 9.614 }, { opponent: [2.676, 5.485], player: [-2.749, 19.533], hp: 2, pos: [-2.249, 0.404, 19.233], velocity: [0.597, 3.678, -14.681], g: 9.698 }, ]; const flub = TUNE.matchFlub; TUNE.matchFlub = 0; // isolate movement/contact; reliability and surprise rates have their own tests try { for (const hz of [30, 60, 120]) for (const c of cases) { freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(29); startMatch(); G.interlude.introT = 0; G.match.opp = 18; const rz = oppOf(); Object.assign(rz, { x: c.opponent[0], z: c.opponent[1], hp: c.hp, hpMax: 8, foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 }); Object.assign(P, { x: c.player[0], z: c.player[1], swingT: -1 }); G.phase = 'rally'; G.wasServe = false; Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.05, x: c.pos[0], y: c.pos[1], z: c.pos[2], vx: c.velocity[0], vy: c.velocity[1], vz: c.velocity[2], g: c.g }); for (let i = 0; i < hz * 3 && ball.active && ball.lastHit === 'P' && G.phase === 'rally'; i++) { update(1 / hz); assert.ok(rz.z <= COURT.netZ - 1.2, 'the approach stays on its own side'); } assert.equal(ball.lastHit, 'O', `${hz}Hz: the reachable phone winner is returned`); assert.deepEqual(score.pts, [0, 0], 'the rally continues instead of gifting a point'); } } finally { TUNE.matchFlub = flub; } }); test('match low contact: scoop rising balls, reject falling balls and contact across the net', () => { const flub = TUNE.matchFlub; TUNE.matchFlub = 0; try { for (const [z, y, vy, returns] of [[5, 0.12, 1.5, true], [5, 0.12, -1, false], [COURT.netZ + 0.1, 1, 0, false]]) { freshSplatter(); startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 0, z: Math.min(z, COURT.netZ - 1.2), swingT: -1, swingCool: 0 }); G.phase = 'rally'; G.wasServe = false; Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, x: 0, y, z, vx: 0, vy, vz: -1, g: TUNE.gravity }); update(DT); assert.equal(ball.lastHit === 'O', returns, `contact at z=${z}, y=${y}, vy=${vy}`); } } finally { TUNE.matchFlub = flub; } }); test('match recovery: finish the serve, recover during the outbound rally, hold on dead balls', () => { startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 3, z: -0.5, serveHold: 0.4 }); G.phase = 'rally'; Object.assign(ball, { active: true, lastHit: 'O', bounces: 0, x: 0, y: 2, z: 14, vx: 0, vy: 0, vz: 1 }); update(DT); assert.deepEqual([rz.x, rz.z], [3, -0.5], 'serve follow-through holds its mark'); rz.serveHold = 0; update(DT); // THE COURT HAS ITS LENGTH: the baseliner's spot is BEHIND the line now (rallyBack), not 2.5 m in assert.ok(rz.x < 3 && rz.z < -0.5 && rz.z >= -TUNE.rallyBack, 'recovers towards centre, to its spot behind the baseline'); const recoverySpeed = Math.hypot(rz.vx, rz.vz); ball.lastHit = 'P'; ball.vz = -1; update(DT); assert.ok(Math.abs(Math.hypot(rz.vx, rz.vz) - recoverySpeed * 2) < 1e-9, 'recovery uses half chase speed, leaving room for wrong-footing'); for (const dead of ['inactive', 'scored', 'pretoss']) { ball.lastHit = 'O'; ball.vz = 1; ball.active = dead !== 'inactive'; ball.scored = dead === 'scored'; G.phase = dead === 'pretoss' ? 'pretoss' : 'rally'; const pos = [rz.x, rz.z]; update(DT); assert.deepEqual([rz.x, rz.z], pos, `${dead}: no recovery outside a live flight`); assert.deepEqual([rz.vx, rz.vz], [0, 0], 'no stale sprint trail'); } }); test('the approaching Champion can still be passed into the open back corner', () => { startMatch(); G.interlude.introT = 0; G.match.opp = 18; const rz = oppOf(); Object.assign(rz, { x: 3, z: 10, foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 }); G.phase = 'rally'; G.wasServe = false; Object.assign(ball, { active: true, lastHit: 'P', bounces: 0, pow: 0.8, x: -3.5, y: 0.3, z: 1.5, vx: 0, vy: -2, vz: -4, g: TUNE.gravity }); for (let i = 0; i < 60 && score.pts[0] === 0; i++) update(DT); assert.deepEqual(score.pts, [1, 0], 'a legal passing shot beats a Champion stranded at the net'); }); // ---- THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT") ---------------------- // Oliver: "Aufschlag, dann schnell vor ans Netz ... ein sehr schneller Ballwechsel" — every match had // become a volley exchange at the T. Measured: the rally ball landed ON the service line, the // opponent stood on the bounce and volleyed 90-100% of everything from opponent 6 up, and the // auto-run chased the earliest contact, ~0.7 s per shot. These pin the shape that replaced it. test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not volleyed at the T', () => { // the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { // a BASELINER (THE LINE JUDGE): since the styles (step 2) a net rusher volleys on his way in by // design, so the baseline shape is asserted against a body whose game is the baseline startMatch(); G.interlude.introT = 0; G.match.opp = 11; assert.equal(cfg.matchFoe(11).style, 'baseliner', 'the subject plays from the back'); oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number for (let i = 0; i < 2.2 * 60; i++) update(DT); const hits = []; let prev = ball.lastHit, putAway = false; for (let i = 0; i < 120 * 60; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (d < 2.9 && d > 2.2) keyPressed('Space'); } const b0 = ball.bounces, kill = putAway; update(DT); if (ball.active && ball.lastHit !== prev && !G.wasServe) { const ch = ball.lastHit === 'P' ? P : oppOf(); // the answer to a PUT-AWAY (step 4) is a defence, not the rally's shape: a man caught on his heels // takes it where he can, in the air included if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT, kill }); putAway = ball.heels > 0; } prev = ball.active ? ball.lastHit : null; } const back = hits.filter(h => h.back); assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts'); // the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of // net contacts follows its timing; the back-court volley share below is the guard against the old T assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`); const volleys = back.filter(h => h.volley && !h.kill).length; assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`); const gaps = []; for (let k = 1; k < hits.length; k++) if (hits[k].back && hits[k - 1].back && hits[k].t - hits[k - 1].t < 3) gaps.push(hits[k].t - hits[k - 1].t); gaps.sort((a, b) => a - b); assert.ok(gaps[gaps.length >> 1] >= 0.75, 'a baseline exchange leaves time to play: median ' + gaps[gaps.length >> 1].toFixed(2) + ' s per shot'); } finally { TUNE.autoAssist = assist; } }); test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobody approaches a man at the net', () => { const flub = TUNE.matchFlub; TUNE.matchFlub = 0; // the opponent's answer to one ball from the player's baseline, landing `depth` m inside its line const play = (depth, pace, from) => { freshSplatter(); startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 0, z: -TUNE.rallyBack, swingT: -1, swingCool: 0, serveHold: 0 }); Object.assign(P, { x: 0, z: 24, swingT: -1 }); G.phase = 'rally'; G.wasServe = false; const v = solveShot(0, 1, 22.5, 0.5, depth, pace); Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.5, rx: null, from, x: 0, y: 1, z: 22.5, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); let bounced = null, at = null; for (let i = 0; i < 4 * 60 && ball.lastHit === 'P' && ball.active; i++) { bounced = ball.bounces; update(DT); } assert.equal(ball.lastHit, 'O', `depth ${depth}: the opponent returns it`); at = rz.z; for (let i = 0; i < 30; i++) update(DT); // its own ball is in the air: where does it go now? return { bounced, at, then: rz.z, approach: ball.approach }; }; try { const deep = play(2.4, 24, 12); assert.equal(deep.bounced, 1, 'a deep ball is played after the bounce'); assert.ok(deep.at < 1, 'from the baseline, not the T: met at z ' + deep.at.toFixed(2)); assert.equal(deep.approach, null, 'and it is no approach'); assert.ok(deep.then < 1, 'he stays back: ' + deep.then.toFixed(2)); const short = play(6.4, 15, 12); assert.equal(short.approach, 'O', 'a short ball taken is an approach'); assert.ok(short.at > 2, 'he ran in for it: met at z ' + short.at.toFixed(2)); assert.ok(short.then > short.at + 0.5, 'and follows it in: ' + short.at.toFixed(2) + ' -> ' + short.then.toFixed(2)); const pass = play(6.4, 15, 3); assert.equal(pass.approach, null, 'against a man at the net the same ball is a pass, never an approach'); } finally { TUNE.matchFlub = flub; } }); test('the auto-run does not SHAKE: no back-and-forth while it chases a ball', () => { // Oliver, 2026-10-14: "wenn der Autoplay den Ball versucht zu bekommen, zittert der Player schnell hin und // her". intercept re-planned every frame and its margins (a bounce peaking on a height floor, a point on // the back boundary) flipped the target by metres as the 10 ms flight grid slid against the frame: // 9-62 reversals inside 200 ms per five minutes. The chase now latches its plan. Ring-tapping play. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { let fast = 0, chases = 0; for (const opp of [4, 12, 18]) { startMatch(); G.interlude.introT = 0; G.match.opp = opp; for (let i = 0; i < 2.2 * 60; i++) update(DT); let last = 0, lastRev = -99, armed = false; for (let i = 0; i < 60 * 60; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = opp; if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed && ringCue()?.gold) { armed = true; keyPressed('Space'); } if (ball.lastHit !== 'O') armed = false; const x0 = P.x, chasing = G.phase === 'rally' && ball.active && ball.lastHit === 'O'; update(DT); if (!chasing) { last = 0; continue; } chases++; const dx = P.x - x0; if (Math.abs(dx) > 1e-4) { if (last && Math.sign(dx) !== Math.sign(last)) { if (i - lastRev < 12) fast++; lastRev = i; } last = dx; } } endMatch(); } assert.ok(chases > 3000, 'a real sample of chasing: ' + chases + ' frames'); assert.ok(fast <= 2, `no shake: ${fast} direction reversals inside 200 ms`); } finally { TUNE.autoAssist = assist; } }); test('THE FOLLOW-THROUGH: a tap ~80 ms early on a baseline ball is met weakly, not whiffed', () => { // Step 4 (b), 2026-10-14. An early tap starts the swing while the ball is in the air, the swing waits // for the bounce, and the groundstroke window had run out by then: a whiff at ~70 ms early, while a // tap 120 ms LATE still met it. swingTail lets the swing meet it at q 0.05 — a sitter, not a miss. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; const run = () => { startMatch(); G.interlude.introT = 0; G.match.opp = 6; for (let i = 0; i < 2.2 * 60; i++) update(DT); let armed = false, incoming = 0, met = 0; for (let i = 0; i < 90 * 60; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { const c = ringCue(); if (c && c.eta - c.sweet <= 5 / 60 + 1e-6 && c.eta - c.sweet > 4 / 60) { armed = true; incoming++; keyPressed('Space'); } } const before = ball.lastHit; update(DT); if (armed && before === 'O' && ball.lastHit === 'P') { met++; armed = false; } else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; } endMatch(); return { incoming, met }; }; const tail = TUNE.swingTail; try { const on = run(); assert.ok(on.incoming >= 20, 'a real sample: ' + on.incoming); assert.ok(on.met / on.incoming >= 0.8, `an ~80 ms early tap is met: ${on.met}/${on.incoming}`); TUNE.swingTail = 0; const off = run(); assert.ok(off.met / off.incoming < 0.6, `...where the old edge whiffed it: ${off.met}/${off.incoming}`); } finally { TUNE.autoAssist = assist; TUNE.swingTail = tail; } }); test('THE SITTER IS PUNISHED: the put-away chance climbs the ladder, goes for the line, and finds him on his heels', () => { // Step 4 (b): before it, the CPU had no way to win a point — a q 0.05 floater came back like a drive // and every point it took was the player's own whiff, which no opponent dial touches. startMatch(); G.interlude.introT = 0; const zb = oppOf(); const share = (n, pow) => { G.match.opp = n; zb.foe = cfg.matchFoe(n); let hits = 0, kills = 0, wide = 0, heels = 0; for (let i = 0; i < 1200; i++) { G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; P.x = 0; P.z = 23; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow, rx: null, spin: 1, soft: false, x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; hits++; if (ball.heels > 0) { kills++; heels++; const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; if (Math.abs(ball.x + ball.vx * t) > 2.9) wide++; assert.ok(ball.pow >= TUNE.punishQ, 'a put-away is struck clean'); } ball.active = false; } return { hits, kills: kills / hits, wide: kills ? wide / kills : 0 }; }; const champ = share(cfg.LADDER, 0.1), first = share(1, 0.1), good = share(cfg.LADDER, 0.9); assert.ok(Math.abs(champ.kills - TUNE.punishMax) < 0.06, `the champion puts a sitter away ${champ.kills.toFixed(2)}`); assert.ok(first.kills < champ.kills - 0.3 && first.kills > 0.1, `the first rung does it rarely ${first.kills.toFixed(2)}`); assert.equal(good.kills, 0, 'a good ball is never put away'); assert.ok(champ.wide > 0.9, `the champion's put-away goes for the line ${champ.wide.toFixed(2)}`); endMatch(); }); test('a low SLICE drive is not a dug-out drop: the scoop cap is for soft backspin balls only', () => { // Step 2's slicer hits every rally ball at spin 0.8, and the drop's dig-out cap read `spin < 1`, so every // low contact against him came back at lowVolleyQ — found when step 4 started punishing sitters. startMatch(); G.interlude.introT = 0; const zb = oppOf(); G.match.opp = 6; zb.foe = cfg.matchFoe(6); const flub = TUNE.matchFlub; TUNE.matchFlub = 0; try { for (const soft of [false, true]) { let n = 0, capped = 0; for (let i = 0; i < 300; i++) { G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; P.x = 0; P.z = 23; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, rx: null, spin: 0.8, soft, x: 0, z: 2.4, y: 0.3, vx: 0, vy: 0.5, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; n++; if (ball.pow <= TUNE.lowVolleyQ + 1e-9) capped++; ball.active = false; } assert.ok(n > 200, 'returns: ' + n); if (soft) assert.equal(capped, n, 'a dug-out drop is still a weak reply'); else assert.equal(capped, 0, 'a low slice drive is an ordinary ball'); } } finally { TUNE.matchFlub = flub; endMatch(); } }); test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => { // Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a // groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms // before the ring's kiss came back at q ~0.6, ~45 ms at 0.32 (0.57 now). The rally modes now fall from 1 at the edge of // PERFECT to the window's edge. The finger lands 3 frames EARLY of the kiss, every ball. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); G.interlude.introT = 0; G.match.opp = 6; for (let i = 0; i < 2.2 * 60; i++) update(DT); let armed = false, incoming = 0, returned = 0, sumQ = 0; for (let i = 0; i < 90 * 60; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { const c = ringCue(); if (c && c.eta - c.sweet <= 3 / 60 + 1e-6 && c.eta - c.sweet > 2 / 60) { armed = true; incoming++; keyPressed('Space'); } } const before = ball.lastHit; update(DT); if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; } else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; } assert.ok(incoming >= 20, 'a real sample: ' + incoming); assert.ok(returned / incoming >= 0.9, `an early-ish tap still returns: ${returned}/${incoming}`); assert.ok(sumQ / returned >= 0.42, 'and still returns a real ball (0.33 on the old curve): mean q ' + (sumQ / returned).toFixed(2)); } finally { TUNE.autoAssist = assist; } }); test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => { // Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen". // A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the // groundstroke window that took a tap made exactly on the gold ring from q 0.82 to 0.09, and at // 150 ms a third of the balls were simply missed. keyPressed's `lead` starts the swing clock at // touch-down; this is the ring-tapping player, with a real finger's 120 ms of tap on top. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); G.interlude.introT = 0; G.match.opp = 6; for (let i = 0; i < 2.2 * 60; i++) update(DT); const LAG = 7; // frames: ~120 ms between the finger landing and it lifting let fire = -1, armed = false, incoming = 0, returned = 0, sumQ = 0; for (let i = 0; i < 90 * 60; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { if (ringCue()?.gold) { armed = true; incoming++; fire = i + LAG; } // the finger LANDS on gold and lifts LAG later } if (i === fire) keyPressed('Space', LAG / 60); // the lift: the touch's age rides along const before = ball.lastHit; update(DT); if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; } else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; } assert.ok(incoming >= 30, 'a real sample: ' + incoming); assert.ok(returned / incoming >= 0.9, `a gold-ring tap returns the ball: ${returned}/${incoming}`); assert.ok(sumQ / returned >= 0.6, 'and returns it cleanly: mean q ' + (sumQ / returned).toFixed(2)); } finally { TUNE.autoAssist = assist; } }); // ---- THE SOFT BALL (step 3, 2026-10-14): the down-flick is soft, its length is how far ---------------- // One player shot, struck from the baseline with the swing clock put exactly where trySwing scores it: // q = 1 at the window's centre, `late` seconds off it for a mistimed one. The opponent is parked out of // play so the flight is the shot's own. Returns where it landed, how high it flew, its spin, and the // gap between its first and second bounce. function softShot(mag, late = 0, rzZ = -0.6) { freshSplatter(); startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 3, z: rzZ, swingCool: 999, swingT: -1 }); G.phase = 'rally'; G.wasServe = false; Object.assign(P, { x: 0, z: 24, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT + late }); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false }, spin: 1, soft: false, x: 0.4, y: 1.0, z: 23.6, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity }); G.swipe = { ax: 0, az: 1, fx: 0, fy: 1, mag }; update(DT); G.swipe = null; assert.equal(ball.lastHit, 'P', 'the swing connects'); const q = ball.pow, spin = ball.spin, land = predictLanding(); let apex = ball.y, first = null, atNetMan = null; const netMan = COURT.netZ - TUNE.netStand; for (let i = 0; i < 6 * 60 && ball.active && ball.bounces < 2; i++) { update(DT); apex = Math.max(apex, ball.y); if (atNetMan === null && ball.z <= netMan) atNetMan = ball.y; if (ball.bounces === 1 && !first) first = ball.z; } return { q, spin, depth: land.z, apex, atNetMan, roll: first === null ? null : Math.abs(ball.z - first), rz }; } const winLevelNow = () => _winLevel(); test('THE SOFT BALL: a short down-flick is a dying drop, a long one an attacking lob over the net man', () => { const drop = softShot(TUNE.flickMin + 2), lob = softShot(TUNE.flickFull + 20); // Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob" — a NATURAL short flick (a thumb // travels 40-80 px) must already be the drop, not a floater on the way to a lob const flick = softShot(TUNE.dropMax - 15); assert.ok(Math.abs(flick.depth - drop.depth) < 0.3 && flick.spin === drop.spin, 'a short natural flick is the drop'); assert.ok(drop.q > 0.95 && lob.q > 0.95, 'both struck flush'); assert.ok(Math.abs(drop.depth - (COURT.netZ - TUNE.dropNet)) < 0.7, 'the drop lands just over the net: z ' + drop.depth.toFixed(2)); // (dropApex is the ask; from the baseline the net-clearance loop lofts it a little, which is right) assert.ok(drop.apex < lob.apex - 1.5, `on a low arc: ${drop.apex.toFixed(2)} m against the lob's ${lob.apex.toFixed(2)}`); assert.ok(drop.spin < 0.5 && drop.roll < 2.5, `and it DIES: spin ${drop.spin.toFixed(2)}, ${drop.roll.toFixed(2)} m between its bounces`); assert.ok(Math.abs(lob.depth - TUNE.lobDeep) < 0.8, 'the lob lands deep: z ' + lob.depth.toFixed(2)); assert.ok(lob.apex > TUNE.lobApex - 0.6 && lob.spin === 1, 'high, and with no backspin: ' + lob.apex.toFixed(2) + ' m'); // ...over a man at the net: where he stands it must be out of his racket's reach assert.ok(lob.atNetMan > CONTACT.maxY + 0.5, `over the net man's racket: ${lob.atNetMan.toFixed(2)} m at his depth`); }); test('a mistimed soft ball drifts toward the sitter — the easy ball, never an out', () => { const clean = softShot(TUNE.flickFull + 20), rushed = softShot(TUNE.flickFull + 20, 0.07); assert.ok(rushed.q < 0.3, 'the rushed one really is mistimed: q ' + rushed.q.toFixed(2)); assert.ok(Math.abs(rushed.depth - TUNE.softSitter) < Math.abs(clean.depth - TUNE.softSitter), `it falls short of the corner: z ${clean.depth.toFixed(2)} -> ${rushed.depth.toFixed(2)}`); assert.ok(rushed.depth > 0 && rushed.depth < COURT.netZ, 'and stays in the court'); }); test('a soft ball is DISGUISED: the opponent stands reading it, but runs at once for a drive', () => { const moved = (soft) => { freshSplatter(); startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 0, z: -0.6, swingCool: 999, swingT: -1 }); G.phase = 'rally'; G.wasServe = false; G.aiReactT = 0; const v = solveShot(0, 1, 23, 2.5, soft ? 9.5 : 3, soft ? 12 : 25); Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: soft ? 0.3 : 1, soft, x: 0, y: 1, z: 23, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); for (let i = 0; i < Math.round((TUNE.softRead - 0.05) * 60); i++) update(DT); return Math.hypot(rz.x, rz.z + 0.6); }; assert.ok(moved(false) > 0.5, 'a drive: he is already on his way'); assert.ok(moved(true) < 0.05, 'a drop: he has not read it yet'); }); test('a LOBBED net man who runs it down only gets a defensive ball back', () => { const flub = TUNE.matchFlub; TUNE.matchFlub = 0; try { for (const lob of [false, true]) { freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = 18; const rz = oppOf(); Object.assign(rz, { x: 0, z: 3, swingCool: 0, swingT: -1, foe: cfg.matchFoe(18) }); G.phase = 'rally'; G.wasServe = false; G.aiReactT = 1; Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 1, spin: 1, soft: lob, rx: { net: false, short: false, lob }, x: 0, y: 1.2, z: 3.6, vx: 0, vy: -1, vz: -4, g: TUNE.gravity }); update(DT); assert.equal(ball.lastHit, 'O', 'he gets to it'); if (lob) assert.ok(ball.pow <= TUNE.lowVolleyQ + 1e-9, 'run down from a lob: q ' + ball.pow.toFixed(2)); else assert.ok(ball.pow > TUNE.lowVolleyQ, 'the same ball unlobbed is a full reply: q ' + ball.pow.toFixed(2)); } } finally { TUNE.matchFlub = flub; } }); // ---- THE STYLES (step 2, 2026-10-14): how a body plays is a row of dials -------------------------- // 400 returns off one body, the player parked at `pz`: what share came back soft, and the spins. function styleReturns(rung, pz, aiZ = 2) { freshSplatter(); startMatch(); G.interlude.introT = 0; G.match.opp = rung; const zb = oppOf(); zb.foe = cfg.matchFoe(rung); const out = { n: 0, soft: 0, lob: 0, drop: 0, spins: new Set() }; const flub = TUNE.matchFlub; TUNE.matchFlub = 0; try { for (let i = 0; i < 400; i++) { G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; P.x = 0; P.z = pz; P.swingT = -1; zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false, pow: 1, // no sitter: no put-away x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; out.n++; if (ball.soft) { out.soft++; if (ball.spin < 1) out.drop++; else out.lob++; } else out.spins.add(Math.round(ball.spin * 100) / 100); ball.active = false; } } finally { TUNE.matchFlub = flub; } return out; } test('THE STYLES: every opponent has one, and the walk-on says which', () => { for (let n = 1; n <= cfg.LADDER; n++) { const f = cfg.matchFoe(n), st = cfg.STYLES[f.style]; assert.ok(st, `${f.short} plays a known style (${f.style})`); assert.ok(st.tag.length <= 38, `${f.style}'s tag fits the modal's one line: ${st.tag}`); } startMatch(); const m = G.modal || G.modalQ.find(q => q.foe); assert.ok(!m || !m.foe || m.foe.style === cfg.STYLES[cfg.matchFoe(1).style].tag, 'the walk-on carries the tag'); }); test('a PUSHER moonballs as a habit and lobs a man at the net; a BIG HITTER carries topspin', () => { const pusher = cfg.STYLES[cfg.matchFoe(1).style]; assert.equal(cfg.matchFoe(1).style, 'pusher'); const back = styleReturns(1, 24); assert.ok(Math.abs(back.soft / back.n - pusher.loft) < 0.08, `moonballs ${back.soft}/${back.n} vs loft ${pusher.loft}`); assert.equal(back.drop, 0, 'a pusher does not drop'); const net = styleReturns(1, COURT.netZ + TUNE.netStand); assert.ok(Math.abs(net.lob / net.n - pusher.lob) < 0.08, `lobs at the net man ${net.lob}/${net.n} vs ${pusher.lob}`); const power = styleReturns(5, 24); assert.equal(cfg.matchFoe(5).style, 'power'); assert.deepEqual([...power.spins], [cfg.STYLES.power.spin], 'every big-hitter rally ball kicks'); assert.equal(power.soft, 0, 'and none of them floats'); }); test('a TOUCH player drops you from inside when you hang back — and never from his own baseline', () => { assert.equal(cfg.matchFoe(7).style, 'touch'); const inside = styleReturns(7, COURT.L + 0.5, 4), deep = styleReturns(7, COURT.L + 0.5, -0.5); assert.ok(Math.abs(inside.drop / inside.n - cfg.STYLES.touch.drop) < 0.08, `drops from inside ${inside.drop}/${inside.n}`); assert.equal(deep.drop, 0, 'from behind his own line a drop is no option'); }); test('a NET RUSHER serves and volleys: the serve is his approach and he sprints in behind it', () => { assert.equal(cfg.matchFoe(15).style, 'rusher'); startMatch(); G.interlude.introT = 0; G.match.opp = 15; const zb = oppOf(); zb.foe = cfg.matchFoe(15); score.server = 'O'; setupPoint(); Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 }); // the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in let served = -1, z0 = null, appr = null, zMax = -99, rushed = false, sprint = 0; for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) { G.modal = null; G.modalQ.length = 0; const zPrev = zb.z; update(DT); if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; } if (served >= 0) { zMax = Math.max(zMax, zb.z); rushed ||= !!zb.rushing; sprint = Math.max(sprint, (zb.z - zPrev) / DT); } } assert.ok(served >= 0, 'he served'); assert.equal(appr, 'O', 'the serve is an approach'); assert.ok(rushed && zMax > z0 + 1.2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`); // ...at a SPRINT, not the half-pace walk an ordinary recovery uses assert.ok(sprint > 0.8 * cfg.TUNE.racketSpeed * 1.25, 'he sprints in: ' + sprint.toFixed(2) + ' m/s'); }); test('LOBBED at the net, the player backpedals too, and only blocks it back', () => { freshSplatter(); startMatch(); G.interlude.introT = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { const zb = oppOf(); zb.swingCool = 999; G.phase = 'rally'; G.wasServe = false; Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 }); const v = solveShot(0, 1, 2, 0, COURT.L - 2, 9); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); update(DT); assert.ok(ball.rx && ball.rx.lob && !ball.rx.net, 'the plan turns: it is a lob, he runs it down'); } finally { TUNE.autoAssist = assist; } }); // ---- THE PLAYER'S WAY TO THE NET AND BACK (the double tap), AND THE SMASH (2026-10-14, Oliver: "Steuerung ans Netz gehen" // ... "wenn man einen Lob vom Gegner als Volley annimmt, soll man derb schmettern") --------------------- // One player shot with a given swipe from (x 0, z pz), then `secs` of flight with the opponent parked out // of play. Returns the shot's flags and where the player's own run took him. function ownShot(swipe, pz, secs = 1.2, taps = []) { freshSplatter(); startMatch(); G.interlude.introT = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { const rz = oppOf(); Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 }); G.phase = 'rally'; G.wasServe = false; Object.assign(P, { x: 0, z: pz, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT }); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false }, spin: 1, soft: false, x: 0.4, y: 1.0, z: pz - 0.4, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity }); G.swipe = swipe; update(DT); G.swipe = null; assert.equal(ball.lastHit, 'P', 'the swing connects'); let swung = false; for (let i = 0; i < secs * 60 && ball.active; i++) { rz.swingCool = 999; if (taps.includes(i)) { keyPressed('Space', 0.1); swung ||= P.swingT >= 0; } update(DT); } return { approach: ball.approach, retreat: ball.retreat, fromNet: Math.abs(P.z - COURT.netZ), swung }; } finally { TUNE.autoAssist = assist; } } test('THE DOUBLE TAP is his legs: from the back it takes him to the net, a single tap does nothing', () => { // Oliver, 2026-10-14: "das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden". // While his own ball flies there is nothing to hit, so taps there are the move, never a swing. const deep = { ax: 0, az: -1, fx: 0, fy: -1, mag: TUNE.flickFull + 20 }; const plain = ownShot(deep, 24), one = ownShot(deep, 24, 1.2, [6]), two = ownShot(deep, 24, 1.2, [6, 6 + 10]); assert.equal(plain.approach, null, 'a long up-flick is only a shot now: no run'); assert.ok(plain.fromNet > TUNE.netFrom + 3, `...he stays back: ${plain.fromNet.toFixed(1)} m`); assert.ok(!one.swung && one.approach === null && one.fromNet > TUNE.netFrom + 3, 'one tap while his ball flies: nothing at all'); assert.equal(two.approach, 'P', 'the double tap is the way in'); assert.ok(!two.swung, '...and neither tap swung'); assert.ok(two.fromNet < TUNE.netFrom, `...and he is at the net by the time the ball gets there: ${two.fromNet.toFixed(1)} m`); const slow = ownShot(deep, 24, 1.2, [6, 6 + Math.ceil(TUNE.dblTap * 60) + 3]); assert.equal(slow.approach, null, 'two taps further apart than dblTap are not a double tap'); }); test('THE DOUBLE TAP at the net is the way back; a long down-flick is only the lob', () => { const down = { ax: 0, az: 1, fx: 0, fy: 1, mag: TUNE.flickFull + 20 }; const lob = ownShot(down, COURT.netZ + TUNE.netStand, 1.5); assert.equal(lob.retreat, null, 'the lob alone does not take him back'); assert.ok(lob.fromNet < TUNE.netFrom, `...he holds the net: ${lob.fromNet.toFixed(1)} m`); const back = ownShot(down, COURT.netZ + TUNE.netStand, 1.5, [6, 14]); assert.equal(back.retreat, 'P', 'the double tap at the net is the way back'); assert.ok(back.fromNet > TUNE.netFrom, `...and he is back behind the net zone: ${back.fromNet.toFixed(1)} m`); }); test('THE DOUBLE TAP is a STANCE: a volley from mid-court keeps him coming in, not walking back', () => { // Oliver on v376: "wenn man doppeltippt, geht er minimal vor". His own ball's flight covers 4-9 m of the 10, // his next ball was met from mid-court — outside the net zone — and the recovery after it took him straight // back to the baseline. P.post holds the net until the next double tap, a lob he runs down, or the point. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; const after = post => { freshSplatter(); startMatch(); G.interlude.introT = 0; const rz = oppOf(); Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 }); G.phase = 'rally'; G.wasServe = false; Object.assign(P, { x: 0, z: COURT.netZ + 9, swingT: -1 }); // his volley was struck 9 m out P.post = post; const v = solveShot(0, 1, P.z - 0.4, 1, 3, 18); Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, rx: null, spin: 1, soft: false, approach: null, retreat: null, x: 0, y: 1, z: P.z - 0.4, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); for (let i = 0; i < 60 && ball.active; i++) { rz.swingCool = 999; update(DT); } return Math.abs(P.z - COURT.netZ); }; try { const committed = after('net'), loose = after(null); assert.ok(committed < TUNE.netFrom, `committed to the net, he keeps coming in: ${committed.toFixed(1)} m`); assert.ok(loose > 9, `without the stance the old recovery still walks him back: ${loose.toFixed(1)} m`); } finally { TUNE.autoAssist = assist; } }); test('an INCOMING ball keeps every tap a swing: the double tap never eats a shot', () => { freshSplatter(); startMatch(); G.interlude.introT = 0; oppOf().swingCool = 999; G.phase = 'rally'; G.wasServe = false; const v = solveShot(0, 1, 2, 0.5, 21, 20); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); P.swingT = -1; keyPressed('Space', 0.1); assert.ok(P.swingT >= 0, 'the first tap swings'); P.swingT = -1; keyPressed('Space', 0.1); assert.ok(P.swingT >= 0, 'and so does the second'); assert.equal(ball.approach, null, 'no run was called'); }); test('THE SMASH: a lob taken out of the air is struck hard to the open court; a deep lob still beats him', () => { // the opponent's lob from its baseline, at the player standing at the net; a gold-ring tap, as a player would const lobAt = (tz, speed) => { freshSplatter(); startMatch(); G.interlude.introT = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { const zb = oppOf(); Object.assign(zb, { x: 2, z: 0.5, swingCool: 999 }); G.phase = 'rally'; G.wasServe = false; Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 }); const v = solveShot(0, 1, 2, 0, tz, speed); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, pow: 0.8, heels: 0, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); let tapped = false; for (let i = 0; i < 4 * 60 && ball.lastHit === 'O' && ball.active; i++) { zb.swingCool = 999; if (!tapped && ringCue()?.gold) { tapped = true; keyPressed('Space'); } update(DT); } return { hit: ball.lastHit === 'P', smash: ball.smash, q: ball.pow, pace: Math.hypot(ball.vx, ball.vz), land: ball.lastHit === 'P' ? predictLanding() : null }; } finally { TUNE.autoAssist = assist; } }; const short = lobAt(COURT.L - 6, 9), deep = lobAt(COURT.L - 1.5, 9); assert.ok(short.hit && short.smash, 'a short lob is smashed'); assert.ok(short.q > 0.6 && short.pace > 30, `...hard: q ${short.q.toFixed(2)}, ${short.pace.toFixed(1)} m/s`); assert.ok(Math.abs(short.land.x) > 2.9, `...into the open court: lands at x ${short.land.x.toFixed(1)}`); assert.ok(!deep.smash, 'a deep lob is not smashed — he turns and runs it down'); if (deep.hit) assert.ok(deep.q <= TUNE.lowVolleyQ + 1e-9, 'and only blocks it back'); }); test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => { // Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht // bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb // and the cup logged nothing at all. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); G.interlude.introT = 0; for (let i = 0; i < 2.2 * 60; i++) update(DT); G.missLog.length = 0; const zb = oppOf(); zb.swingCool = 999; const incoming = () => { G.phase = 'rally'; G.wasServe = false; G.swung = false; G.rallyNear = null; const v = solveShot(0, 1, 2, 0.5, 21, 20); Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); }; incoming(); keyPressed('Space', 0.1); // far too early: the ball has barely left for (let i = 0; i < 30; i++) update(DT); const miss = G.missLog.find(e => e.result === 'MISS'); assert.ok(miss && /tap too early/.test(miss.reason) && /tap 100ms/.test(miss.reason), 'the whiff is explained: ' + (miss && miss.reason)); for (let i = 0; i < 4 * 60 && G.phase === 'rally'; i++) update(DT); const lost = G.missLog.find(e => e.result === 'LOST'); assert.ok(lost, 'the point it cost is logged: ' + JSON.stringify(G.missLog.map(e => e.result))); // a clean hit carries its shot and its tap for (let i = 0; i < 4 * 60 && G.phase !== 'pretoss'; i++) update(DT); incoming(); G.missLog.length = 0; let lift = -1; for (let i = 0; i < 3 * 60 && ball.lastHit === 'O'; i++) { if (lift < 0 && ringCue()?.gold) lift = i + 6; // the finger lands on gold... if (i === lift) keyPressed('Space', 0.1); // ...and lifts 100 ms later update(DT); } const hit = G.missLog.find(e => e.result === 'HIT'); assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason)); // ...and the opponent's half is there: what it hit back, and where it lands relative to you for (let i = 0; i < 2 * 60 && ball.lastHit === 'P'; i++) { zb.swingCool = 0; update(DT); } const them = G.missLog.find(e => e.result === 'THEM'); assert.ok(them && (/lands/.test(them.reason) || /whiffed/.test(them.reason)), 'the reply is logged: ' + (them && them.reason)); } finally { TUNE.autoAssist = assist; } }); test('a deep ball the player waits for BEHIND his baseline is still his to hit — not "passed you"', () => { // Oliver's v366 log vs THE BRUTE: "LOST passed you" with no swing. The match called the point the // moment its ball crossed 0.3 m behind the baseline — but since step 1 the player waits up to // rallyBehind behind it for a deep, kicking ball, so the ball was taken away on its way to him. const flub = TUNE.matchFlub; TUNE.matchFlub = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); G.interlude.introT = 0; const zb = oppOf(); zb.swingCool = 999; G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0; Object.assign(P, { x: 0, z: COURT.L + 1.8, swingT: -1, tgt: null, vx: 0, vz: 0 }); TUNE.autoAssist = 0; TUNE.matchAssist = 0; // he holds his deep mark: the ball comes to him const v = solveShot(0, 1, 1, 1.25, COURT.L - 2.2, 27, TUNE.gravity * 1.6); // deep, a little wide, heavy topspin Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1.15, soft: false, x: 0, y: 1, z: 1, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity * 1.6 }); let crossed = false, lift = -1; for (let i = 0; i < 3 * 60 && ball.active && ball.lastHit === 'O'; i++) { if (lift < 0 && ringCue()?.gold) lift = i + 6; if (i === lift) keyPressed('Space', 0.1); update(DT); if (ball.z > COURT.L + 0.3) crossed = true; } assert.ok(crossed, 'the ball really did cross the old line'); assert.equal(ball.lastHit, 'P', 'and he returned it: ' + JSON.stringify(G.missLog.map(e => e.result + ' ' + e.reason))); // ...while a ball that truly gets past him is still called, and says whether he swung TUNE.autoAssist = assist; TUNE.matchAssist = 1.15; freshSplatter(); startMatch(); G.interlude.introT = 0; oppOf().swingCool = 999; G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0; Object.assign(P, { x: 0, z: 23, swingT: -1 }); const w = solveShot(0, 1, 1, 3.9, COURT.L - 3, 28); // wide, past him Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, x: 0, y: 1, z: 1, vx: w.vx, vy: w.vy, vz: w.vz, g: TUNE.gravity }); TUNE.autoAssist = 0; TUNE.matchAssist = 0; // a statue: it must go by for (let i = 0; i < 3 * 60 && G.phase === 'rally'; i++) update(DT); const lost = G.missLog.find(e => e.result === 'LOST'); assert.ok(lost && /never swung, it came within/.test(lost.reason), 'the verdict says how close it came: ' + (lost && lost.reason)); } finally { TUNE.matchFlub = flub; TUNE.autoAssist = assist; TUNE.matchAssist = 1.15; } }); test('a topspin ball kicking out wide is planned where the player may go, and returned', () => { // Oliver's v369 log, 84.26 s: "ball line 2.72m wide — run, don't swing", runTo=8.73. Since the dip // ends at the bounce, a ball landing just inside the line drifts on sideways long enough that the // auto-run planned him far outside the frame he is held in (xLimit) — he waited at the edge. const flub = TUNE.matchFlub; TUNE.matchFlub = 0; const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); G.interlude.introT = 0; oppOf().swingCool = 999; G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0; Object.assign(P, { x: -1.2, z: 24.4, swingT: -1, tgt: null }); const g = TUNE.gravity * (1 + 0.68 * TUNE.spinFactor); const v = solveShot(-4, 1, -0.6, 3.3, COURT.L - 3.67, 30, g); // hard cross-court: lands 3.3, kicks on out Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1.15, soft: false, x: -4, y: 1, z: -0.6, vx: v.vx, vy: v.vy, vz: v.vz, g }); let lift = -1, widest = 0; for (let i = 0; i < 3 * 60 && ball.active && ball.lastHit === 'O'; i++) { if (lift < 0 && ringCue()?.gold) lift = i + 6; if (i === lift) keyPressed('Space', 0.1); update(DT); if (P.tgt) widest = Math.max(widest, Math.abs(P.tgt.x)); } // (headless the frame is wide enough that the bound is P_BOUNDS.x; on a phone it is the screen, ~4.3) assert.ok(widest <= cfg.P_BOUNDS.x + 1e-6, 'the run stays where he may stand: widest target x ' + widest.toFixed(2)); assert.equal(ball.lastHit, 'P', 'and he gets it back: ' + JSON.stringify(G.missLog.map(e => e.result + ' ' + e.reason))); } finally { TUNE.matchFlub = flub; TUNE.autoAssist = assist; } }); test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => { // the only out a rally ball may find is wide — depth must never put one past the baseline startMatch(); G.interlude.introT = 0; const zb = oppOf(); const sample = n => { G.match.opp = n; zb.foe = cfg.matchFoe(n); const depths = []; for (let i = 0; i < 400; i++) { G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false; P.x = 0; P.z = 24; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, rx: null, x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests) const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; depths.push(COURT.L - (ball.z + ball.vz * t)); ball.active = false; } return depths; }; const weak = sample(1), strong = sample(18); // solveShot aims at ty 0.08 (the ball on the grass), so the true bounce is a few dm past the mark for (const d of [...weak, ...strong]) assert.ok(d >= TUNE.rallyDeep - 0.5, 'never long, never near the line: ' + d.toFixed(2)); const mean = a => a.reduce((s, x) => s + x, 0) / a.length; assert.ok(mean(weak) > mean(strong) + 0.4, `weaker timing sits up shorter: ${mean(weak).toFixed(2)} vs ${mean(strong).toFixed(2)} m inside`); endMatch(); }); test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => { startMatch(); G.interlude.introT = 0; const zb = oppOf(); const measure = (n, mode) => { G.match.opp = n; G.match.train = mode === 'train'; G.match.cup = mode === 'cup' ? { round: 0, rounds: [{ need: 1 }] } : null; G.match.surprised = G.match.expected = false; zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999; G.phase = 'rally'; G.wasServe = false; Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0, x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 }); update(DT); const chase = Math.hypot(zb.vx, zb.vz); let hits = 0, pace = 0, paced = 0, width = 0; const seed = _seed; _seed = 9173; // repeatable samples; cosmetic timers may still consume different rolls for (let i = 0; i < 1200; i++) { G.phase = 'rally'; G.wasServe = false; P.x = 0; P.z = 23; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0; Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); update(DT); if (ball.lastHit !== 'O') continue; // a style's soft ball takes its pace from its arc on purpose, so the pace dial reads rally balls hits++; if (!ball.soft) { pace += Math.hypot(ball.vx, ball.vz); paced++; } const t = (ball.vy + Math.sqrt(ball.vy ** 2 + 2 * ball.g * ball.y)) / ball.g; width = Math.max(width, Math.abs(ball.x + ball.vx * t)); ball.active = false; } _seed = seed; return { chase, hits, pace: pace / paced, width }; }; let previous = 0; for (let n = 1; n <= 8; n++) { const cup = measure(n, 'cup'), train = measure(n, 'train'), climb = measure(n, 'climb'); const same = (a, b, why) => { assert.equal(a.chase, b.chase, why + ': chase'); assert.ok(Math.abs(a.pace / b.pace - 1) < 0.005, why + ': return pace'); assert.ok(Math.abs(a.width - b.width) < 0.12, why + ': width ' + a.width + ' / ' + b.width); assert.ok(Math.abs(a.hits - b.hits) < 40, why + ': whiff rate'); }; same(train, cup, `${n}: the training rep matches its cup opponent`); assert.ok(climb.chase > previous, `${n}: the arcade's early chase curve keeps rising`); previous = climb.chase; if (n < 8) assert.ok(climb.chase > cup.chase, `${n}: less opening assistance in arcade`); else same(climb, cup, 'opponent eight rejoins the original curve'); if (n === 1) { assert.ok(climb.chase / cup.chase > 1.3, 'the opening opponent covers materially more ground'); assert.ok(climb.pace / cup.pace > 1.2, 'its actual returns arrive faster'); assert.ok(climb.width / cup.width > 1.4, 'its actual returns use more of the court'); assert.ok(climb.hits > cup.hits + 40, 'it gives away fewer returns'); assert.ok(climb.width < 2.5, 'opening returns still leave a reachable margin'); } } G.match.cup = null; G.match.train = false; endMatch(); }); test('the first opponent cannot paint lines: its returns are width-clamped', () => { // aimError x (1 - 0.55 quality) scattered returns to +-3.5 m, which an auto-run player cannot // cover — "still a LOT of unreachable balls". Width is the ladder's third dial now. assert.ok(TUNE.matchWideBase < 2.5, 'opponent one is kept inside reachable width'); assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung'); }); test('an aimed return can be played OUT, but only by mistiming it', () => { // The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError // at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and // went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in. // Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same // quality the scatter shrinks with, so the two can never sum past the line — that sum is the // whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally. // The contract is NOT "never out" — that was the overcorrection ("too easy. i must be able to // play out"). It is: going out is possible and BOUNDED, it gets rarer as timing improves, and a // perfectly timed flick can never do it. Risk decided by skill rather than by a coin toss. const line = COURT.W / 2, aimW = TUNE.flickWidth; // doHit's far-court aim width const pOut = q => { const reach = aimW * (TUNE.flickReach + (1 - TUNE.flickReach) * q); const e = TUNE.aimError * (1 - q); return e <= 0 ? 0 : Math.max(0, Math.min(1, (e - (line - reach)) / (2 * e))); }; assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%'); assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)'); // ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the // worst-case rate was halved off 15%, and this is what stops flickReach drifting back up. assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%'); assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out'); for (let q = 0.05; q <= 1.0001; q += 0.05) assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`); // ...and it must still be worth flicking: perfect timing reaches the corner. assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner'); }); test("a flick's LENGTH is its width: the three-lane cliff is gone", () => { // The gesture used to be read as three LANES off a 0.38 ratio, so two neighbouring swipes differed // by the whole court — "sometimes applied very strong, sometimes barely does anything", with no // way to tell which one you had asked for. Length is the strength now, as it always was on serve. const swipe = (m, fx = 1) => ({ ax: 1, fx, fy: 0, mag: m }); assert.equal(swayOf({ ax: -1 }), -1, 'keys have no length — a held direction is the whole lane'); assert.equal(swayOf(swipe(TUNE.flickMin)), 0, 'a swipe at the flick threshold aims nothing yet'); assert.equal(swayOf(swipe(TUNE.flickFull)), 1, '...and a committed one takes the whole width'); assert.equal(swayOf(swipe(999)), 1, 'over-swiping cannot aim past the corner'); let prev = 0; for (let m = TUNE.flickMin; m <= TUNE.flickFull; m += 2) { const v = swayOf(swipe(m)); assert.ok(v >= prev - 1e-9 && v - prev < 0.05, `the sway climbs smoothly at ${m}px, no cliff`); prev = v; } // ...and a diagonal is a diagonal, not a lane vote: under the old ratio this aimed dead centre assert.ok(Math.abs(swayOf(swipe(TUNE.flickFull, 0.35)) - 0.35) < 1e-9, 'fx carries the aim'); assert.equal(flatCount(3, swipe(TUNE.flickMin + 4)), 4, 'a swipe too short to bend it counts as flat'); assert.equal(flatCount(3, swipe(TUNE.flickFull)), 0, '...and a real one ends the lesson'); }); test('a gentle flick cannot be played out — only a committed one can', () => { // Oliver: "the number one reason i die is that i myself keep firing balls into the out." Every // aimed shot took the FULL lane and then +-aimError on top, so whether it sailed was the dice's // call at any width the player picked. Width follows the swipe now, so the risk band has an edge // the player can stop short of — and going out becomes a thing you chose, not a thing rolled. const line = COURT.W / 2; const worst = s => s * TUNE.flickWidth * TUNE.flickReach + TUNE.aimError; // widest it can land assert.ok(worst(0.5) < line, 'half a swipe is in at ANY timing'); assert.ok(worst(1) > line, '...and a full one can still sail: the risk is kept, and it is chosen'); // the threshold render colours the live aim ring with is that same arithmetic, not a magic number const safe = (line - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end'); }); test('an aimed shot keeps its DIRECTION: scatter may push it wider, never back past centre', () => { // Dial 1 (2026-08-20). At rushed quality the symmetric scatter outweighed the quality-shrunk // aim, so a mistimed left flick landed "wherever" — usually centre, read as the ball following // the opponent. The model mirrors doHit's lane path: outward rolls untouched (the chosen // out-risk keeps its exact tuned shape), inward rolls keep only aimTrust of themselves. const land = (s, q, u) => { // u in [-1,1]: the raw scatter roll const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q; let e = u * TUNE.aimError * (1 - q); if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust; return s * TUNE.flickWidth * reach + e; }; const line = COURT.W / 2; for (let q = 0; q <= 1; q += 0.1) { assert.ok(land(0.5, q, -1) > 0, 'a committed right swipe stays right at q=' + q.toFixed(1) + ': ' + land(0.5, q, -1).toFixed(2)); assert.ok(land(-0.5, q, 1) < 0, '...and a committed left swipe stays left, mirrored'); } assert.ok(land(1, 0, 1) > line, 'the outward tail is untouched: a mistimed full swipe can still sail'); assert.ok(land(1, 1, 1) < line, 'and a perfectly timed one still never does'); // the knob relationship the direction guarantee rests on — retuning must keep it assert.ok(TUNE.aimTrust * TUNE.aimError < 0.5 * TUNE.flickWidth * TUNE.flickReach, 'aimTrust*aimError must stay under a committed swipe\'s worst-case aim'); }); test('an aimed ball never lands on the WRONG SIDE of you', () => { // The absolute lane's blind spot: a mistimed left flick's target sits only slightly left of // CENTRE — which is RIGHT of a player standing in the left box, so the ball visibly flew right // out of a left flick. (The fully relative lane that fixed this harder was feel-vetoed the same // day: "way too difficult now, I am dying constantly of out".) Model mirrors doHit's lane path. const land = (px, s, q, u) => { const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q; let e = u * TUNE.aimError * (1 - q); if (s && e * Math.sign(s) < 0) e *= TUNE.aimTrust; let tx = s * TUNE.flickWidth * reach + e; const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, px)); return s < 0 ? Math.min(tx, bound) : Math.max(tx, bound); }; for (let q = 0; q <= 1; q += 0.25) for (const u of [-1, 0, 1]) for (const s of [-0.2, -0.6, -1]) { assert.ok(land(-2, s, q, u) <= -2, 'a left flick from the left box never crosses the player: ' + land(-2, s, q, u).toFixed(2) + ' at s=' + s + ' q=' + q + ' u=' + u); assert.ok(land(0, s, q, u) <= 0, '...and from centre it never crosses centre'); } assert.equal(land(-2, -1, 1, 0), -TUNE.flickWidth, 'a perfect corner flick is untouched by the guard'); assert.equal(land(2, -1, 1, 0), -TUNE.flickWidth, 'crosscourt from the right box keeps its full sweep'); // the guard can never conjure an out: the bound is capped inside the line assert.ok(TUNE.flickWidth < COURT.W / 2, 'flickWidth stays inside the singles line'); assert.ok(Math.abs(Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, -6.5))) <= TUNE.flickWidth, 'a body parked in the alley clamps to the corner, not past the line'); }); test('the flick aims from the LIVE swipe: contact reads G.swipe, no release needed', () => { // The swing fires the moment the drag crosses flickMin now (input.js), so at contact the finger // is usually still down and no released flickAim exists yet. The aim has to come from the live // vector — without this, every crossing-fired flick would land flat and the latency fix (a left // flick at the service line released too late to aim anything) would have swung at nothing. startMatch(); G.modal = null; G.modalQ.length = 0; const arm = () => { // one rally ball, contact at the window's centre G.interlude.introT = 0; G.phase = 'rally'; P.x = 0; P.z = 22.8; P.swingT = -1; G.placeMark = null; ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; ball.x = 0; ball.y = 0.7; ball.z = P.z - 4.15; ball.vx = 0; ball.vy = 2.0; ball.vz = 14; ball.g = TUNE.gravity; keyPressed('Space'); // what the flickMin crossing fires mid-gesture for (let i = 0; i < 30 && ball.lastHit !== 'P'; i++) update(DT); assert.equal(ball.lastHit, 'P', 'contact happened'); return G.placeMark; }; G.swipe = { ax: -1, az: 0, fx: -1, fy: 0, mag: TUNE.flickFull }; // held LEFT, finger still down const left = arm(); assert.ok(left && left.x < -2, 'the live swipe aimed it left: x=' + (left ? left.x.toFixed(2) : 'none')); G.swipe = { ax: 1, az: 0, fx: 1, fy: 0, mag: TUNE.flickFull }; const right = arm(); assert.ok(right && right.x > 2, '...and mirrored right: x=' + (right ? right.x.toFixed(2) : 'none')); G.swipe = null; // bare tap, no gesture at all assert.equal(arm(), null, 'no swipe and no released flick: a flat return stamps no mark'); }); test('a wounding point knocks the body down and it gets up MINUS the limb', () => { // The beat exists because the wound stage used to POP between points — an arm stopped being there // and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears // the stage it has BECOME, and the point is handed back only when it is standing again. startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); const zb = zombies.find(z => z.variant === 'r'); assert.equal(racketStage(zb), 0, 'setup: opponent starts unhurt'); // A quarter off a full pool is 0.75 — still stage 0, so point ONE has no limb to explain. The // stage crosses on the second point (0.5) and the third (0.25); that progression is the beat. resolveInterlude('P'); assert.equal(G.matchFall, null, 'point one takes a quarter but crosses nothing'); resolveInterlude('P'); // ...this one takes the arm assert.ok(G.matchFall, 'a stage-crossing point starts the fall'); assert.equal(G.matchFall.from, 0, 'it collapses as the body it still was'); update(DT); assert.match(zb.showFrame, /fall1$/, 'first frame is the buckle'); assert.ok(!/w\d/.test(zb.showFrame), 'and it is the UNWOUNDED buckle'); // ...and it must LIE THERE a moment: a corpse that touches the ground and is instantly upright // reads as a stumble, not as something the point did to it. THE RISE ROW IS GONE (2026-08-13, // Oliver: "remove the rise frame. the opponent respawns anyway") — the beat ends prone and the // body takes its mark in the wound skin. let downFrames = 0; for (let i = 0; i < (FALL_LEN + 1) * 60 && G.matchFall; i++) { update(DT); if (/fall3$/.test(zb.showFrame || '')) downFrames++; } assert.ok(downFrames > 0.3 * 60, 'it stays down for a beat: ' + (downFrames / 60).toFixed(2) + 's'); assert.equal(G.matchFall, null, 'the fall ends on its own clock'); assert.equal(racketStage(zb), 1, '...and it comes back as the one-armed body'); assert.equal(zb.showFrame, null, 'and hands the sprite back to drawZombie'); assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed'); }); test('the fall beats the ball: a passing winner fells the body while the ball still rolls', () => { // Oliver: "the zombie falls after the ball is lost although we know this earlier — it could // play out as ball lost BECAUSE of the falling." A winner that bounced in beyond the body's // provable reach resolves at bounce ONE: the collapse starts, the ball keeps rolling, and the // decided-latch makes sure the rally can never bank twice. startMatch(); G.mode = 'splatter'; // the app's intoSplatter() precedes startMatch() G.interlude.introT = 0; const zb = oppOf(); zb.hp = 3; // so THIS point crosses a wound stage (3 -> 2 is the arm) zb.x = -4; zb.z = 2; // parked far from where the winner lands ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false; ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity; G.phase = 'rally'; G.wasServe = false; const pts = score.pts[0]; for (let i = 0; i < 40 && !G.matchFall; i++) update(DT); assert.ok(G.matchFall, 'the collapse starts at the decided moment, not at the dead ball'); assert.ok(ball.active, '...and the ball is still rolling past the falling body'); assert.equal(score.pts[0], pts + 1, 'the point banked at the decision'); for (let i = 0; i < 5 * 60; i++) update(DT); // the ball dies its ordinary death, the fall plays out assert.equal(score.pts[0], pts + 1, '...and never banked a second time'); assert.ok(!G.match.decided, 'the latch cleared for the next rally'); }); test('an ordinary far winner keeps its ordinary ending, and the match plays on (the soft-lock)', () => { // The first shipped version of the early fall latched `decided` AFTER resolveInterlude — and on // a point with no stage change matchPoint re-arms the next rally synchronously, so the latch // outlived its rally and swallowed every resolution after it: no falls, no points, no serve, // ever again. Oliver: "the fall never happens, the game does not continue, the ball is gone." startMatch(); G.mode = 'splatter'; G.interlude.introT = 0; const zb = oppOf(); // full pool: this point crosses NO stage zb.x = -4; zb.z = 2; const launch = () => { ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false; ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity; G.phase = 'rally'; G.wasServe = false; }; launch(); const pts = score.pts[0]; for (let i = 0; i < 5 * 60 && score.pts[0] === pts; i++) update(DT); assert.equal(G.matchFall, null, 'no stage to show: no early fall, the ordinary ending played'); assert.equal(score.pts[0], pts + 1, 'the point banked once'); assert.ok(!G.match.decided, 'and the latch is not left armed'); launch(); // THE NEXT RALLY MUST STILL RESOLVE — the lock test for (let i = 0; i < 5 * 60 && score.pts[0] === pts + 1; i++) update(DT); assert.equal(score.pts[0], pts + 2, 'the match plays on'); }); test('the menus own the theme, and hand the court back its silence', () => { // Oliver, 2026-08-15: "after some games — or returning to the main menu via the close button — // the background music does not play any more, but you sometimes still hear the court background // noise." loopBed no-ops once a file is playing, so menuMusic() could only ever START the theme, // never bring it BACK from the duck start() put it under — and nothing ducked the court's own // beds at all. The menu was silent and noisy at once, permanently, from the first run onward. const d = audio.bedTest.ducked, THEME = 'background-music.m4a', AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a'; audio.menuMusic(); // on the menu assert.ok(!d.has(THEME), 'the theme plays on the menu'); audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts assert.ok(d.has(THEME), 'the theme hands the court over'); assert.ok(!d.has(AMB) && !d.has(ZOM), 'and the court gets its own beds'); audio.menuMusic(); // ...and the run ends, however it ended assert.ok(!d.has(THEME), 'the theme is BACK — this is the bug'); assert.ok(d.has(AMB) && d.has(ZOM), '...and the court does not follow you into the menu'); audio.ambient(); audio.zombieMusic(); // ...and the NEXT run is not played in silence assert.ok(!d.has(AMB) && !d.has(ZOM), 'a second run sounds like the first'); // ...but the SPLATTER BED IS THE GORE'S ALONE. Every run of every mode starts the ambience, so // un-ducking both court beds there put the zombie music back on in classic tennis for the rest of // the session (Oliver: "after playing the splatter gore, the zombie background sound also keeps // playing in the normal tennis game"). Leaving the court is one event; arriving is not. audio.menuMusic(); // back to the menu after a gore run audio.ambient(); // ...and into a game of plain tennis assert.ok(!d.has(AMB), 'the ambience belongs to every mode'); assert.ok(d.has(ZOM), 'the splatter music does NOT follow you into classic tennis'); audio.menuMusic(); }); test('SPLATTER GORE opens in the gore — no tennis prologue to win first', () => { // Oliver, 2026-08-15: "it should not have the normal tennis intro anymore, we should just start // right in splatter mode." The transition is not deleted (forceZombies still plays it) — what is // gone is it standing between the menu and the mode. restartRun(); assert.equal(G.mode, 'tennis', 'a fresh run starts where every run starts'); startGore(); assert.equal(G.mode, 'splatter', 'the mode you picked is the mode you get'); assert.ok(G.everTransitioned, '...and the prologue can never fire behind it'); assert.ok(zombies.some(z => z.variant === 'c'), 'the man you came to play is already one of them'); assert.ok(G.hideO, 'so the tennis opponent is gone from the far court'); assert.equal(score.server, 'P', 'and the serve is yours'); startGore(); assert.equal(zombies.filter(z => z.variant === 'c').length, 1, 'a second call is a no-op, not a second corpse'); }); test('the gore economy: barrels stop being income, and the late court keeps getting worse', () => { // "in splatter gore it now gets too easy to just play through... maybe we should reduce the // barrels again — pretty early in the game every won game gives a barrel." At 3/8 the gap hit ONE // at game 16 and stayed, so the back half of a run paid a weapon per game won. The floor is the // fix, and it is the property worth pinning: a barrel is a reward, never an income. const gapAt = g => { G.runGames = g; return barrelGap(); }; for (const g of [0, 5, 12, 20, 40, 120]) assert.ok(gapAt(g) >= 2, `game ${g}: a barrel is never one-per-game (gap ${gapAt(g)})`); assert.ok(gapAt(40) <= gapAt(0), 'the late game still earns them faster than the early one'); assert.equal(gapAt(400), TUNE.barrelFloor, '...down to the floor and no further'); // ...and the pressure keeps climbing where the barrels stop const capAt = g => { G.runGames = g; return machCap(); }; assert.equal(capAt(0), TUNE.machMax, 'game one gets the old two guns'); assert.ok(capAt(40) > capAt(0), 'a long run faces more of them'); assert.equal(capAt(400), TUNE.machMaxCap, '...to a ceiling, because the court still has to read'); // the two ramps that used to stop dead around game 25 now run past it assert.ok(1 + Math.min(TUNE.zSpeedMax, 30 * TUNE.zSpeedPerGame) > 1 + Math.min(TUNE.zSpeedMax, 22 * TUNE.zSpeedPerGame), 'shamblers keep gaining speed past game 22'); assert.equal(Math.min(TUNE.nastyMax, 40 * TUNE.nastyPerGame), 1, 'and by the end every body that walks out is off the advanced roster'); G.runGames = 0; }); test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => { _startMatch(true); G.modal = null; G.modalQ.length = 0; G.mode = 'splatter'; const d = G.match; assert.ok(d.cup, 'the cup rides the match machinery'); const CUP_ROUNDS = d.cup.rounds; // THE LEAGUES: the run carries its own rounds const dealt = []; for (let round = 0; round < CUP_ROUNDS.length; round++) { const R = CUP_ROUNDS[round]; assert.ok(!dealt.includes(d.opp), R.name + ' never re-deals a fallen body'); dealt.push(d.opp); assert.equal(d.cup.round, round, 'on ' + R.name); assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')'); const zb = oppOf(); assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round'); for (let g = 1; g <= R.need; g++) { for (let k = 0; k < 4; k++) resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away if (g < R.need) assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on'); } // the round-winning game arms the KILL FALL: the body drops first, bursts after the hold assert.ok(G.matchFall && G.matchFall.kill, R.name + ': the kill fall is armed'); for (let i = 0; i < 4 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill'); if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on } assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds'); assert.equal(G.match.felled, CUP_ROUNDS.length, 'every round felled a body, banked as a match run'); // THE LEAGUES: this cup was league one's, so winning it IS the promotion assert.equal(prof.prof.lg, 2, 'the cup is the promotion gate'); const pr = prof.takePromo(); assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play'); }); // ---- THE SEASON: the tournament is the story, so the club has to remember the attempts -------- test('THE SEASON: the fixture names the league, its rounds and the body that opens it', () => { for (const l of cfg.LEAGUES) { const fx = cfg.cupFixture(l.id), R = cfg.cupRoundsFor(l.id); assert.equal(fx.rung, R[0].band[0], l.name + ': the fixture opens on round one\'s rung'); assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body'); assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE', l.name + ': the title carries the league'); // the text reduction moved the round COUNT off the button and into the modal's sub line assert.ok(fx.line.includes(R[0].name) && fx.line.includes(fx.foe.name), l.name + ': the line names the first round and the body in it'); assert.ok(fx.sub.includes(String(R.length)) && fx.sub.includes(String(l.id)), l.name + ': the sub carries the league and how many rounds it runs'); } // the champion has nothing above him: his fixture is a defence, not a promotion assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE'); // ...and a record reads in the rounds' own words const R3 = cfg.cupRoundsFor(3); assert.equal(cfg.cupRoundName(3, 1), R3[0].name, 'one round reached is round one'); assert.equal(cfg.cupRoundName(3, R3.length), 'THE FINAL', 'the last round is the final'); }); test('THE SEASON: a cup exit is recorded, forward only, and names who put you out', () => { prof.prof.lg = 3; _startMatch(true); // league 3's cup, round one G.modal = null; G.modalQ.length = 0; G.match.cup.round = 2; // deep: the semi-final const rung = G.match.cup.rung; killPlayer(); assert.equal(prof.cupBest(3), 3, 'the round you were in is the round you reached'); assert.equal(prof.prof.out, rung, 'the body holding the racket is the one that put you out'); assert.equal(prof.cupBest(1), 0, 'and it is that league\'s record, not the club\'s'); // a shallower attempt afterwards leaves the record standing — and still updates the eliminator restartRun(); G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); prof.prof.lg = 3; _startMatch(true); G.modal = null; G.modalQ.length = 0; G.match.cup.round = 0; const rung2 = G.match.cup.rung; killPlayer(); assert.equal(prof.cupBest(3), 3, 'a worse run never erases a better one'); assert.equal(prof.prof.out, rung2, 'but the LAST body to end an attempt is always the offer'); // the training court is not an attempt and writes nothing const out = prof.prof.out; restartRun(); G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); _startMatch(false, null, 2); G.modal = null; G.modalQ.length = 0; killPlayer(); assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt'); }); test('THE CUP BOARD: the league ranks, the level it was taken at breaks the tie', () => { // the inscription is written at the promotion and never again — a league is taken once prof.prof.lg = 2; prof.prof.xp = prof.xpFor(9); prof.recordCupWin(1, prof.levelAt(prof.prof.xp)); assert.equal(prof.cupAt(1), 9, 'the level that won the cup is the one inscribed'); prof.recordCupWin(1, 3); assert.equal(prof.cupAt(1), 9, '...and a later write never overwrites it'); assert.equal(prof.cupAt(2), 0, 'a league not yet taken carries no inscription'); // the board itself: league is the score, the level the tie-break, and it is the ONE board whose // second column is better LOW (server.py's rank_key is the other half of this) const B = statsMod.BOARDS[statsMod.MODE_CUP]; assert.equal(B.score({ k: 4, g: 22 }), 4, 'the league ranks'); assert.equal(B.sub({ k: 4, g: 22 }), 22, 'the level rides along'); assert.ok(B.subLow, 'and lower is better on it'); }); test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => { // the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad // roll paired the strongest challenger with the shortest round, inverting the tuned curve for (const l of cfg.LEAGUES) { const R = cfg.cupRoundsFor(l.id); const order = R.map(r => (assert.equal(r.band.length, 1, 'one settled body per round'), r.band[0])); for (let i = 1; i < order.length; i++) assert.ok(order[i] > order[i - 1], `${l.name}: round ${i + 1} is the harder body`); assert.equal(order[0], l.band[0], `${l.name} opens on its easiest`); assert.equal(order[order.length - 1], l.band[1], `${l.name} ends on its final`); } // ...and the run reads that order off exactly _startMatch(true); G.modal = null; G.modalQ.length = 0; assert.equal(G.match.opp, 1, 'round one walks on the easiest body'); endMatch(); }); test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => { // Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while // matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the // no-fall path re-armed synchronously, and the after-set latch went stale ("basically like // freeze from before"). The latch is set BEFORE the resolve now (the swallow exempts the // resolution in progress via G.matchEarly), and willFall mirrors the cup divisor exactly. _startMatch(true); G.modal = null; G.modalQ.length = 0; G.mode = 'splatter'; const d = G.match; G.interlude.introT = 0; for (let k = 0; k < 4; k++) resolveInterlude('P'); // round one falls to the ordinary path G.modal = null; G.modalQ.length = 0; passMopBeat(); assert.equal(d.cup.round, 1, 'on the quarter-final'); const zb = oppOf(); const launch = () => { zb.x = -4; zb.z = 2; ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.wid = 'ball'; ball.collected = false; ball.x = 3.4; ball.z = 7; ball.y = 0.5; ball.vx = 0.5; ball.vy = -2; ball.vz = -6; ball.g = TUNE.gravity; G.phase = 'rally'; G.wasServe = false; const before = score.pts[0] + score.games[0] * 10; for (let i = 0; i < 8 * 60 && score.pts[0] + score.games[0] * 10 === before && !G.matchFall; i++) update(DT); for (let i = 0; i < 6 * 60 && (G.matchFall || G.modal); i++) { G.modal = null; G.modalQ.length = 0; update(DT); } }; launch(); // 8 -> 7: crosses nothing (the old mispredict) assert.equal(zb.hp, 7, 'point one chipped the pool'); assert.ok(!d.decided, 'no stale latch after a no-crossing point'); launch(); // 7 -> 6: still nothing launch(); // 6 -> 5: the arm comes off, mid-flight assert.ok(!d.decided, 'the fall beat ended and released its latch'); launch(); // the game point, mid-round: same body plays on assert.ok(zombies.includes(zb), 'game one banked, the quarter-final body stands'); assert.equal(score.games[0], 2, 'the game went up'); launch(); // game two opens: the match is ALIVE assert.equal(score.pts[0], 1, 'the next rally resolved — no freeze'); }); test('service alternates by game, and the opponent actually serves', () => { // Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls // advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return // timing is half the sport and it was never asked for. startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); assert.equal(score.server, 'P', 'you open the match'); for (let pt = 0; pt < 4; pt++) { // four straight points is the game resolveInterlude('P'); for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish } assert.equal(score.server, 'O', 'their serve next game'); passMopBeat(); // the mop beat (and the boy's exit) come first // the opponent stands behind ITS baseline to serve, not on the receiving mark const rz = zombies.find(z => z.variant === 'r'); assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2)); // ...and it tosses and strikes on its own, once the walk-on beat is over let served = false; for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; } assert.ok(served, 'the opponent serves without the player touching anything'); }); test('a lost point never fells the opponent', () => { // The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a // collapse there would read as the opponent being hurt by winning. startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); const hp = G.hp; resolveInterlude('O'); assert.equal(G.matchFall, null, 'their point starts no fall'); assert.equal(G.hp, hp - 1, '...it draws your blood instead'); }); test('the placement mark fades on its own clock', () => { // The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the // state machine owes is that a mark never lingers — a permanent ring would read as a target the // player must hit rather than feedback on where the last shot went. startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); G.placeMark = { x: 2, z: 4, t: 0.8 }; for (let i = 0; i < 60; i++) update(DT); assert.equal(G.placeMark, null, 'gone within a second'); }); test('REACHABILITY: an auto-run player can cover the first opponent\'s returns', () => { // The test this issue always deserved and never had — "unreachable balls" came back TWICE because // the suite only asserted the config dials, never the experience. This plays the match headless // with the same mediocre autopilot the browser uses (auto-run + a fixed tap window, no placement) // and asserts that the returns actually present themselves: a ball that never comes within volley // reach of an auto-run player is unreachable BY CONSTRUCTION, whatever the knobs claim. const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; try { startMatch(); for (let i = 0; i < 2.2 * 60; i++) update(DT); const returns = []; let cur = null; for (let i = 0; i < 90 * 60 && G.mode !== 'eaten'; i++) { G.modal = null; G.modalQ.length = 0; if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!cur) cur = { minD: 99 }; cur.minD = Math.min(cur.minD, d); if (d < 2.9 && d > 2.2) keyPressed('Space'); } if (cur && (!ball.active || ball.lastHit !== 'O')) { returns.push(cur.minD); cur = null; } update(DT); } assert.ok(returns.length >= 10, 'the soak saw a real rally sample: ' + returns.length + ' returns'); const unreachable = returns.filter(d => d > 2.6).length; // never inside volley reach assert.ok(unreachable / returns.length <= 0.15, unreachable + ' of ' + returns.length + ' returns never came within reach of an auto-run player'); } finally { TUNE.autoAssist = assist; } }); test('a served ball with a slide on it is no longer a free kill', () => { // Oliver: "the moment you serve you can serve hard with a slight slide and almost all of them // fail to get the ball" — the first ten opponents died to one gesture. Measured before the fix: // a flush, hard, swayed serve was returned 0 times out of 24 at EVERY rung of the ladder, because // the receiver waited on the tennis mark (1.3 m off the centre line, covering the T and conceding // the whole corner) and then chased the ball's LIVE position, which during a serve is still up by // the server's baseline. Mark, first step and read are all dialled in config; this asserts the // EXPERIENCE, not the dials, exactly like the reachability soak above. // A serve into the very corner of the box is deliberately still an ace at the bottom of the // ladder — the trick that had to die is the one that needed no placement. const serve = sx => { freshSplatter(); startMatch(); for (let i = 0; i < 2.2 * 60; i++) update(DT); ball.active = true; ball.collected = false; ball.bounces = 0; ball.scored = false; ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.6; ball.vx = ball.vy = ball.vz = 0; G.wasServe = true; hitServe(P, 1, { sx, hard: 1 }); // flush timing, full pace, `sx` of slide for (let i = 0; i < 5 * 60; i++) { update(DT); if (ball.lastHit === 'O') return true; // he got a racket on it if (!ball.active || G.phase !== 'rally') break; } return false; }; // n = 40, NOT 10, and this is the arithmetic to copy for any other rate gate here. The real return // rate is ~0.88 (measured, 200 trials a seed across eight seeds: 0.85..0.92), so a 10-sample gate // asking for 6 fails whenever the tail lands — p ~= 0.004, which is one seed in 230 and duly showed // up as SEED=80. At 40 the same 60% bar sits five standard deviations below the true rate: the gate // still fails instantly if the receiver regresses toward the 0-in-24 this test was born from, and // it costs nothing (a returned serve breaks the frame loop early). let got = 0; const n = 40; for (let i = 0; i < n; i++) if (serve(-0.5)) got++; assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`); }); test('the match racket is longer, and only the match racket', () => { assert.ok(TUNE.matchReach > 1.2, 'match contact reach is widened — the recorder showed misses by centimetres'); // the gore modes' contact stays untouched: matchReach only multiplies inside G.match }); test('the ladder pace dial exists and starts below full pace', () => { assert.ok(TUNE.matchOppPace < 1, 'opponent one returns slower than the physics maximum'); assert.ok(TUNE.matchOppPacePer > 0, 'and earns pace back per rung'); }); test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => { // tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still // said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot // line (COURT.L + 0.3), so the moment the toss went up the game scored "its passing shot ends the // duel": a point and blood per TAP. serveNum was a red herring; this was the loop Oliver hit twice. startMatch(); for (let i = 0; i < 2.0 * 60; i++) update(DT); // ride out the intro beat — it blocks serving const hp0 = G.hp, pts0 = score.pts.slice(); ball.lastHit = 'O'; ball.active = false; // the zombie touched the last rally ball assert.ok(P.z > COURT.L + 0.3, 'setup: the server stands behind the passing-shot line'); keyPressed('Space'); // the toss goes up assert.equal(G.phase, 'toss', 'the toss is up'); for (let i = 0; i < 40; i++) update(DT); // two thirds of a second of flight assert.equal(G.hp, hp0, 'no blood for tossing a ball'); assert.deepEqual(score.pts, pts0, 'and no point changes hands'); assert.equal(ball.lastHit, 'P', 'the toss is the server\'s ball'); }); test('every point starts with the receiver on its mark', () => { startMatch(); const rz = oppOf(); rz.x = 6; rz.z = 12; // stranded mid-court by the last rally resolveInterlude('P'); assert.ok(Math.abs(rz.x) < 3 && rz.z <= 1.5, 'reset to the receiver spot, not wherever the rally left it: x=' + rz.x.toFixed(1) + ' z=' + rz.z.toFixed(1)); assert.equal(rz.x, O.x, 'on the exact mark the tennis branch computed for the receiver'); }); test('the ladder escalates in speed and reliability, not in hp', () => { // pure arithmetic on the knobs racketAI reads — pinned so a future tuning pass cannot silently // flatten the ladder again assert.ok(TUNE.matchSpeedBase > 1, 'opponent one is already quicker than the duel lumber'); assert.ok(TUNE.matchSpeedPer > 0, 'each opponent is faster'); assert.ok(TUNE.matchFlub < TUNE.racketFlub, 'the match whiffs less than the duel from the start'); assert.ok(TUNE.matchFlubKeep < 1, 'and less again per rung'); }); test('a game won heals, so a clean player can go the distance', () => { startMatch(); G.hp = 1; for (let k = 0; k < 4; k++) resolveInterlude('P'); for (let i = 0; i < 5 * 60 && G.matchFall; i++) update(DT); // the heal waits for the burst now assert.equal(G.hp, Math.min(G.hpMax, 1 + TUNE.matchHeal), 'the game paid its heal'); }); test('regicide: killing the body mid-game wins the game outright', () => { startMatch(); const zb = oppOf(); resolveInterlude('P'); // 15-0, so the score is mid-game const games = score.games[0]; zombies.length = 0; // the body is destroyed by a shot, not by the score resolveInterlude('P'); // killZombie's exit lands here with the court empty assert.equal(score.games[0], games + 1, 'the game banks'); assert.deepEqual(score.pts, [0, 0], 'the part-played points reset'); passMopBeat(); assert.ok(oppOf(), 'and the next opponent is on'); }); test('the match measures OPPONENTS FELLED, banked as its own kind of run', () => { localStorage.removeItem('splatterRuns'); startMatch(); for (let g = 0; g < 2; g++) { // two games = two bodies down for (let k = 0; k < 4; k++) resolveInterlude('P'); passMopBeat(); // each corpse gets its mop beat } assert.equal(G.match.opp, 3, 'on the third rung'); G.hp = 1; resolveInterlude('O'); assert.equal(G.mode, 'eaten', 'bled out'); assert.equal(G.match.felled, 2, 'the summary counts bodies, not games'); assert.ok(G.newBest, 'first run of the day is a record'); // a shorter second run must NOT overwrite the mark, and must know what it is chasing G.mode = 'splatter'; endMatch(); startMatch(); for (let k = 0; k < 4; k++) resolveInterlude('P'); passMopBeat(); // no points settle while the boy owns the court G.hp = 1; resolveInterlude('O'); assert.equal(G.match.felled, 1); assert.equal(G.match.best, 2, 'the mark to beat is shown'); assert.ok(!G.newBest, 'and one body does not beat two'); // ...and none of it lands in the gore mode's stats, which is where it all used to go: a felled // opponent is a kill credited to 'bomb', and three of them are not a massacre. const runs = JSON.parse(localStorage.getItem('splatterRuns')); assert.ok(runs.every(r => r.m === 'z'), 'both runs are tagged as match runs'); assert.equal(readStats('all').kills, 0, 'the gore screen shows nothing from a match'); assert.equal(readStats('all', undefined, 'z').longest, 2, '...and the match view knows the best'); G.mode = 'splatter'; endMatch(); }); // ---- classic tennis: the mode where nothing turns --------------------------------------------- test('classic tennis: a game is not a set, and six with a two-game lead is', () => { G.mode = 'tennis'; G.classic = true; G.everTransitioned = false; score.games = [5, 4]; assert.equal(classicGame('P'), false, '5-4 is not a set — advancePoint has not banked the sixth yet'); score.games = [6, 5]; assert.equal(classicGame('P'), false, 'nor is 6-5: the two-game lead is the rule'); assert.deepEqual(G.sets, [0, 0]); score.games = [7, 5]; assert.equal(classicGame('P'), false, '7-5 IS a set, but not the match'); assert.deepEqual(G.sets, [1, 0], 'the set is banked'); assert.deepEqual(score.games, [0, 0], 'and the games reset under it'); assert.equal(G.done, null, 'one set is not a match'); }); test('the toss strike has stakes: flush is fast, a miss is slow, the old free window is gone', () => { // the serve-agency pass: apexSnap/apexFall are TUNE knobs and deliberately tight, so timing // must show up in the ball's actual pace (the fault economy then prices the aimed serve) G.mode = 'tennis'; G.classic = true; G.match = null; G.interlude = false; G.done = null; const strike = dy => { G.phase = 'pretoss'; ball.active = false; score.server = 'P'; P.pose = 'idle'; keyPressed('Space'); // toss assert.equal(G.phase, 'toss', 'the tap tossed'); ball.y = serveApexY() - dy; ball.vy = 0.1; // park the ball dy below the apex keyPressed('Space'); // strike assert.equal(G.phase, 'rally', 'the strike fired'); return Math.hypot(ball.vx, ball.vy, ball.vz); }; const flush = strike(0); const oldFree = strike(0.1); // inside the old 0.12 snap, outside the new one const missed = strike(0.4); assert.ok(flush > missed * 1.2, `a flush strike is materially faster (${flush.toFixed(1)} vs ${missed.toFixed(1)})`); assert.ok(oldFree < flush - 0.5, 'the 0.12 m free-perfect window no longer exists'); }); test('classic tennis: the second set ends the run, with no corpse', () => { G.mode = 'tennis'; G.classic = true; G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14; G.classicWon = 12; assert.equal(classicGame('P'), true, 'the match is over'); assert.ok(G.done && G.done.won, 'and you won it'); assert.notEqual(G.mode, 'eaten', 'nobody died — this is the other ending'); const runs = JSON.parse(localStorage.getItem('splatterRuns')); const r = runs[runs.length - 1]; assert.equal(r.m, 'c', 'banked as a classic run'); assert.equal(r.dead, 0, 'a match WON is not a death — that is how wins are counted'); // GAMES YOU WON, not sets: it is what classic's world board ranks, because every win is two sets // and a board of nothing but 2s is a wall of ties. Nothing ever displayed the old value. assert.equal(r.s, 12, 'games won ride in the second headline slot'); assert.equal(statsMod.BOARDS.c.score(r), 12, "...and that is the classic board's number"); }); test('classic tennis: losing the match ends it the same way, and counts as a loss', () => { G.mode = 'tennis'; G.classic = true; G.sets = [0, 1]; score.games = [0, 6]; G.runGames = 12; assert.equal(classicGame('O'), true); assert.ok(G.done && !G.done.won, 'beaten'); const runs = JSON.parse(localStorage.getItem('splatterRuns')); assert.equal(runs[runs.length - 1].dead, 1, 'a match lost IS the run ending badly'); }); test('classic wears the match feel, and the difficulty table is a ladder', () => { // THE RALLY MODES SHARE ONE PLAYER FEEL: classic gets the match's assist floor (with matchReach // and the short-ball chase riding the same rallyFeel() switch) — "in classic tennis I tend to // miss balls way more often" was these dials being match-only. G.classic = false; G.match = null; assert.ok(!rallyFeel(), 'gore alone keeps the old feel'); G.classic = true; assert.ok(rallyFeel(), 'classic is a rally mode'); assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor'); G.classic = false; // ...and DIFFS climbs monotonically on every dial. MEDIUM used to be pinned dial-for-dial to the // shipped TUNE values; that pin is gone (Oliver, 2026-08-15: "medium is too easy") — the middle // chip is now its own tuning, above what "no difficulty" played like, and HARD stepped back. // (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach, // qMin and serve are the dials a player actually reads as skill.) const [e, m, h] = cfg.DIFFS; assert.ok(e.flub > m.flub && m.flub > h.flub, 'easier flubs more'); assert.ok(e.quality < m.quality && m.quality < h.quality, 'harder hits better'); assert.ok(e.speed < m.speed && m.speed < h.speed, 'harder runs faster'); assert.ok(e.reaction > m.reaction && m.reaction > h.reaction, 'harder reads the shot sooner'); assert.ok(e.reach < m.reach && m.reach < h.reach, 'harder swings a longer racket'); assert.ok(e.qMin <= m.qMin && m.qMin < h.qMin, 'harder raises the quality floor'); assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better'); assert.ok(e.smart <= m.smart && m.smart < h.smart, 'harder plays the open court'); assert.ok(e.recover <= m.recover && m.recover < h.recover, 'harder recovers to centre faster'); // EASY is a step below the shipped no-difficulty CPU, MEDIUM a step above it — that ordering is // the point of the retune, so it stays pinned even though the exact numbers are free to move. assert.ok(e.flub > TUNE.classicFlub && m.flub < TUNE.classicFlub, 'EASY misses more than shipped, MEDIUM less'); assert.ok(e.reaction > TUNE.aiReaction && m.reaction < TUNE.aiReaction, 'MEDIUM reads faster than shipped'); assert.ok(e.reach < TUNE.aiReach && m.reach > TUNE.aiReach, 'MEDIUM out-reaches shipped'); }); // ---- NETPLAY (src/net.js + beats.startNetVs). The shot is the sync unit and THE RECEIVER OF A // FLIGHT IS ITS ONLY JUDGE — these pin both halves of that rule, with netTest.sent standing in // for the socket so the wire traffic is a plain array the assertions can read. function freshNet(side) { restartRun(); G.classic = true; // netplay rides the classic tennis machinery assert.ok(startNetVs(), 'net vs takes a clean court'); netTest.sent = []; netMsg({ t: 'start', side }); // paired: 'A' hosts and serves first } test('netplay: the vs match owns the court both ways', () => { assert.ok(_startMatch(), 'setup: the match takes the court'); assert.ok(!startNetVs(), 'netplay refuses while the match owns it'); endMatch(); assert.ok(startNetVs(), 'and takes it once the match is gone'); assert.ok(!_startMatch(), 'the match refuses while netplay owns the court'); assert.ok(!startDrill(), 'the drill refuses too'); startDuel(); assert.equal(G.interlude, false, 'and no duel can begin'); endNetVs(); assert.equal(G.net, null, 'endNetVs hands the court back'); }); test('netplay: the mirror is a half-turn about the net and its own inverse', () => { const s = { x: 1.3, y: 2.2, z: 24.3, vx: -3.2, vy: 4.1, vz: -28 }; const m = mirror(s); assert.ok(s.z > COURT.netZ && m.z < COURT.netZ, 'a near-baseline strike lands on the far half'); assert.ok(m.vz > 0, 'travelling away becomes travelling toward'); const rt = mirror(m); for (const k of ['x', 'y', 'z', 'vx', 'vy', 'vz']) assert.ok(Math.abs(rt[k] - s[k]) < 1e-9, `two mirrors are the identity (${k})`); }); test('netplay: a remote serve arrives mirrored and arms the receiver as its judge', () => { freshNet('B'); // they host, they serve assert.equal(score.server, 'O', 'the host serves first on both scoreboards'); // their serve event, in THEIR coordinates: struck from behind their near baseline netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true }); assert.ok(ball.active && ball.lastHit === 'O', 'their ball, live on this court'); assert.ok(ball.z < COURT.netZ, 'launched from the far half'); assert.ok(ball.vz > 0, 'and travelling toward the receiver'); assert.equal(G.phase, 'rally'); assert.ok(G.wasServe, 'the receiver\'s own service-box machinery is armed'); }); test('netplay: the receiver judges the flight — an untouched winner scores and goes over the wire', () => { freshNet('B'); // a legal rally ball from their side (their frame): lands mid-court, never swung at netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false }); for (let i = 0; i < 900 && ball.active; i++) update(DT); assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge'); assert.deepEqual(score.pts, [0, 1], 'their untouched winner is their point'); const pt = netTest.sent.find(m => m.t === 'pt'); assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone, winner relative to sender'); }); test('netplay: an outbound ball is picture — this phone never judges its own shot', () => { freshNet('A'); G.phase = 'rally'; ball.active = true; ball.collected = false; ball.bounces = 0; ball.x = 0; ball.y = 1.5; ball.z = 20; ball.vx = 8; ball.vy = 3; ball.vz = -16; ball.g = 9.2; ball.lastHit = 'P'; G.wasServe = false; // a rally ball flying well OUT — 8 m/s of sideways const pts = JSON.stringify(score.pts); for (let i = 0; i < 240; i++) update(DT); assert.equal(JSON.stringify(score.pts), pts, 'no local verdict on an outbound flight'); assert.ok(!netTest.sent.some(m => m.t === 'pt' || m.t === 'fault'), 'and none was sent'); netMsg({ t: 'pt', win: 'me', reason: 'out' }); // their call arrives instead assert.deepEqual(score.pts, [0, 1], 'their call, this scoreboard'); assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); }); test('netplay: the toss travels — the receiver watches the serve coming', () => { freshNet('A'); // this phone serves: its toss must go over the wire keyPressed('Space'); assert.equal(G.phase, 'toss', 'the tap tossed'); assert.ok(netTest.sent.some(m => m.t === 'toss'), 'the toss is an event'); freshNet('B'); // ...and on the receiving phone netMsg({ t: 'toss' }); assert.equal(G.phase, 'toss', 'their toss is a live toss here'); assert.ok(ball.active && ball.lastHit === 'O' && ball.vy > 0, 'a real ball rising over O'); assert.ok(ball.z < COURT.netZ, 'on the far half'); for (let i = 0; i < 300 && G.phase === 'toss'; i++) update(DT); // nobody strikes it assert.equal(G.phase, 'pretoss', 'an uncaught remote toss falls back by plain physics'); netMsg({ t: 'toss' }); // the re-toss, then the strike over it netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true }); assert.equal(G.phase, 'rally', 'the strike lands cleanly over the replayed toss'); assert.ok(ball.vz > 0, 'and the serve is inbound'); }); test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s second serve', () => { freshNet('B'); // they serve — long, out of the box netMsg({ t: 'shot', x: 0.5, y: 2.6, z: 24.3, vx: 0, vy: 1, vz: -35, g: 9.2, pow: 1, serve: true }); for (let i = 0; i < 300 && ball.active; i++) update(DT); assert.equal(G.serveNum, 2, 'the receiver\'s copy of the point advances to second serve'); assert.ok(netTest.sent.some(m => m.t === 'fault'), 'the fault went to their phone'); assert.deepEqual(score.pts, [0, 0], 'a first fault is not a point'); freshNet('A'); // ...and on the server phone the same message lands G.serveNum = 1; G.phase = 'rally'; ball.active = true; ball.lastHit = 'P'; G.wasServe = true; netMsg({ t: 'fault' }); assert.equal(G.serveNum, 2, 'their call re-armed the second serve'); // THE CALLED BALL PLAYS OUT (2026-08-15). It used to be switched off where it stood — which on // the striker's phone is a whole court and a latency away from the call, and read as the game // loop stopping. It is `scored` (nothing it does can move the scoreboard again) and still LIVE. assert.ok(ball.scored, 'the call is made: this flight can never score again'); assert.ok(ball.active, '...but the ball keeps rolling instead of freezing where the call caught it'); assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back'); const pts = score.pts.slice(), sn = G.serveNum; for (let i = 0; i < 600 && ball.active; i++) update(DT); assert.ok(!ball.active, 'and it comes to a natural halt by itself'); assert.deepEqual(score.pts, pts, 'the roll scored nothing on the way'); assert.equal(G.serveNum, sn, '...and faulted nothing either'); }); test('netplay: the host\'s court rides the room, so both phones play the same surface', () => { const { court, selectCourt } = state; selectCourt('grass'); freshNet('B'); // ...and the start message says otherwise netMsg({ t: 'start', side: 'B', court: 'clay' }); assert.equal(state.court.id, 'clay', 'the guest is moved onto the host\'s court'); assert.equal(TUNE.bounceXZ, cfg.COURTS.find(c => c.id === 'clay').bounceXZ, '...and onto its physics, not just its picture'); netMsg({ t: 'start', side: 'B' }); // an old build sends no court: keep what we have assert.equal(state.court.id, 'clay', 'a start without a court changes nothing'); selectCourt('grass'); }); test('netplay: the scoreboards reconcile, so one lost verdict cannot lock the court', () => { // THE BUG (Oliver, over cellular): "both players stand on the same side and nobody can serve." // setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so two sims // a point apart place both players on the same half and each waits for the other. The host's // pretoss stream carries its scoreboard; the guest adopts it, mirrored. freshNet('B'); // we are the guest: the host is the authority score.pts = [2, 1]; score.server = 'P'; // ...and we have drifted a point away from it const myX = P.x; netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [2, 3] }); // their P serves, their 2 to our 3 assert.equal(score.server, 'O', 'the server is taken from the host, mirrored'); assert.deepEqual(score.pts, [3, 2], '...and so are the points'); assert.notEqual(P.x, myX, 'the point is re-armed, so both bodies stand where the new score puts them'); // an ordinary position frame carries no scoreboard and must never touch one score.pts = [1, 1]; netMsg({ t: 'pos', x: 0, z: 1, m: 0 }); assert.deepEqual(score.pts, [1, 1], 'a plain pos frame is picture only'); // ...and a live rally is never reconciled underneath itself G.phase = 'rally'; netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [3, 3] }); assert.deepEqual(score.pts, [1, 1], 'mid-rally the scoreboard is left alone'); // only the HOST publishes it: two authorities is the drift, not the cure freshNet('B'); netTest.sent = []; G.phase = 'pretoss'; netTick(0.2); assert.ok(netTest.sent.some(m => m.t === 'pos'), 'the guest still streams its position'); assert.ok(!netTest.sent.some(m => m.srv), '...but never a scoreboard'); freshNet('A'); netTest.sent = []; G.phase = 'pretoss'; netTick(0.2); assert.ok(netTest.sent.some(m => m.t === 'pos' && m.srv), 'the host publishes its scoreboard'); }); // ---- NETPLAY DEATH MATCH: the host's pick rides the room's start message, and the match // machinery is driven by the same receiver-is-judge verdicts classic uses — routed through // resolveInterlude instead of pointTo, so wounds, blood, the fall and the burst all come along. function freshNetDeath(side) { restartRun(); G.classic = true; // the waiting court, exactly as the menu builds it assert.ok(startNetVs(), 'net vs takes a clean court'); netTest.sent = []; netMsg({ t: 'start', side, death: true }); } test('netplay death match: the host\'s pick rebuilds the court on pairing — friend, not roster body', () => { freshNetDeath('B'); assert.ok(G.net && G.net.ready && G.net.death, 'the wire survives the mode swap'); assert.equal(G.mode, 'splatter'); assert.ok(G.match && G.interlude, 'the match machinery owns the court'); assert.ok(!zombies.some(z => z.variant === 'r'), 'NO roster body — the friend IS the opponent'); assert.equal(G.hideO, false, 'the living opponent is the picture'); assert.equal(G.card, null, 'and no SHAMBLER walk-on card'); assert.equal(score.server, 'O', 'the host serves first here too'); assert.equal(G.modal, null, 'no popup may eat the first remote serve'); }); test('netplay death match: THE BURST IS THE DEATH — a won game is only a game', () => { // Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner // already at 0:40 — the winner sees the zombie explode but the player on the other side is not // dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight. freshNetDeath('A'); netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them assert.equal(score.pts[0], 1, 'the point banks'); assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); const blood = G.hp; netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me assert.equal(G.hp, blood - 1, 'their point draws my blood'); // THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length; netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... ) O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; } assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says'); netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 }); for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; } assert.ok(onHim() > 0, '...and one a point from death bleeds hard'); score.pts = [3, 0]; netMsg({ t: 'pt', win: 'you' }); // the game-winning point assert.equal(score.games[0], 1, 'game banked'); assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing'); assert.ok(!G.done, '...and the match is not over'); for (let i = 0; i < 60 * 6; i++) update(DT); assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on'); assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game'); assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again'); }); test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => { freshNetDeath('B'); netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false }); const blood = G.hp; for (let i = 0; i < 900 && ball.active; i++) update(DT); assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge'); assert.equal(G.hp, blood - 1, 'their untouched winner draws my blood'); const pt = netTest.sent.find(m => m.t === 'pt'); assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone'); }); test('netplay death match: bleeding out is said on the wire, and the other phone wins on it', () => { freshNetDeath('B'); G.hp = 1; netMsg({ t: 'pt', win: 'me' }); // the point that empties me assert.equal(G.mode, 'eaten', 'my run ends as every match death does'); assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud'); freshNetDeath('A'); // ...and the winner's phone, receiving it score.games[0] = 3; const before = parts.length; netMsg({ t: 'dead' }); assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart'); assert.ok(parts.length > before, '...and the gore is real'); assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug'); for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT); assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst'); assert.equal(G.match.felled, 3, 'felled = the games won on the way'); }); // THE FRIEND'S RACKET (Oliver, 2026-09-11). Every other opponent is simulated locally and cracks // through gameplay's own strike line; the friend's swing runs on nobody's phone but theirs, so the // only place the sound can come from is the shot message — and only AFTER the ball it is pitched // off has been written. test('netplay: the friend\'s strike makes a noise, pitched off the ball it just sent', () => { freshNet('B'); audio.sfxTest.log = []; netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false }); assert.ok(audio.sfxTest.log.includes('tennis-opponent'), 'the far racket cracks on the wire event'); audio.sfxTest.log = null; }); // ---- OPEN 13: the four missing sounds (2026-09-23). Synthesized, so the ear is the phone's job; // what the suite pins is that each one fires at its EVENT, and that fire stays one clock, not a // sound per body. test('the machine squeaks, fire crackles and catches, the gun cracks, the boss stomps and growls', () => { const heard = n => audio.sfxTest.log.includes(n); freshSplatter(); audio.sfxTest.log = []; spawnZombie(0, 8, 'm'); const m = zombies[zombies.length - 1]; m.rollTo = 3; m.rollT = 0; m.fireT = 99; // keep it ROLLING for the whole window: one squeak per grind tick is a 60% roll, and a single 3 m // trip is only ~5 ticks — about 1 seed in 100 heard nothing (seed 31337 found it) for (let i = 0; i < 60 * 4 && !heard('squeak'); i++) { if (m.rollTo === undefined) m.rollTo = m.x > 0 ? -3 : 3; update(DT); G.hp = 9; } assert.ok(heard('squeak'), 'a rolling machine announces itself'); freshSplatter(); audio.sfxTest.log = []; for (let k = 0; k < 8; k++) spawnZombie(-1.4 + k * 0.4, 6, 'a'); zombies[0].burnT = 3; update(DT); assert.ok(heard('crackle'), 'a burning body crackles'); assert.equal(audio.sfxTest.log.filter(n => n === 'whump').length, 1, 'an epidemic lights the row, and says so ONCE'); const lit = zombies.filter(z => z.burnT > 0).length; audio.sfxTest.log = []; for (let i = 0; i < 60; i++) update(DT); const crackles = audio.sfxTest.log.filter(n => n === 'crackle').length; assert.ok(lit >= 3 && crackles >= 3 && crackles <= 16, `one court clock, not one per body: ${crackles} crackles/s for ${lit} lit`); audio.sfxTest.log = []; for (let k = 0; k < 4; k++) firePellet(); assert.equal(audio.sfxTest.log.filter(n => n === 'gunshot').length, 2, 'the gun cracks every other round'); freshSplatter(); startBossFight(1); audio.sfxTest.log = []; for (let i = 0; i < 60 * 30 && !(heard('stomp') && heard('growl')); i++) { update(DT); G.hp = 9; } assert.ok(heard('stomp'), 'the boss has feet'); assert.ok(heard('growl'), 'and a throat: the wind-up draws breath'); audio.sfxTest.log = null; }); // ---- THE REMATCH. The relay cannot re-pair (its room code is popped on pairing), so playing the // same friend again is the SOCKET THAT IS STILL OPEN: one 'again' frame, both phones reset the run // under the decisions the room already carries. The desync to guard against is the two courts // coming up different — so these assert the whole handover, from both seats. test('netplay rematch: one frame resets both phones onto the same fresh court', () => { freshNetDeath('A'); // the host: my serve, death match score.games = [2, 1]; score.pts = [3, 0]; G.hp = 2; netMsg({ t: 'dead' }); // their blood ran out: the win ending, burst and all for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT); assert.ok(G.done && G.hideO, 'setup: the summary is up over a burst friend'); netTest.sent = []; assert.ok(netAgain(), 'the line is up: the rematch takes it'); assert.ok(netTest.sent.some(m => m.t === 'again'), 'and the friend is told'); assert.ok(G.net && G.net.ready && G.net.death, 'THE PAIRING SURVIVES THE RESET — same room, same mode'); assert.ok(!G.done && G.mode === 'splatter' && G.match, 'the match machinery owns the court again'); assert.deepEqual([score.games, score.pts], [[0, 0], [0, 0]], 'nothing carries over'); assert.equal(G.hp, G.hpMax, 'full blood'); assert.equal(G.hideO, false, 'and the friend is standing'); assert.equal(score.server, 'P', 'the host still serves first, on both scoreboards'); assert.ok(!zombies.some(z => z.variant === 'r'), 'still no roster body — the friend IS the opponent'); assert.equal(G.modal, null, 'and no DEATH MATCH explainer may eat the first serve'); // the other seat — the one that LOST, which is the phone most likely to ask for another: their // frame arrives and does the same thing, WITHOUT echoing one back freshNetDeath('B'); G.hp = 1; netMsg({ t: 'pt', win: 'me' }); assert.equal(G.mode, 'eaten', 'setup: bled out, on the death screen'); netTest.sent = []; netMsg({ t: 'again' }); assert.ok(G.mode === 'splatter' && G.match && G.net.ready, 'their tap restarts this phone too'); assert.equal(G.hp, G.hpMax, '...back on its feet with a full pool'); assert.equal(score.server, 'O', 'the host serves here as well — the two courts agree'); assert.ok(!netTest.sent.some(m => m.t === 'again'), 'an applied rematch does not echo back out'); // BOTH TAPPED AT ONCE: the second frame must not restart a court that is already fresh netTest.sent = []; score.pts = [2, 0]; netMsg({ t: 'again' }); assert.deepEqual(score.pts, [2, 0], 'the echo inside the window is swallowed'); }); test('netplay rematch: a dead line refuses, so the summary falls through to the clubhouse', () => { freshNetDeath('A'); netMsg({ t: 'bye' }); // they left: netDead clears ready assert.equal(netAgain(), false, 'no socket, no rematch'); endNetVs(); assert.equal(netAgain(), false, 'and none off a court netplay does not own'); }); // classic netplay has no G.match to rebuild — what it must NOT lose is G.classic itself, which the // menu set on pairing and which resetState wipes (a rematch that came up in gore mode is the bug) test('netplay rematch: the classic duel comes back as classic tennis', () => { freshNet('B'); G.sets = [0, 1]; score.games = [1, 4]; netAgain(); assert.equal(G.classic, true, 'the same game is on the court'); assert.ok(!G.match && G.mode === 'tennis', '...and the death match was never dealt into it'); assert.deepEqual(score.games, [0, 0], 'a fresh match'); assert.equal(score.server, 'O', 'the host serves first'); }); test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => { assert.ok(_startMatch(), 'solo match on a clean court'); G.modal = null; G.modalQ.length = 0; const rz = zombies.find(z => z.variant === 'r'); G.matchFall = { t: 0.3, from: 1, vx: 0, x0: rz.x, kill: true }; // landed, prone, pre-burst const before = parts.length; for (let i = 0; i < 20; i++) update(DT); assert.ok(parts.length > before, 'the prone hold gushes (gushProne), where bleed() is suppressed'); }); // stats.js ships its own fixture-based check — the period filter, the mode split and the name // normalisation that has to agree with server.py. It was exported and never called by anything. test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); }); // ---- THE PLAYER'S COLOURS. The feature's one real claim is that the kit can move without dragging // the skin with it, and the whole claim lives in tintPx. These are the colours MEASURED off // p_idle1.png (the four biggest opaque populations), not invented ones — if the art is ever redrawn // into a palette these buckets do not separate, this is the test that says so. const { tintPx } = await import('./src/render.js'); const SHORTS = [22, 120, 186], SHORTS_DK = [0, 91, 159]; // kit blue, lit and in shadow const SKINPX = [214, 149, 111], SHIRT = [214, 213, 200]; // forearm, and the shirt white const dom = ([r, g, b]) => r > g && r > b ? 'r' : g > b ? 'g' : 'b'; test('kit and skin are separate buckets: recolouring one leaves the other alone', () => { const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0]; assert.equal(asDrawn.h, undefined, 'SKINS[0] is the "as drawn" entry'); // the kit moves, in the light AND in the shadow — a bucket that only catches the lit ramp step // leaves the folds behind in the old colour, which is the failure that would actually be visible assert.equal(dom(tintPx(...SHORTS, red, asDrawn)), 'r', 'lit shorts turn red'); assert.equal(dom(tintPx(...SHORTS_DK, red, asDrawn)), 'r', 'shadowed shorts turn red too'); // ...and the skin does not, which is the question the whole design turns on assert.equal(tintPx(...SKINPX, red, asDrawn), null, 'skin is untouched by a kit change'); assert.equal(tintPx(...SHIRT, red, asDrawn), null, 'the shirt white is in neither bucket'); }); test('skin recolours by lightness, and stays a skin tone rather than rotating to green', () => { const asDrawn = cfg.KITS[0], deep = cfg.SKINS.find(s => s.id === 'deep'); const out = tintPx(...SKINPX, asDrawn, deep); assert.equal(dom(out), 'r', 'a darker tone is still red-dominant, not hue-rotated off the band'); assert.ok(out[0] < SKINPX[0], 'DEEP is genuinely darker than the drawn tan'); assert.equal(tintPx(...SHORTS, asDrawn, deep), null, 'the kit is untouched by a skin change'); }); test('the shading survives the swap: the ramp keeps its order and its spread', () => { const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0]; const lit = tintPx(...SHORTS, red, asDrawn), dk = tintPx(...SHORTS_DK, red, asDrawn); const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; assert.ok(lum(lit) > lum(dk), 'the lit step stays lighter than the shadowed one'); // hue rotation keeps lightness, so the gap between the two ramp steps must survive within rounding assert.ok(Math.abs((lum(lit) - lum(dk)) - (lum(SHORTS) - lum(SHORTS_DK))) < 12, 'the ramp keeps its spread — a flattened one would read as a painted-on patch'); }); // THE TWO NEIGHBOURS THE SKIN BUCKET MUST NOT EAT. Both of these shipped broken once — the shirt // came out mud-spattered, and then the death frames' blood came out brown, which on this game is // the worst bug in the file. Colours measured off p_run1/p_smash (the shirt) and p_die4 (the pool). const SHIRT_WARM = [243, 236, 224], SHIRT_W2 = [240, 232, 224]; // warm HIGHLIGHTS, not neutral const BLOOD = [150, 20, 25], POOL = [170, 15, 20], BLOOD_DK = [92, 10, 14]; test('the shirt stays white — its warm highlights are not skin', () => { for (const sk of cfg.SKINS.slice(1)) { assert.equal(tintPx(...SHIRT_WARM, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`); assert.equal(tintPx(...SHIRT_W2, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`); } }); test('BLOOD STAYS BLOOD — no skin pick may turn the death frames to mud', () => { for (const sk of cfg.SKINS.slice(1)) { for (const [nm, c] of [['spray', BLOOD], ['pool', POOL], ['dried', BLOOD_DK]]) assert.equal(tintPx(...c, cfg.KITS[0], sk), null, `${sk.id} leaves the ${nm} red`); } // ...and it survives a kit change too, which is the other half of the same claim for (const k of cfg.KITS.slice(1)) assert.equal(tintPx(...POOL, k, cfg.SKINS[0]), null, `${k.id} leaves the pool red`); }); // A WHITE KIT IS WASHED, NOT DYED. Rotating a near-neutral does nothing, and the only saturated // blue on her sheets is HER HAIR — same hue as his shorts, so a dye turned it green. Colours // measured off p_idle1_f. const HER_SKIRT = [208, 216, 224], HER_BRIGHT = [240, 248, 248]; // cool-cast whites const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220 test('a white kit takes a wash: her whites colour, her hair and skin do not', () => { assert.equal(cfg.TOURS.find(t => t.id === 'women').kit, 'wash'); assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, 'dye'); const red = cfg.KITS.find(k => k.id === 'red'); for (const c of [HER_SKIRT, HER_BRIGHT]) { const out = tintPx(...c, red, cfg.SKINS[0], 'wash'); assert.ok(out, 'her whites take the colour'); assert.equal(dom(out), 'r', 'CLARET reads as rose on her'); // a WASH, not a dye: it must stay pale, or she is wearing a block of poster paint assert.ok(Math.min(...out) > 150, `${c} stays a pastel, not a solid (${out})`); } // the trap this whole mode exists to avoid, pinned from both sides assert.ok(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'dye'), 'her hair IS in the dye arc — the trap'); assert.equal(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'wash'), null, 'a wash cannot reach it'); assert.equal(tintPx(...SKINPX, red, cfg.SKINS[0], 'wash'), null, 'nor her skin'); }); test('the wash leaves the shading: her folds stay in order', () => { const red = cfg.KITS.find(k => k.id === 'red'); const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; const lit = tintPx(...HER_BRIGHT, red, cfg.SKINS[0], 'wash'); const shade = tintPx(...HER_SKIRT, red, cfg.SKINS[0], 'wash'); assert.ok(lum(lit) > lum(shade), 'the lit fold stays lighter than the shaded one'); }); // NETPLAY: the friend across the net wears their own pick, on the friend's OWN sheets — n_*, the // player's kit drawn from the front (assets/_frontroll.py). The claim this pins is that he needs no // arc of his own any more: every colour below is measured off n_idle1.png / n_idle1_f.png, and they // fall into the SAME two buckets the player's back-view sheets do. // // The old netplay art was the roster opponent's red, on a third arc (`TINT.oKit`, 345->15). Both // halves of why that had to go are pinned here: on his sheet the only thing in that arc was the // SHIRT, so his pick moved a different garment than yours; on hers the arc also held her skin. test('the friend across the net rides the PLAYER arcs: his shorts dye, her whites wash, skin holds', () => { const HIS_SHORTS = [24, 112, 176], HIS_SHIRT = [224, 216, 208], HIS_SKIN = [232, 136, 104]; const HER_KIT = [240, 240, 248], HER_SKIN = [240, 168, 136], HIS_SHOE = [176, 176, 184]; const green = cfg.KITS.find(k => k.id === 'green'); const out = tintPx(...HIS_SHORTS, green, cfg.SKINS[0], 'dye', cfg.TINT.kit); assert.ok(out && dom(out) === 'g', 'his blue shorts take the pick — the same garment yours moves'); assert.equal(tintPx(...HIS_SHIRT, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null, 'his white shirt stays white on a dye tour'); assert.equal(tintPx(...HIS_SHOE, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null, 'and his shoe grey is under the kit floor, blue-ish hue or not'); assert.ok(dom(tintPx(...HER_KIT, green, cfg.SKINS[0], 'wash')) === 'g', 'her whole white kit takes the wash — top and skirt together, which is the fix'); for (const [nm, c] of [['his', HIS_SKIN], ['hers', HER_SKIN]]) assert.equal(tintPx(...c, green, cfg.SKINS[0], nm === 'his' ? 'dye' : 'wash', cfg.TINT.kit), null, `${nm} skin is left alone — the old red arc is what used to claim it`); }); // The look crosses the wire ONCE, on pairing — neither phone can change its pick mid-match, because // the setup screen is only reachable between runs. test('netplay: your look goes over the wire on pairing, and theirs comes back', () => { state.selectKit('green'); state.selectSkin('deep'); freshNet('A'); const look = netTest.sent.find(m => m.t === 'look'); assert.ok(look, 'pairing announces this phone\'s look'); assert.equal(look.k, 'green'); assert.equal(look.s, 'deep'); assert.equal(look.u, state.tour.id, "...and WHICH BODY it plays in — the tour is part of the look"); netMsg({ t: 'look', k: 'red', s: 'pale', u: 'women' }); assert.equal(G.net.look.kit.id, 'red', 'their kit is what the opponent is drawn in'); assert.equal(G.net.look.skin.id, 'pale'); assert.equal(G.net.look.tour.id, 'women', 'and their tour, whatever THIS phone picked'); // an older build, or a colour this one has never heard of, must not blank the opponent out netMsg({ t: 'look', k: 'chartreuse', s: undefined, u: 'mixed doubles' }); assert.equal(G.net.look.kit.id, cfg.KITS[0].id, 'an unknown colour falls back to as-drawn'); assert.equal(G.net.look.skin.id, cfg.SKINS[0].id); assert.equal(G.net.look.tour.id, state.tour.id, 'and an unknown tour falls back to OUR tour'); state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found }); // THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard. // It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving // end (the same tidyName the world board and server.py's clean() agree on), never as sent. test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => { freshNet('A'); const look = netTest.sent.find(m => m.t === 'look'); assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers'); netMsg({ t: 'look', k: 'red', s: 'pale', n: ' 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.foe.head, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head (the zoned foe layout)'); assert.ok(G.modal.foe.lvl >= 1 && G.modal.foe.mine >= 1, '...and carries the level pair'); const z0 = oppOf().z, intro0 = G.interlude.introT; for (let i = 0; i < 60; i++) update(DT); assert.equal(oppOf().z, z0, 'the frozen court holds while it is read'); assert.equal(G.interlude.introT, intro0, '...and the intro beat holds with it, however long that takes'); G.modal = null; G.modalQ.length = 0; endMatch(); }); test('THE TRAINING COURT: the rung is parked, nothing banks, and bleeding out is a modal', () => { assert.ok(_startMatch(false, null, 5), 'training rides the match machinery'); G.modal = null; G.modalQ.length = 0; const d = G.match; assert.ok(d.train, 'flagged as the training court'); assert.equal(d.opp, 5, 'the picked rung is the one that walks on'); const runGames0 = G.runGames; G.interlude.introT = 0; G.hp = 3; // a scuffed pool, to prove the reset for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; } passMopBeat(); // the kill fall, the mop, the SAME body walking on assert.equal(G.match.opp, 5, 'a felled body comes back as itself — the ladder never advances'); assert.equal(G.runGames, runGames0, 'no ladder metric moves on the training court'); assert.equal(G.hp, G.hpMax, 'every rep starts whole — the reset, without the trainer ceremony'); // ...and running out of blood is a popup and a fresh pool, never the death screen G.interlude.introT = 0; G.hp = 1; resolveInterlude('O'); assert.notEqual(G.mode, 'eaten', 'the training court has no death in it'); assert.ok(G.modal && G.modal.title === 'IT BLED YOU OUT', 'bleeding out announces itself'); assert.equal(G.hp, G.hpMax, '...and hands the pool back'); G.modal = null; G.modalQ.length = 0; endMatch(); }); test('THE READ: a habit is learned by repetition and a break wrong-foots the racket', () => { // The twist for the post-COACH wall ("very fast, barely make mistakes"): repeat a lane and the // opponent anticipates it, break an established habit and it is wrong-footed for that ball. G.seen.match = true; _startMatch(); G.modal = null; G.modalQ.length = 0; const zb = oppOf(), d = G.match; d.opp = cfg.LADDER; zb.foe = cfg.matchFoe(cfg.LADDER); // the champion's dials, live G.interlude.introT = 0; const hit = fx => { // one real player swing, aimed by a committed flick G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1; zb.x = 0; zb.z = 2; zb.swingCool = 999; // it must not return the rig's ball ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; G.swipe = { ax: Math.sign(fx), fx, fy: 0, mag: TUNE.flickFull }; keyPressed('Space'); for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT); G.swipe = null; assert.equal(ball.lastHit, 'P', 'the rig landed a swing'); ball.active = false; // the rally is not the subject }; for (let i = 0; i < TUNE.readHabit; i++) hit(-1); assert.equal(d.laneRun, TUNE.readHabit, 'a run of the same lane goes on the books'); assert.ok(!d.surprised && !d.expected, 'the habit itself surprises nobody'); hit(-1); // one more of the same: the anticipated ball assert.ok(d.expected && !d.surprised, 'the repeated ball is anticipated — spam feeds the machine'); hit(1); // the break assert.ok(d.surprised && !d.expected, 'the break wrong-foots him for this one flight'); assert.equal(d.laneRun, 1, '...and the new lane starts its own book'); // ...and the sprint dies with the chase: the rig just deactivated the ball, so one tick later // the body carries no velocity — a stale vx kept the speed ghosts on a zombie that had already // stopped ("teilweise bleiben die doppelten ghosts länger sichtbar") update(DT); assert.equal(zb.vx || 0, 0, 'no chase, no velocity — the ghost trail cannot outlive the sprint'); G.modal = null; G.modalQ.length = 0; endMatch(); // the dials must MATTER at the top and vanish at the bottom — pinned so they cannot drift: const spd = n => (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (cfg.matchFoe(n).speed ?? 1); assert.ok(spd(cfg.LADDER) * (1 - TUNE.surpSlow) < spd(14), 'a wrong-footed champion runs slower than the head coach at full stride'); const champFlub = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, cfg.LADDER - 1) * cfg.matchFoe(cfg.LADDER).flub; assert.ok(champFlub < 0.02, '(the champion barely misses on his own — the wall being answered)'); // 0.15, not 0.25: the same-day retune ("die Gegner sind jetzt etwas zuu einfach") took a third // off every surprise dial — the window must stay REAL, but it is an opening, not a free point assert.ok(champFlub + TUNE.surpFlub > 0.15, 'the surprise opens a real whiff window in that racket'); assert.ok(TUNE.readAnticipate <= 1, 'anticipation never reads better than perfect'); assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game'); }); // ---- THE LEAGUES, phase 1: the profile clock ------------------------------- // The plan of record is PROJECT.md "THE LEAGUES". These pin the three promises phase 1 makes: // the curve is fast-early/slow-late and capped, the award funnel pays every mode and mutes every // no-stakes mode, and the run delta survives nothing it should not. test('PROFILE: the level curve is monotonic, fast early, capped', () => { let prev = -1; for (let l = 1; l <= prof.LEVEL_CAP; l++) { const x = prof.xpFor(l); assert.ok(x > prev, 'xp thresholds strictly climb'); prev = x; } assert.ok(prof.xpFor(2) <= 120, 'level 2 lands inside the first minutes — the hook'); assert.ok(prof.xpFor(prof.LEVEL_CAP) > 50000, 'the cap is a long way off — levelling is not easy'); assert.equal(prof.levelAt(0), 1, 'everyone starts at level 1'); assert.equal(prof.levelAt(prof.xpFor(7)), 7, 'levelAt inverts xpFor on the threshold'); assert.equal(prof.progress(prof.xpFor(prof.LEVEL_CAP) + 999).frac, 1, 'the cap holds at a full bar'); // steps GROW: each level costs more than the one before (fast early IS slow late) const step = l => prof.xpFor(l + 1) - prof.xpFor(l); assert.ok(step(40) > step(10) && step(10) > step(2), 'the ladder steepens as it climbs'); }); test('PROFILE: award pays through the one point funnel, and mutes every no-stakes mode', () => { const xp0 = prof.prof.xp; advancePoint('P'); assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'a rally point pays through advancePoint'); advancePoint('O'); assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'the opponent\'s point pays you nothing'); // a won game pays the game on top of its final point (3 more points win it from 1-0) score.pts = [3, 0]; advancePoint('P'); assert.equal(prof.prof.xp, xp0 + 2 * prof.XP.point + prof.XP.game, 'the game lands with its last point'); // every no-stakes mode is muted INSIDE award — a call site cannot forget the guard for (const flag of ['drill', 'bench', 'testMode']) { const before = prof.prof.xp; G[flag] = true; assert.equal(prof.award('kill'), 0, flag + ' pays nothing'); assert.equal(prof.prof.xp, before, flag + ' moved no xp'); G[flag] = false; } G.match = { train: true }; assert.equal(prof.award('point'), prof.XP.point / 4, 'the training court pays a QUARTER — reps count'); G.match = null; assert.equal(prof.award('point'), prof.XP.point, '...and the real court pays full'); }); test('PROFILE: the run delta survives the death screen and resets with the next run', () => { prof.beginRunXp(); const from = prof.runDelta().from; prof.award('kill'); prof.award('point'); const d = prof.runDelta(); assert.equal(d.xp, prof.XP.kill + prof.XP.point, 'the delta is the run\'s own earnings'); assert.equal(d.from, from, 'the starting level was recorded at the run start'); restartRun(); // the tap off the death screen assert.equal(prof.runDelta().xp, 0, 'the next run opens with a clean delta'); assert.ok(prof.prof.xp > 0, '...while the banked xp survives, which is the whole point'); }); test('PROFILE: zombie names fit the board and its charset, and the profile round-trips', () => { for (let k = 0; k < 60; k++) { const n = prof.zombieName(); assert.ok(n.length >= 3 && n.length <= 12, 'a name fits the 12-char board limit: ' + n); assert.equal(n, statsMod.tidyName(n), 'a rolled name is already board-legal: ' + n); } prof.prof.lg = 3; prof.save(); assert.ok(prof.prof.xp >= 0 && prof.prof.lg === 3, 'the profile holds what was written'); prof.prof.lg = 1; prof.save(); }); test('THE CLIMB: the arcade runs the whole earned ladder from the bottom and clamps at your top', () => { prof.prof.lg = 2; assert.ok(_startMatch(), 'the arcade starts'); G.modal = null; G.modalQ.length = 0; G.mode = 'splatter'; assert.equal(G.league, 2, 'the run wears the profile league'); const d = G.match; assert.equal(d.opp, 1, 'the climb opens at the very bottom of the ladder'); for (let b = 0; b < 8; b++) { G.interlude.introT = 0; for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; } for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } passMopBeat(); } assert.equal(d.opp, 8, "the ladder clamps at your league's top — the cup is the only way up"); assert.equal(d.felled, 8, 'every felled body counts, clamp or no clamp'); // ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and // read its intro modal instead of wiping it G.interlude.introT = 0; for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; } for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } let saw = null; for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !saw; i++) { update(DT); if (G.modal && G.modal.body) saw = G.modal.body.join(' | '); } assert.ok(saw && saw.includes('THE CUP IS THE WAY UP'), 'the repeat walk-on carries the promotion lesson: ' + saw); G.modal = null; G.modalQ.length = 0; endMatch(); }); test('THE LEAGUES: the top league crowns a champion instead of promoting', () => { prof.prof.lg = 5; _startMatch(true); G.modal = null; G.modalQ.length = 0; G.mode = 'splatter'; const d = G.match; assert.equal(d.cup.rounds.length, 3, 'a three-rung league plays three rounds'); for (let round = 0; round < d.cup.rounds.length; round++) { const R = d.cup.rounds[round]; G.interlude.introT = 0; for (let g = 1; g <= R.need; g++) for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; } for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } if (round < d.cup.rounds.length - 1) passMopBeat(); } assert.ok(G.done && G.done.cup, 'the trophy screen holds'); assert.equal(prof.prof.lg, 5, 'there is no league six'); assert.equal(prof.prof.ch, 1, 'the profile is crowned'); const pr = prof.takePromo(); assert.ok(pr && pr.champion, 'the ceremony parked is the champion one'); }); test("THE LEAGUES: the pace tier follows the OPPONENT's territory, and nothing else", () => { G.match = { opp: 1 }; assert.equal(leaguePace(), 1, 'the bottom of the ladder IS the shipped game'); G.match.opp = 4; assert.equal(leaguePace(), 1, '...flat inside a band: league one all plays league-one tempo'); G.match.opp = 18; assert.ok(leaguePace() > 1.2, "the champion's territory is a visibly faster world"); const climbs = [1, 5, 9, 13, 16].map(o => { G.match.opp = o; return leaguePace(); }); for (let i = 1; i < climbs.length; i++) assert.ok(climbs[i] > climbs[i - 1], 'each band border pulls the tempo up'); G.net = { on: true }; assert.equal(leaguePace(), 1, 'netplay is a human and exempt'); G.net = null; G.match = null; assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director'); }); test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => { G.match = null; G.runGames = 0; assert.equal(duelSkill(), 0, 'a fresh run duels the shipped opponent'); G.runGames = Math.round(TUNE.duelRampGames / 2); const mid = duelSkill(); G.runGames = TUNE.duelRampGames + 10; assert.ok(mid > 0 && mid < 1, 'the ramp is a ramp'); assert.equal(duelSkill(), 1, '...and it caps'); G.match = { opp: 5 }; assert.equal(duelSkill(), 0, 'the match opponent has its own ladder — the ramp is duel-only'); G.match = null; // the dial relationships the ramp promises: a full-skill duel still misses and still misreads assert.ok(TUNE.racketFlub * (1 - TUNE.duelFlubCut) > 0.05, 'a maxed duel still whiffs sometimes'); assert.ok(TUNE.duelReadMax < 1, 'a maxed duel can still be wrong-footed'); assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality'); }); test('THE WEAPON LICENCE: the league licenses, the run paces — tins and barrels agree, league 5 pays double', () => { G.runGames = 0; prof.prof.lg = 5; assert.ok(!tinLadder().includes('laser'), 'even the top league waits for the run to reach lasers'); G.runGames = 12; prof.prof.lg = 1; assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'), 'league one NEVER sees a laser, however deep the run — that was the barrel complaint'); prof.prof.lg = 3; assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'), 'league three licenses the laser, not the rocket'); prof.prof.lg = 4; assert.ok(tinLadder().includes('rocket'), 'league four licenses the rocket'); G.runGames = 0; // the double tin: an empty rack, one delivery, twice the stock at league five const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; }; G.rack.length = 0; G.temp = null; G.deliveries = 0; prof.prof.lg = 5; deliverTin(); const first = G.rack[0]; assert.equal(first.n, cfg.WEAPONS[first.id].tin * 2, 'a league-five duel pays a double tin'); G.rack.length = 0; G.temp = null; G.deliveries = 0; prof.prof.lg = 1; deliverTin(); assert.equal(G.rack[0].n, cfg.WEAPONS[G.rack[0].id].tin, 'league one pays the shipped tin'); }); test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is the shipped game', () => { const xp0 = prof.prof.xp; prof.prof.xp = 0; const b1 = prof.bonus(); assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1, run: 1, window: 1 }, 'level one changes nothing'); prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); const bMax = prof.bonus(); assert.ok(bMax.melee < 1 && bMax.melee >= 0.7, 'the swish floor drops, boundedly'); assert.ok(bMax.parry > 1 && bMax.parry <= 1.3, 'the parry reaches further, boundedly'); assert.ok(bMax.grace > 1 && bMax.grace <= 1.4, 'the grace stretches, boundedly'); assert.ok(bMax.run > 1 && bMax.run <= 1.15, 'the legs quicken, boundedly'); assert.ok(bMax.window > 1 && bMax.window <= 1.2, 'the window widens, boundedly'); // FRONT-LOADED: level 20 must already carry more than half the cap's gift (Oliver plays there) prof.prof.xp = prof.xpFor(20); assert.ok((prof.bonus().run - 1) / (bMax.run - 1) > 0.5, 'the curve pays on the way up'); prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); // back to the cap for the grace-landing check below // ...and the grace actually lands on the player: hurtPlayer writes the scaled window G.mode = 'splatter'; G.hurtT = 0; G.dodgeI = 0; G.hp = 4; hurtPlayer(1); assert.ok(G.hurtT > TUNE.hurtGrace * 1.3, 'a maxed profile\'s grace window is visibly longer'); prof.prof.xp = xp0; }); test('THE LEVEL IS YOU, in the league: legs and window grow with xp — baseline, classic and netplay untouched', () => { const legLevel = _legLevel, winLevel = _winLevel, assistK = _assistK; prof.prof.xp = 0; G.match = { opp: 5 }; const legs0 = assistK(); assert.equal(legLevel(), 1, 'level one IS the shipped game'); assert.equal(winLevel(), 1, '...window too'); prof.prof.xp = prof.xpFor(20); assert.ok(legLevel() > 1.05 && assistK() > legs0, 'level 20 legs are visibly quicker in the league'); assert.ok(winLevel() > 1.05, '...and the contact window visibly wider'); G.net = { on: true }; assert.equal(legLevel(), 1, 'netplay is a fair human match — no level legs'); assert.equal(winLevel(), 1, '...and no level window'); G.net = null; G.match = null; G.classic = true; assert.equal(legLevel(), 1, 'classic keeps its own difficulty picker'); G.classic = false; prof.prof.xp = 0; }); test('THE PEER GIFT: cup and its training rep ease from fifteen levels down to parity', () => { const peerK = _peerK, legLevel = _legLevel, winLevel = _winLevel; G.match = { opp: 15, cup: {} }; // displayed level 41 prof.prof.xp = prof.xpFor(25); assert.equal(peerK(), 0, 'sixteen levels down gets no gift and no penalty'); const legs25 = legLevel(), win25 = winLevel(); prof.prof.xp = prof.xpFor(36); assert.ok(peerK() > 0.6 && peerK() < 1, 'closing most of the gap earns most of the gift'); prof.prof.xp = prof.xpFor(41); assert.equal(peerK(), 1, 'parity earns the full gift'); assert.ok(legLevel() / legs25 > 1.1, 'level 41 has materially more reachability than level 25'); assert.ok(TUNE.swingWindow * (winLevel() - win25) > 0.025, 'the normal contact window gains over 25ms'); G.match = { opp: 15 }; // arcade: fixed comparison assert.equal(peerK(), 0, 'arcade does not soften with opponent parity'); G.match = { opp: 15, train: true }; assert.equal(peerK(), 1, 'the exact training rep feels like the cup match'); G.net = { on: true }; assert.equal(peerK(), 0, 'netplay never reads profile advantage'); G.net = null; G.match = null; prof.prof.xp = 0; }); const recorder = await import('./src/recorder.js'); const { fault: recordFault } = await import('./src/gameplay.js'); test('PLAYTEST: recording the level-26 cup changes neither gameplay nor its random stream', () => { const play = enabled => { recorder.setRecording(false); _seed = 77123; freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(26); P.dustAcc = 0; // cosmetic distance accumulator survives resetState's Object.assign if (enabled) recorder.beginRecording({ build: 'test' }); _startMatch(true); for (let i = 0; i < 180 * 60 && G.mode !== 'eaten' && !G.done; i++) { G.modal = null; G.modalQ.length = 0; if (G.phase === 'pretoss' && !(G.interlude?.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (d < 2.9 && d > 2.2) keyPressed('Space'); } update(DT); } return JSON.stringify({ seed: _seed, mode: G.mode, hp: G.hp, score, player: [P.x, P.z], ball: [ball.x, ball.y, ball.z, ball.lastHit], opponent: G.match.opp }); }; assert.equal(play(true), play(false), 'passive instrumentation leaves the real simulation identical'); const saved = JSON.parse(recorder.recordingJSON()); assert.equal(saved.events[0].level, 26); assert.equal(saved.events[0].mode, 'cup'); assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'P' && e.kind === 'serve')); assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'O'), 'real opponent returns are captured'); const points = saved.events.filter(e => e.type === 'point'); assert.ok(points.length > 0, 'the long baseline rallies reached a scored point'); assert.ok(points.every(e => e.reason !== 'unspecified' && e.foe && e.state.games && e.run)); assert.ok(points.some(e => e.tail.length > 10), 'the point carries live movement frames'); assert.ok(points.every(e => e.tail.length <= 90), 'trajectory storage is bounded'); }); test('PLAYTEST: faults, decided points and fatal losses retain their exact scoring context', () => { recorder.beginRecording({ build: 'test' }); startMatch(); G.interlude.introT = 0; recordFault(); recordFault(); let data = JSON.parse(recorder.recordingJSON()); assert.equal(data.events.filter(e => e.type === 'fault').length, 2); assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]), [['O', 'double_fault']]); G.match.decided = true; G.matchEarly = false; resolveInterlude('P', 'double_bounce'); assert.equal(recorder.recordingInfo().points, 1, 'a spent flight cannot record a second verdict'); G.matchEarly = true; resolveInterlude('P', 'unreachable_in_flight'); G.matchEarly = false; G.match.decided = false; G.hp = 1; resolveInterlude('O', 'passed_player'); update(DT); data = JSON.parse(recorder.recordingJSON()); assert.equal(data.points, 3); assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]), [['O', 'double_fault'], ['P', 'unreachable_in_flight'], ['O', 'passed_player']]); assert.equal(data.events.filter(e => e.type === 'point').at(-1).state.hp, 1, 'fatal point recorded before death mutates state'); assert.equal(data.events.at(-1).outcome, 'lost'); assert.ok(store.get('splatter-playtest-v1').includes('passed_player'), 'complete points persist on the phone'); recorder.setRecording(false); }); test('PLAYTEST: storage failure and the recording limit cannot interfere with scoring', () => { recorder.beginRecording(); startMatch(); const write = localStorage.setItem; try { localStorage.setItem = () => { throw Error('quota'); }; resolveInterlude('O', 'passed_player'); assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'the game still scores when storage is unavailable'); assert.ok(recorder.recordingInfo().warning.includes('Export')); assert.ok(recorder.recordingJSON().includes('passed_player'), 'in-memory export still works'); } finally { localStorage.setItem = write; } recorder.recordEvent('test_limit', { payload: 'x'.repeat(1500001) }); assert.ok(recorder.recordingInfo().full && !recorder.recordingInfo().active); const previous = recorder.recordingInfo().events; resolveInterlude('O', 'passed_player'); assert.equal(recorder.recordingInfo().events, previous, 'a full recording stops without changing the game'); assert.ok(recorder.recordingJSON().length < 1530000, 'oversized data never enters the export'); recorder.beginRecording(); recorder.setRecording(false); }); test('PLAYTEST: Gore and netplay are excluded', () => { recorder.beginRecording(); const before = recorder.recordingInfo().events; recorder.recordEvent('shot'); assert.equal(recorder.recordingInfo().events, before, 'no match, no recorded play'); startMatch(); G.net = { on: true }; const matchEvents = recorder.recordingInfo().events; recorder.recordEvent('shot'); recorder.recordFrame(DT); assert.equal(recorder.recordingInfo().events, matchEvents, 'no network player data'); G.net = null; recorder.setRecording(false); }); const recoveredRecorder = await import('./src/recorder.js?reload-check'); test('PLAYTEST: reloading restores saved events without silently starting recording', () => { assert.ok(recoveredRecorder.recordingInfo().events > 0); assert.equal(recoveredRecorder.recordingInfo().active, false); assert.equal(recoveredRecorder.recordingInfo().events, recorder.recordingInfo().events); }); // File sharing is asynchronous; exercise the actual exporter with browser APIs stubbed, just // like the game's platform above. Cancellation keeps the data; an unsupported share downloads. { const create = document.createElement, share = navigator.share, canShare = navigator.canShare; let file, downloads = 0, anchor; try { navigator.canShare = ({ files }) => { file = files[0]; return true; }; navigator.share = async () => {}; assert.equal(await recorder.exportRecording(), 'shared'); assert.ok(file.name.endsWith('.json')); assert.equal(JSON.parse(await file.text()).schema, 1); navigator.share = async () => { throw Object.assign(Error('cancelled'), { name: 'AbortError' }); }; assert.equal(await recorder.exportRecording(), 'cancelled'); document.createElement = () => (anchor = { click() { downloads++; }, remove() {} }); navigator.share = async () => { throw Error('share unavailable'); }; assert.equal(await recorder.exportRecording(), 'download'); assert.equal(downloads, 1); assert.ok(anchor.download.endsWith('.json') && anchor.href.startsWith('blob:')); navigator.canShare = () => false; assert.equal(await recorder.exportRecording(), 'download'); assert.equal(downloads, 2); assert.ok(recorder.recordingInfo().events > 0, 'export never deletes the saved recording'); } finally { document.createElement = create; navigator.share = share; navigator.canShare = canShare; } passed++; console.log(' ok - PLAYTEST: file sharing, cancellation and download fallback preserve the export'); } // ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") ---------------------------------------------- // One act, played headless through the real modules: the cup (its final is the boss's body), the // chapter (no duel, the act's stock, the mid-boss checkpoint, the act boss), the act card, and a death // that restarts from the checkpoint instead of ending anything. const storyGame = () => { zombies.length = 0; for (let k = 0; k < TUNE.killsPerPoint * 4; k++) splatPoint('P', true); }; function intoStory(act, cp) { prof.prof.story = { act, cp, rack: [], tries: 0 }; G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); assert.ok(startStory(), 'the story takes the court'); G.modal = null; G.modalQ.length = 0; } function winStoryCup() { for (let i = 0; i < 120 * 60 && G.story && G.story.part === 'cup'; i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) resolveInterlude('P', 'test'); update(DT); } G.modal = null; G.modalQ.length = 0; } test('THE STORY: every act\'s cup final is the body that becomes its boss, and the stock grows act by act', () => { const famOf = n => cfg.matchFoe(n).fam; const bossFam = { 0: 'gk', 5: 'ma', 2: 'cu', 3: 'hc', 9: 'ch' }; // design -> the ladder body it grew from for (let a = 1; a <= 5; a++) { const A = cfg.storyAct(a), R = cfg.storyRounds(a); assert.equal(R.length, cfg.cupRoundsFor(A.lg).length, `act ${a}: the league cup's own round count`); assert.equal(R[R.length - 1].name, 'THE FINAL'); assert.equal(famOf(R[R.length - 1].band[0]), bossFam[A.boss], `act ${a}: the finalist is the boss's body`); const band = cfg.leagueOf(A.lg).band, rungs = R.map(r => r.band[0]).sort((x, y) => x - y); assert.deepEqual(rungs, Array.from({ length: band[1] - band[0] + 1 }, (_, i) => band[0] + i), `act ${a}: the whole band, once`); } const all = new Set([0, 1, 2, 3, 4].flatMap(i => [cfg.STORY.acts[i].mid, cfg.STORY.acts[i].boss])); assert.equal(all.size, 10, 'the ten boss designs, each exactly once'); assert.deepEqual(cfg.storyPool(1), ['bomb', 'triple', 'fire']); assert.ok(cfg.storyPool(4).includes('laser') && cfg.storyPool(4).includes('rocket')); assert.equal(cfg.storyStock(5)[0].n, cfg.WEAPONS.bomb.tin * 2, 'act V pays double tins'); assert.deepEqual(cfg.cupRoundsFor(2).map(r => r.band[0]), [5, 6, 7, 8], 'the league cup itself is untouched'); }); test('THE STORY: the act\'s cup is won into its chapter — no promotion, the stock in the rack, full blood', () => { intoStory(1, 'cup'); assert.equal(G.story.part, 'cup'); assert.ok(G.match && G.match.cup, 'the act opens on its cup'); winStoryCup(); assert.ok(G.story && G.story.part === 'chap', 'the won cup hands the court to the chapter'); assert.ok(!G.match && !G.done, 'no trophy screen, no match left behind'); assert.equal(prof.takePromo(), null, 'the story moves the league itself — no promotion ceremony'); assert.equal(prof.prof.story.cp, 'chap', 'the chapter is the checkpoint now'); assert.deepEqual(G.rack.map(e => e.id), ['bomb', 'triple', 'fire'], 'act I\'s stock is in the rack'); assert.equal(G.hp, TUNE.playerHp); assert.equal(G.runGames, cfg.STORY.base(1), 'the chapter starts at its own game 1'); }); test('THE STORY: a chapter has no duel, a mid-boss checkpoint at five and the act boss at ten', () => { prof.prof.lg = 1; intoStory(2, 'chap'); assert.equal(G.runGames, cfg.STORY.base(2)); for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } assert.ok(!G.duelDue && !G.interlude, 'no duel was ever called'); assert.ok(G.bossFight, 'the mid-boss walks on after game five'); const mid = zombies.find(z => z.variant === 'B'); assert.equal(mid.art.name, cfg.storyBossArt(2, 'mid').name, 'act II\'s mid-boss is the stringer'); G.rack = [{ id: 'bomb', n: 1 }]; G.hp = 1; // a hard first half zombies.length = 0; endBossFight(); assert.equal(prof.prof.story.cp, 'mid', 'the mid-boss is the checkpoint'); assert.equal(G.hp, G.hpMax, 'full blood'); for (const e of cfg.storyStock(2)) assert.ok(G.rack.find(r => r.id === e.id).n >= e.n, `${e.id} restocked`); // the lucky-clear duel trigger: one fresh racket zombie alone in pretoss — never a duel here G.bossFight = 0; zombies.length = 0; spawnZombie(0, 2, 'r'); G.phase = 'pretoss'; update(DT); assert.ok(!G.interlude, 'the racket zombie is an ordinary body in the story'); zombies.length = 0; for (let g = 0; g < cfg.STORY.length - cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } const boss = zombies.find(z => z.variant === 'B'); assert.ok(G.bossFight && boss, 'the act boss walks on after game ten'); assert.equal(boss.art.name, cfg.storyBossArt(2, 'end').name, 'act II\'s boss is the sponsor mascot'); zombies.length = 0; endBossFight(); for (let i = 0; i < 20 * 60 && !G.done; i++) { G.modal = null; update(DT); } assert.equal(G.done && G.done.story, 2, 'the act is cleared'); assert.equal(prof.prof.story.act, 3); assert.equal(prof.prof.story.cp, 'cup'); assert.ok(prof.prof.lg >= 3, 'the next act\'s league opens'); }); test('THE STORY: death is not permadeath — it banks nothing and restarts from the checkpoint', () => { intoStory(3, 'chap'); for (let g = 0; g < cfg.STORY.midAt; g++) { storyGame(); G.modal = null; } zombies.length = 0; endBossFight(); // past the mid-boss const saved = prof.prof.story.rack.map(e => e.id + e.n).join(); G.best = null; storyGame(); killPlayer(); assert.equal(G.mode, 'eaten'); assert.equal(prof.prof.story.tries, 1, 'the attempt is counted'); assert.equal(prof.prof.story.cp, 'mid', 'the checkpoint stands'); assert.equal(G.best, null, 'no board, no run record (endRun never ran)'); restartRun(); intoStory(3, 'mid'); prof.prof.story.tries = 1; assert.equal(G.runGames, cfg.STORY.base(3) + cfg.STORY.midAt, 'back at game six'); assert.ok(G.story.midDone, 'the mid-boss stays beaten'); assert.equal(G.hp, G.hpMax); }); test('THE STORY: a barrel holds what the act has earned', () => { intoStory(1, 'chap'); const seen = new Set(); for (let i = 0; i < 60; i++) { zombies.length = 0; G.runGames = cfg.STORY.base(1) + 3; const zb = (() => { const z = spawnZombie(0, 0, 'o'); return zombies.find(q => q.variant === 'o') || z; })(); if (zb && zb.wid) seen.add(zb.wid); } assert.ok(seen.size >= 2, 'barrels were opened: ' + [...seen]); for (const id of seen) assert.ok(cfg.storyPool(1).includes(id), `act I barrel: ${id}`); }); test('THE STORY: a profile from before the story starts it from the top, keeping its level', () => { const s = prof.cleanStory(undefined); assert.deepEqual([s.act, s.cp], [1, 'pro']); const t = prof.cleanStory({ act: 9, cp: 'nonsense', rack: [{ id: 'bomb', n: 500 }, { n: 2 }], tries: -3 }); assert.deepEqual([t.act, t.cp, t.tries], [5, 'cup', 0]); assert.deepEqual(t.rack, [{ id: 'bomb', n: 99 }]); assert.equal(cfg.storyFixture({ act: 1, cp: 'mid' }).line, 'CHAPTER I · FROM GAME 6'); assert.equal(cfg.storyFixture({ act: 5, cp: 'done' }).title, 'THE STORY IS TOLD'); }); console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report