diff --git a/src/games/party/handler.test.ts b/src/games/party/handler.test.ts new file mode 100644 index 0000000..f2f3970 --- /dev/null +++ b/src/games/party/handler.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import handler from './handler'; +import type { State } from './logic'; + +// Minimal ctx stub — the handler only touches ctx.state (+ ctx.leaderboard on +// scoring, which these tests don't reach). +function ctx(state: State) { + return { state, leaderboard: () => ({ submit: () => {} }) } as never; +} + +function lobby(players: Record): State { + return { phase: 'lobby', tick: 0, players } as unknown as State; +} + +const player = (id: string) => ({ id, joinedAt: 0 }); + +describe('Caption Clash — host can start', () => { + it('the first present player (VIP) can start a match', () => { + const s = lobby({ + p2: { name: 'B', score: 0, present: true }, + p1: { name: 'A', score: 0, present: true }, + }); + // p1 sorts first among present players → p1 is the VIP host. + handler.onMessage!(player('p1'), { kind: 'start', mode: 'party' }, ctx(s)); + expect(s.phase).toBe('play'); + }); + + it('a non-VIP player cannot start', () => { + const s = lobby({ + p1: { name: 'A', score: 0, present: true }, + p2: { name: 'B', score: 0, present: true }, + }); + handler.onMessage!(player('p2'), { kind: 'start', mode: 'party' }, ctx(s)); + expect(s.phase).toBe('lobby'); + }); + + it('a solo player is the VIP and can start (regression: firstPlayer was undefined)', () => { + const s = lobby({ solo: { name: 'S', score: 0, present: true } }); + handler.onMessage!(player('solo'), { kind: 'start', mode: 'turnbased' }, ctx(s)); + expect(s.phase).toBe('play'); + }); +}); + +describe('Caption Clash — lobby ghost cleanup', () => { + it('onTick prunes absent players while in the lobby', () => { + const s = lobby({ + here: { name: 'H', score: 0, present: true }, + ghost1: { name: 'G1', score: 3, present: false }, + ghost2: { name: 'G2', score: 5, present: false }, + }); + handler.onTick!(ctx(s)); + expect(Object.keys(s.players)).toEqual(['here']); + }); + + it('does NOT prune absent players mid-match (score preserved for rejoin)', () => { + const s = { phase: 'play', mode: 'party', tick: 5, players: { + here: { name: 'H', score: 0, present: true }, + dropped: { name: 'D', score: 7, present: false }, + } } as unknown as State; + handler.onTick!(ctx(s)); + expect(s.players['dropped']).toBeDefined(); + expect(s.players['dropped']!.score).toBe(7); + }); +}); diff --git a/src/games/party/handler.ts b/src/games/party/handler.ts index fa254ca..aec525e 100644 --- a/src/games/party/handler.ts +++ b/src/games/party/handler.ts @@ -39,9 +39,17 @@ const initialState: State = { players: {}, }; -/** Whether a player id is the room VIP (first player still present). */ +/** Whether a player id is the room VIP (the host who can start a match): the + * first still-present player by stable id order. Derived from `state.players` + * so it matches the client's own VIP rule exactly (the client gates the + * "Start match" button the same way) — relying on `ctx.firstPlayer` instead + * broke this, since the platform passes an empty player list to the handler, + * leaving `firstPlayer` undefined and nobody ever the VIP. */ function isVip(id: string, ctx: HandlerContext): boolean { - return ctx.firstPlayer?.id === id; + const present = Object.keys(ctx.state.players) + .filter((pid) => ctx.state.players[pid]?.present) + .sort(); + return present[0] === id; } /** Submit every player's final score to the shared 'party' leaderboard. */ @@ -131,6 +139,16 @@ export default defineRoom({ const s = ctx.state; s.tick += 1; + // In the lobby, drop players who have left so the roster stays clean — a + // persistent room would otherwise accumulate absent "ghost" records across + // sessions. During a match, absent players are kept (present:false) so a + // reconnect restores their score. + if (s.phase === 'lobby') { + for (const id of Object.keys(s.players)) { + if (!s.players[id]?.present) delete s.players[id]; + } + } + if (s.phase === 'lobby' || s.phase === 'over') return; if (deadlineReached(s)) { diff --git a/src/games/siege/handler.test.ts b/src/games/siege/handler.test.ts new file mode 100644 index 0000000..d7ac504 --- /dev/null +++ b/src/games/siege/handler.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import handler, { type State } from './handler'; + +function ctx(state: State) { + return { + state, + leaderboard: () => ({ submit: () => {} }), + broadcast: () => {}, + } as never; +} + +function baseState(over: Partial): State { + return { + players: {}, + enemies: {}, + baseHp: 100, + wave: 1, + maxWaves: 8, + tick: 0, + phase: 'playing', + score: 0, + ...over, + } as State; +} + +describe('Siege — self-healing persistent room', () => { + it('the first player into a stale DEFEATed room gets a fresh siege', () => { + // A persistent room left destroyed by a previous session. + const s = baseState({ phase: 'lost', baseHp: 0, wave: 5, score: 300, enemies: { e1: { pos: { x: 0, y: 0 }, hp: 10 } } as never }); + handler.onJoin!({ id: 'p1', joinedAt: 0 }, ctx(s)); + expect(s.phase).toBe('playing'); + expect(s.baseHp).toBe(100); + expect(s.wave).toBe(1); + expect(s.score).toBe(0); + expect(Object.keys(s.enemies)).toHaveLength(0); + expect(s.players['p1']).toBeDefined(); + expect(s.players['p1']!.hp).toBe(100); + }); + + it('auto-restarts a finished game after the hold delay', () => { + const s = baseState({ phase: 'lost', baseHp: 0, wave: 6, score: 500, players: { p1: { pos: { x: 10, y: 10 }, hp: 0 } } as never }); + // First terminal tick arms the restart timer; state stays 'lost'. + handler.onTick!(ctx(s)); + expect(s.phase).toBe('lost'); + // Advance past the hold window (4s @ 20Hz = 80 ticks) and tick again. + s.tick += 90; + handler.onTick!(ctx(s)); + expect(s.phase).toBe('playing'); + expect(s.baseHp).toBe(100); + expect(s.wave).toBe(1); + expect(s.players['p1']!.hp).toBe(100); // kept + respawned at full health + }); + + it('does not restart before the hold delay elapses', () => { + const s = baseState({ phase: 'won', wave: 8, score: 900 }); + handler.onTick!(ctx(s)); // arms timer at tick+80 + s.tick += 10; + handler.onTick!(ctx(s)); + expect(s.phase).toBe('won'); + }); +}); diff --git a/src/games/siege/handler.ts b/src/games/siege/handler.ts index 1a67d61..f1e44fa 100644 --- a/src/games/siege/handler.ts +++ b/src/games/siege/handler.ts @@ -41,6 +41,25 @@ function makeRand(seed: number): () => number { }; } +/** Ticks (at 20 Hz) to hold the WIN/DEFEAT screen before auto-restarting. */ +const RESTART_DELAY_TICKS = 20 * 4; + +/** Reset the world to a fresh siege (base full, wave 1, no enemies), keeping + * the connected players — respawned at full health. Used when the first player + * enters a stale room and to auto-restart after a finished game, so a + * persistent room is never stuck on an old result. */ +function resetWorld(s: State): void { + s.enemies = {}; + s.baseHp = 100; + s.wave = 1; + s.score = 0; + s.phase = 'playing'; + (s as State & { _spawned?: number; _restartAt?: number })._spawned = 0; + (s as State & { _restartAt?: number })._restartAt = undefined; + let i = 0; + for (const id in s.players) s.players[id] = { pos: spawnPlayerPos(i++), hp: 100 }; +} + /** Place a new player near the base but offset so they don't all stack. */ function spawnPlayerPos(index: number): { x: number; y: number } { const angle = (index * Math.PI) / 4; @@ -67,6 +86,9 @@ export default defineRoom({ onJoin(player, ctx: HandlerContext) { const index = Object.keys(ctx.state.players).length; + // First player into a room starts a fresh siege — otherwise they'd inherit + // a persistent room's stale/finished state (e.g. a base left destroyed). + if (index === 0) resetWorld(ctx.state); ctx.state.players[player.id] = { pos: spawnPlayerPos(index), hp: 100 }; }, @@ -81,6 +103,21 @@ export default defineRoom({ onTick(ctx: HandlerContext) { const s = ctx.state; s.tick += 1; + + // Auto-restart a finished game so this (persistent) room stays playable for + // the next visitor instead of being stuck forever on WIN / DEFEAT. Hold the + // result on screen for RESTART_DELAY_TICKS, then reset to a fresh siege — + // keeping whoever is connected, respawned at full health. + if (s.phase === 'won' || s.phase === 'lost') { + const w = s as State & { _restartAt?: number }; + if (w._restartAt === undefined) { + w._restartAt = s.tick + RESTART_DELAY_TICKS; + } else if (s.tick >= w._restartAt) { + resetWorld(s); + } + return; + } + if (s.phase !== 'playing') return; stepWorld(s, DT, STEP_OPTS, makeRand(s.tick));