Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/games/blobs/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 21 additions & 3 deletions src/games/blobs/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlobWorld, BlobMsg>({
initialState: { positions: {}, mass: {}, pellets: [] },
channels: {
Expand All @@ -33,6 +39,10 @@ export const handler = defineRoom<BlobWorld, BlobMsg>({
},
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;
Expand All @@ -45,9 +55,17 @@ export const handler = defineRoom<BlobWorld, BlobMsg>({
);
},
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];
Expand Down
31 changes: 31 additions & 0 deletions src/games/party/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
40 changes: 38 additions & 2 deletions src/games/party/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
Expand Down Expand Up @@ -80,8 +103,11 @@ export default defineRoom<State, Msg>({
},

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) {
Expand Down Expand Up @@ -149,13 +175,23 @@ export default defineRoom<State, Msg>({
}
}

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;
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions src/games/party/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
6 changes: 6 additions & 0 deletions src/games/siege/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
9 changes: 9 additions & 0 deletions src/games/siege/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -94,6 +100,9 @@ export default defineRoom<State, Msg>({

onMessage(player, msg, ctx: HandlerContext<State>) {
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;
Expand Down
Loading