// 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'; // ---- platform stubs, installed before the game modules load ---------------- const absorb = new Proxy(function () {}, { get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb), set: () => true, apply: () => absorb, construct: () => absorb, }); const fakeCtx = () => new Proxy({}, { get: (t, p) => { if (p === 'canvas') return fakeCanvas(); if (p === 'measureText') return () => ({ width: 0 }); if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h }); if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} }); return () => undefined; }, set: () => true, }); function fakeCanvas() { return { width: 270, height: 480, style: {}, getContext: () => fakeCtx(), addEventListener() {}, removeEventListener() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }), toDataURL: () => '', }; } const store = new Map(); globalThis.localStorage = { getItem: k => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), }; globalThis.document = { getElementById: () => fakeCanvas(), createElement: () => fakeCanvas(), addEventListener() {}, removeEventListener() {}, body: { style: {}, appendChild() {} }, }; globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional) globalThis.AudioContext = function () { return absorb; }; globalThis.webkitAudioContext = globalThis.AudioContext; globalThis.matchMedia = () => ({ matches: false, addEventListener() {} }); globalThis.addEventListener = globalThis.addEventListener || (() => {}); globalThis.removeEventListener = globalThis.removeEventListener || (() => {}); globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} }; globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting globalThis.requestAnimationFrame = () => 0; globalThis.devicePixelRatio = 2; globalThis.innerWidth = 390; globalThis.innerHeight = 844; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const { G, P, score, ball, setupPoint } = await import('./src/state.js'); const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js'); const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillTarget } = await import('./src/drill.js'); const { TUNE, bossArt, COURT } = await import('./src/config.js'); const DT = 1 / 60; function freshSplatter() { restartRun(); G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); } let passed = 0; function test(name, fn) { freshSplatter(); fn(); passed++; console.log(' ok - ' + name); } // ---- the contract ----------------------------------------------------------- test('sanity: a horde starts and owns the court in rally phase', () => { forceZombies(); assert.ok(G.horde, 'horde should start on a clean court'); assert.equal(G.phase, 'rally', 'horde steering requires rally phase'); }); test('a duel refuses to start while a horde is live', () => { forceZombies(); startDuel(); assert.equal(G.interlude, false, 'startDuel must refuse during a horde'); }); test('a horde refuses to start while a duel is live', () => { startDuel(); assert.ok(G.interlude, 'duel should start on a clean court'); forceZombies(); assert.equal(G.horde, null, 'startHorde must refuse during a duel'); }); test('field repro: a clear latched before the horde must not advance the beat mid-horde', () => { // A game won during the set card left clearing+afterClear armed while pendingHorde cashed: spawnZombie(0, 5, 'a'); G.clearing = true; G.afterClear = 'game'; G.duelDue = true; forceZombies(); assert.ok(G.horde, 'setup: horde live over a latched clear'); zombies.length = 0; // the gun mows the court down to an empty frame between spawns for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } assert.equal(G.interlude, false, 'the scheduled duel must NOT fire inside the horde'); assert.ok(G.horde, 'the horde must keep running'); assert.ok(G.clearing, 'the clear waits for the horde to end'); // ...and once the horde expires, the parked clear advances and the duel fires — sequentially. for (let i = 0; i < (TUNE.hordeLen + 3) * 10 && G.horde; i++) { update(0.1); zombies.length = 0; } assert.equal(G.horde, null, 'horde over'); for (let i = 0; i < 30 && !G.interlude; i++) { update(DT); if (!G.interlude) zombies.length = 0; } assert.ok(G.interlude, 'the parked duel fires at the first clear AFTER the horde'); }); test('kills mid-duel do not score: resolveInterlude is the only exit', () => { startDuel(); const before = JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]); for (let i = 0; i < 20; i++) splatPoint('P'); splatPoint('O'); assert.equal(JSON.stringify([score.pts, score.games, G.runGames, G.killCount, G.afterClear, G.clearing]), before, 'splatPoint must be a no-op during the duel'); resolveInterlude('P'); assert.equal(G.interlude, false, 'the duel still resolves normally'); }); test('invariant net: an injected duel-inside-horde is repaired within one frame', () => { forceZombies(); G.interlude = { introT: 0 }; // simulate an unknown fourth path corrupting the state G.phase = 'pretoss'; // the welded-player symptom: no steering, no serving update(DT); assert.equal(G.interlude, false, 'watchdog must drop the impossible duel'); assert.equal(G.duelDue, true, 'and re-schedule it instead of eating it'); assert.equal(G.phase, 'rally', 'and hand the finger back to the player'); assert.ok(G.missLog.some(e => e.result === 'INVARIANT'), 'the repair leaves evidence in the miss log'); }); test('a pending horde defers while a duel runs, then fires after it', () => { startDuel(); G.interlude.introT = 0; G.pendingHorde = true; G.seen.horde = true; // not the first horde: no modal in the way G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the latch must hold while the duel owns the court'); assert.ok(G.pendingHorde, 'and stay armed'); resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; // dismiss the win popups; the over beat then expires for (let i = 0; i < 300 && !G.horde; i++) update(DT); // the post-duel over beat runs 2.2 s assert.ok(G.horde, 'the set still gets its horde, after the duel'); }); // ---- regressions mined from the project log (each of these shipped broken once) ---------------- test('a pending horde never pours onto a boss stage', () => { // commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the // boss fight. The latch must hold while the boss owns the court, and fire after. startBossFight(3); assert.ok(G.bossFight, 'setup: boss on court'); G.pendingHorde = true; G.seen.horde = true; G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the latch must hold during the boss fight'); assert.ok(G.pendingHorde, 'and stay armed'); }); test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => { // endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the // EPIC card before it drew a frame. The handback runs first now and the boss card wins. startBossFight(3); G.afterDuel = 'set'; G.seen.horde = true; zombies.length = 0; endBossFight(); assert.equal(G.bossFight, 0, 'fight over'); assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card'); assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde'); for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; } assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it'); }); test('mgT counts down in every phase and releases the serve lock', () => { // commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase // mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock. G.mgT = 0.5; G.gunId = 'gun'; assert.equal(G.phase, 'pretoss', 'setup: not in rally'); for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; } assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally'); assert.equal(G.phase, 'pretoss', 'and hand back the serve loop'); }); test('a won duel pays out: the tin and the trainer', () => { // commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel. G.hp = G.hpMax - 3; startDuel(); G.interlude.introT = 0; const rackBefore = G.rack.length + (G.temp ? 1 : 0); resolveInterlude('P'); const rackAfter = G.rack.length + (G.temp ? 1 : 0); assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon'); assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel'); }); test('a lost duel banks their game — and their set — without a horde', () => { score.games[1] = 5; startDuel(); G.interlude.introT = 0; resolveInterlude('O'); assert.equal(G.sets[1], 1, 'their 6th game banks their set'); assert.deepEqual(score.games, [0, 0], 'games reset'); assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge'); assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde'); }); // ---- the announcements: nothing that changes the control mode may arrive unannounced ----------- test('a set warns THEY KEEP COMING before the wave lands', () => { G.pendingHorde = true; G.seen.horde = true; // not the first horde: no teaching modal in the way G.phase = 'over'; G.overT = 0.01; update(DT); assert.equal(G.horde, null, 'the wave must not land on the same frame as its own announcement'); assert.ok(G.hordeWarn > 0, 'the breather is running'); assert.equal(G.flash && G.flash.text, 'THEY KEEP COMING', 'and it says so'); for (let i = 0; i < 10 && !G.horde; i++) update(DT); // ...but not for a hundredth of a second assert.equal(G.horde, null, 'the breather actually holds'); for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); assert.ok(G.horde, 'and the wave lands when it runs out'); }); test('a mode that steals the court during the warning re-arms the horde', () => { G.pendingHorde = true; G.seen.horde = true; G.phase = 'over'; G.overT = 0.01; update(DT); assert.ok(G.hordeWarn > 0, 'setup: warning running'); startDuel(); // the court is calm during a breather, so a scheduled beat CAN grab it here G.modal = null; G.modalQ.length = 0; // (its first-sight popup would freeze the world we're stepping) for (let i = 0; i < (TUNE.hordeWarnT + 1) * 60 && !G.horde; i++) update(DT); assert.equal(G.horde, null, 'the wave must not land inside the duel'); assert.ok(G.pendingHorde, 'the set is still owed its horde'); }); test('a chapter boss announces itself and hands the court back on its own', () => { startBossFight(2); assert.ok(G.modal, 'the walk-on is an overlay, not a band card'); assert.equal(G.modal.title, bossArt(2).name, 'it names the boss'); assert.deepEqual(G.modal.body, ['ENTERED THE COURT']); for (let i = 0; i < (TUNE.modalIn + TUNE.bossIntroT) * 60 - 6; i++) update(DT); assert.ok(G.modal, 'and holds the frozen court while the player reads it'); for (let i = 0; i < 20 && G.modal; i++) update(DT); assert.equal(G.modal, null, 'then lets go without waiting for a tap'); assert.ok(G.bossFight, 'the fight is on'); }); test('a burst fires through the beat that follows the kill that emptied the court', () => { // The gun/flamer/beacon used to require phase 'rally', and every beat raised by clearing the // court (a point, a won game, the mop-up) parks the phase in 'over' — so the weapon fell silent // exactly when it had killed everything. Its OWN clock is the only thing that ends it. G.mgT = 2; G.gunId = 'flamer'; G.mgCool = 0; G.phase = 'over'; G.overT = 1.5; // the won-game beat, running behind the burst pellets.length = 0; for (let i = 0; i < 30; i++) { update(DT); zombies.length = 0; } // an empty court, as after the last kill assert.equal(G.phase, 'over', 'setup: the beat is still running'); assert.ok(pellets.length > 0, 'the burst keeps firing through it'); assert.ok(G.mgT > 0, 'and its window is still open'); }); // ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------ // Run the stream for a while. There is no announcement beat and no between-rounds pause any more — // a round is always live once two bodies are on court. function drillPlaying(seconds = 1) { startDrill(); for (let i = 0; i < seconds * 60; i++) update(DT); } // kill a body the way every weapon does — through the shared funnel, via the ball's contact test. // Returns false when it is already down: a ball carries through the lane, so a shot at one body can // take the one behind it (see PROJECT.md's open question). const drillKillBib = bib => { const zb = zombies.find(z => z.bib === bib); if (!zb) return false; for (let i = 0; i < 40 && 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'); return true; }; 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 round behind'); }); test('the puzzle is WINNABLE: every called colour is on the far side, and nothing else wears one', () => { startDrill(); const called = G.drill.seq; assert.ok(called.length >= 2, 'a combination is called at once'); const bibbed = zombies.filter(z => z.bib !== undefined); assert.equal(bibbed.length, called.length, 'exactly as many bibs on court as colours called'); assert.deepEqual(bibbed.map(z => z.bib).sort(), called.slice().sort(), 'the bibs on court ARE the combination — no coloured decoys'); for (const z of bibbed) assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1)); assert.equal(new Set(bibbed.map(z => z.bib)).size, bibbed.length, 'a colour names exactly one body'); // ...and it stays true as the batch walks in: nothing new ever puts an uncalled colour on court for (let i = 0; i < 25 * 60; i++) { update(DT); const live = zombies.filter(z => z.bib !== undefined).map(z => z.bib); for (const b of live) assert.ok(G.drill.seq.includes(b), 'an uncalled colour appeared on court'); } }); test('the eaters are the danger: unbibbed, homing, and killing one is never a fault', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement try { startDrill(); for (let i = 0; i < 40 * 60; i++) update(DT); const eaters = zombies.filter(z => z.bib === undefined); assert.ok(eaters.length > 0, 'plain shamblers arrive on their own clock'); assert.ok(eaters.length <= TUNE.drillEaterMax, 'and are capped at ' + TUNE.drillEaterMax); assert.ok(eaters.every(z => !z.drill), 'they are NOT lane-holders — they converge on the player'); // killing one must not touch the combination const seq = G.drill.seq.slice(), faults = G.drill.faults; const e = eaters[0]; for (let i = 0; i < 40 && zombies.includes(e); i++) { ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; ball.x = e.x; ball.z = e.z; ball.vx = ball.vy = ball.vz = 0; update(DT); } assert.ok(!zombies.includes(e), 'setup: the eater is down'); assert.deepEqual(G.drill.seq, seq, 'the combination is untouched'); assert.equal(G.drill.faults, faults, 'and it costs no fault — it never wore a colour'); } finally { TUNE.eatenR = eaten; } }); test('the ramp raises the batch, the walk and the eater rate', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; try { startDrill(); const earlyBatch = G.drill.seq.length, earlyV = zombies.find(z => z.bib !== undefined).v; let earlyEaters = 0; for (let i = 0; i < 40 * 60; i++) { update(DT); } earlyEaters = zombies.filter(z => z.bib === undefined).length; G.drill.t = TUNE.drillRampT; // jump to the top of the ramp zombies.length = 0; update(DT); // force a fresh batch at full difficulty const lateBatch = G.drill.seq.length, lateV = zombies.find(z => z.bib !== undefined).v; assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch); assert.ok(lateV > earlyV * 1.5, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner'); } finally { TUNE.eatenR = eaten; } }); test('finishing a batch rolls straight into the next one — no pause, no deal', () => { drillPlaying(); const seq = G.drill.seq.slice(); for (const bib of seq) drillKillBib(bib); assert.ok(G.drill.rounds >= 1, 'the round is banked'); update(DT); const bibbed = zombies.filter(z => z.bib !== undefined); assert.ok(bibbed.length >= 2, 'a fresh batch is already walking in'); assert.ok(G.drill.seq.length >= 2, 'with its combination already called'); assert.equal(G.drill.step, 0, 'from the top'); for (const z of bibbed) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised'); }); test('the drill keeps its own count; the tennis ladder stays out of it', () => { drillPlaying(); const games = score.games.slice(), pts = score.pts.slice(); for (const bib of G.drill.seq.slice()) drillKillBib(bib); assert.deepEqual(score.games, games, 'the drill does not bank tennis games'); assert.deepEqual(score.pts, pts, '...nor tennis points'); }); test('two out of turn is a double fault, and the survivors are re-called', () => { startDrill(); if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first drillKillBib(wrong1); assert.equal(G.drill.faults, 1, 'out of turn is a fault'); assert.equal(G.drill.lost, 0, 'but not the round'); const wrong2 = G.drill.seq[G.drill.seq.length - 1]; if (wrong2 !== undefined && G.drill.seq.length > 1) { drillKillBib(wrong2); assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate'); assert.deepEqual(G.drill.seq.slice().sort(), zombies.filter(z => z.bib !== undefined).map(z => z.bib).sort(), 'the new combination is exactly what is still standing'); } }); test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => { // It used to lock onto the called body wherever the pull pointed, which made the shot unmissable // and the fault unreachable — Oliver: "no skills involved at all... there is no penalty". drillPlaying(); const called = G.drill.seq[G.drill.step]; const wrong = zombies.find(z => z.bib !== called); assert.ok(wrong, 'setup: something else is on court'); const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 }); assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it'); }); test('a wrong kill never leaves an un-winnable round', () => { drillPlaying(); const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first if (G.drill.seq.length > 1 && drillKillBib(later)) { assert.ok(!G.drill.seq.includes(later), 'the dead colour leaves the list rather than blocking it'); assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)), 'every colour still on the list is still on the court'); } }); test('being caught costs the point and the court, not the run', () => { drillPlaying(); const t0 = G.drill.t; killPlayer(); assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); assert.equal(G.drill.lost, 1, 'they take the point instead'); assert.equal(zombies.length, 0, 'the court is swept — the one thing that interrupts the stream'); assert.ok(G.drill.t < t0, 'and the ramp backs off'); for (let i = 0; i < 10 * 60; i++) update(DT); assert.ok(zombies.length > 0, 'then the stream simply resumes'); assert.ok(G.drill.seq.length >= 2, 'and a round is live again'); }); 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'); }); console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report