diff --git a/src/games/blobs/handler.test.ts b/src/games/blobs/handler.test.ts new file mode 100644 index 0000000..1c8c808 --- /dev/null +++ b/src/games/blobs/handler.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import handler from './handler'; +import type { BlobWorld } from './logic'; + +function ctx(state: BlobWorld, submits: Array<[string, number]> = []) { + return { state, leaderboard: () => ({ submit: (id: string, s: number) => submits.push([id, s]) }) } as never; +} +const player = (id: string) => ({ id, joinedAt: 0 }); + +describe('Blobs — input validation', () => { + it('rejects non-finite move input (no NaN corruption)', () => { + const s: BlobWorld = { positions: { p1: { x: 5, y: 5 } }, mass: { p1: 12 }, pellets: [] }; + handler.onMessage!(player('p1'), { kind: 'move', dx: NaN, dy: Infinity, dt: -1 }, ctx(s)); + expect(Number.isFinite(s.positions['p1']!.x)).toBe(true); + expect(s.positions['p1']).toEqual({ x: 5, y: 5 }); // unchanged + }); + + it('applies a valid move', () => { + const s: BlobWorld = { positions: { p1: { x: 0, y: 0 } }, mass: { p1: 12 }, pellets: [] }; + handler.onMessage!(player('p1'), { kind: 'move', dx: 1, dy: 0, dt: 0.05 }, ctx(s)); + expect(s.positions['p1']!.x).toBeGreaterThan(0); + }); +}); + +describe('Blobs — leaderboard throttle', () => { + it('does NOT submit every tick — only once per 100 ticks', () => { + const submits: Array<[string, number]> = []; + const s: BlobWorld = { positions: { p1: { x: 0, y: 0 } }, mass: { p1: 12 }, pellets: [] }; + const c = ctx(s, submits); + for (let i = 0; i < 99; i++) handler.onTick!(c); + expect(submits).toHaveLength(0); // nothing in the first 99 ticks + handler.onTick!(c); // tick 100 + expect(submits.length).toBe(1); + expect(submits[0]![0]).toBe('p1'); + }); +}); diff --git a/src/games/blobs/handler.ts b/src/games/blobs/handler.ts index b4cc388..1f89de4 100644 --- a/src/games/blobs/handler.ts +++ b/src/games/blobs/handler.ts @@ -20,6 +20,12 @@ type BlobMsg = { kind: 'move'; dx: number; dy: number; dt: number }; /** Starting mass for a freshly-spawned blob. */ const START_MASS = 12; +/** How often (in ticks, at 20 Hz) to report scores to the leaderboard. Reporting + * every tick would be 20 × player-count submits/sec — enough to hammer the + * backend. Every 100 ticks = one submit per player per 5s, which is plenty for + * a live standings board. */ +const LEADERBOARD_EVERY_TICKS = 100; + export const handler = defineRoom({ initialState: { positions: {}, mass: {}, pellets: [] }, channels: { @@ -33,6 +39,10 @@ export const handler = defineRoom({ }, onMessage(player, msg, ctx) { if (msg.kind !== 'move') return; + // Reject non-finite input (NaN/Infinity from a buggy or hostile client) — + // a single bad move would otherwise turn the position into NaN and corrupt + // collision, rendering, and leaderboard scores irrecoverably. + if (!Number.isFinite(msg.dx) || !Number.isFinite(msg.dy) || !Number.isFinite(msg.dt)) return; const pos = ctx.state.positions[player.id]; if (pos === undefined) return; const mass = ctx.state.mass[player.id] ?? START_MASS; @@ -45,9 +55,17 @@ export const handler = defineRoom({ ); }, onTick(ctx) { - resolveEat(ctx.state, (id, score) => { - void ctx.leaderboard('blobs').submit(id, score); - }); + const w = ctx.state as BlobWorld & { _tick?: number }; + w._tick = (w._tick ?? 0) + 1; + const report = w._tick % LEADERBOARD_EVERY_TICKS === 0; + resolveEat( + ctx.state, + report + ? (id, score) => { + void ctx.leaderboard('blobs').submit(id, score); + } + : undefined, + ); }, onLeave(player, ctx) { delete ctx.state.positions[player.id]; diff --git a/src/games/party/handler.test.ts b/src/games/party/handler.test.ts index f2f3970..64261c9 100644 --- a/src/games/party/handler.test.ts +++ b/src/games/party/handler.test.ts @@ -62,3 +62,34 @@ describe('Caption Clash — lobby ghost cleanup', () => { expect(s.players['dropped']!.score).toBe(7); }); }); + +describe('Caption Clash — rematch after results', () => { + it("returns 'over' to a fresh lobby once the results deadline passes", () => { + const s = { phase: 'over', tick: 100, deadlineTick: 99, mode: 'party', round: 3, players: { + a: { name: 'A', score: 300, present: true }, + b: { name: 'B', score: 100, present: false }, + } } as unknown as State; + handler.onTick!(ctx(s)); + expect(s.phase).toBe('lobby'); + expect(s.players['a']!.score).toBe(0); // scores reset for the rematch + expect(s.players['b']).toBeUndefined(); // absent player dropped + expect(s.deadlineTick).toBeUndefined(); + }); + + it("stays on 'over' until the deadline", () => { + const s = { phase: 'over', tick: 50, deadlineTick: 90, players: { a: { name: 'A', score: 1, present: true } } } as unknown as State; + handler.onTick!(ctx(s)); + expect(s.phase).toBe('over'); + }); +}); + +describe('Caption Clash — leave cleans up submissions', () => { + it("removes a departed player's caption so it is not left voteable", () => { + const s = { phase: 'play', mode: 'party', tick: 3, players: { + a: { name: 'A', score: 0, present: true }, + }, submissions: { a: 'my caption' } } as unknown as State; + handler.onLeave!(player('a'), ctx(s)); + expect(s.players['a']!.present).toBe(false); + expect(s.submissions!['a']).toBeUndefined(); + }); +}); diff --git a/src/games/party/handler.ts b/src/games/party/handler.ts index aec525e..e68ef6b 100644 --- a/src/games/party/handler.ts +++ b/src/games/party/handler.ts @@ -13,6 +13,7 @@ import { defineRoom } from '@plot/handler'; import type { HandlerContext } from '@plot/handler'; import { MAX_ROUNDS, + TICKS_PER_SECOND, type Mode, type State, advance, @@ -22,6 +23,28 @@ import { startMatch, } from './logic'; +/** Seconds the final scoreboard stays up before an automatic rematch. */ +const OVER_SECONDS = 8; + +/** Return a finished match to a clean lobby: clear the round, reset scores, and + * drop players who left — keeping the still-present players for a rematch. */ +function returnToLobby(s: State): void { + s.phase = 'lobby'; + s.round = 0; + s.prompt = undefined; + s.submissions = undefined; + s.votes = undefined; + s.turnOrder = undefined; + s.activeIdx = undefined; + s.word = undefined; + s.deadlineTick = undefined; + for (const id of Object.keys(s.players)) { + const rec = s.players[id]; + if (!rec || !rec.present) delete s.players[id]; + else rec.score = 0; + } +} + /** Client → server messages on the `event` channel. */ export type Msg = | { kind: 'setName'; name: string } @@ -80,8 +103,11 @@ export default defineRoom({ }, onLeave(player, ctx) { - const rec = ctx.state.players[player.id]; + const s = ctx.state; + const rec = s.players[player.id]; if (rec) rec.present = false; + // Don't leave a departed player's caption voteable with no author around. + if (s.submissions) delete s.submissions[player.id]; }, onMessage(player, msg, ctx) { @@ -149,13 +175,23 @@ export default defineRoom({ } } - if (s.phase === 'lobby' || s.phase === 'over') return; + // After the results screen has been up long enough, return to a fresh + // lobby for a rematch (scores cleared, present players kept) instead of + // stranding everyone on the final scoreboard forever. + if (s.phase === 'over') { + if (deadlineReached(s)) returnToLobby(s); + return; + } + + if (s.phase === 'lobby') return; if (deadlineReached(s)) { const next = advance(s); Object.assign(s, next); if (next.phase === 'over') { publishScores(ctx); + // Hold the results for OVER_SECONDS, then onTick returns to the lobby. + s.deadlineTick = s.tick + OVER_SECONDS * TICKS_PER_SECOND; } } }, diff --git a/src/games/party/index.ts b/src/games/party/index.ts index c815e94..0af1703 100644 --- a/src/games/party/index.ts +++ b/src/games/party/index.ts @@ -420,6 +420,14 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const poll = window.setInterval(doRender, 200); // ---- Connect -------------------------------------------------------- + // + // Caption Clash uses the low-level `plot.join()` API on purpose (Blobs and + // Siege use the managed `plot.play()` instead). This is a turn-based, lobby + // game: there is no local player to *predict* and no continuous remote motion + // to *interpolate* — it just reads the authoritative snapshot from + // `room.currentState` and re-renders on change. `plot.play()`'s prediction + + // interpolation + frame loop would be dead weight here, so the raw event- + // driven Room API is the right fit and the clearer teaching example. void (async () => { const plot = new Plot({ appKey: config.appKey, appId: config.appId, playerId: config.playerId, apiUrl: config.apiUrl }); diff --git a/src/games/siege/handler.test.ts b/src/games/siege/handler.test.ts index d7ac504..55ef674 100644 --- a/src/games/siege/handler.test.ts +++ b/src/games/siege/handler.test.ts @@ -58,4 +58,10 @@ describe('Siege — self-healing persistent room', () => { handler.onTick!(ctx(s)); expect(s.phase).toBe('won'); }); + + it('rejects non-finite move input (no NaN position corruption)', () => { + const s = baseState({ players: { p1: { pos: { x: 5, y: 5 }, hp: 100 } } as never }); + handler.onMessage!({ id: 'p1', joinedAt: 0 }, { kind: 'move', dx: NaN, dy: Infinity, dt: 0.05 }, ctx(s)); + expect(s.players['p1']!.pos).toEqual({ x: 5, y: 5 }); // unchanged + }); }); diff --git a/src/games/siege/handler.ts b/src/games/siege/handler.ts index f1e44fa..672e4c2 100644 --- a/src/games/siege/handler.ts +++ b/src/games/siege/handler.ts @@ -14,6 +14,12 @@ import { stepPlayer, stepWorld, type SiegeWorld } from './logic'; /** The full authoritative state: the simulated world plus a presentation phase. */ export type State = SiegeWorld & { phase: 'playing' | 'won' | 'lost'; + /** Director bookkeeping (tick numbers), carried in the snapshot: which wave + * has spawned, when the current wave cleared, and when to auto-restart after + * a finished game. Optional — absent until first set. */ + _spawned?: number; + _clearedAt?: number; + _restartAt?: number; }; /** Messages clients may send. Only movement intents are accepted. */ @@ -94,6 +100,9 @@ export default defineRoom({ onMessage(player, msg, ctx: HandlerContext) { if (msg.kind !== 'move') return; + // Reject non-finite direction — NaN/Infinity would freeze the player at a + // corrupt position (stepPlayer normalizes by a NaN magnitude). + if (!Number.isFinite(msg.dx) || !Number.isFinite(msg.dy)) return; const p = ctx.state.players[player.id]; if (p === undefined) return; const dt = Number.isFinite(msg.dt) ? Math.max(0, Math.min(msg.dt, 0.1)) : 0;