From 96e18bb88e825f2c370b3e40b4d5c44782867f80 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:47:46 +0200 Subject: [PATCH] fix(games): host can start Caption Clash; Siege self-heals instead of stuck DEFEAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two showcase-breaking gameplay bugs found by driving the games in a browser (WS-level checks had missed them): Caption Clash — host could never start a match. The server handler's isVip() used ctx.firstPlayer, but the platform passes an empty player list to handlers, so firstPlayer was always undefined and nobody was the VIP. Derive the VIP from state.players (first present by sorted id) to match the client's own gating. Also prune absent players while in the lobby so a persistent room stops accumulating ghost roster entries. Siege — a persistent room stayed stuck on DEFEAT forever (the base takes damage even with no defenders, and there was no reset), so every new visitor saw an instant 'base destroyed'. Reset to a fresh siege when the first player enters a room and auto-restart a few seconds after any win/loss, keeping connected players respawned at full health. Tests: 8 new handler tests (host/non-VIP/solo start, lobby ghost prune, first-join reset, auto-restart timing). Full showcase suite 42 pass. Verified in prod: 8/8 playability checks — both games start/play, and the polluted PARTY1/SIEGE1 default rooms self-healed. --- src/games/party/handler.test.ts | 64 +++++++++++++++++++++++++++++++++ src/games/party/handler.ts | 22 ++++++++++-- src/games/siege/handler.test.ts | 61 +++++++++++++++++++++++++++++++ src/games/siege/handler.ts | 37 +++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/games/party/handler.test.ts create mode 100644 src/games/siege/handler.test.ts 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));