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
64 changes: 64 additions & 0 deletions src/games/party/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { name: string; score: number; present: boolean }>): 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);
});
});
22 changes: 20 additions & 2 deletions src/games/party/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<State>): 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. */
Expand Down Expand Up @@ -131,6 +139,16 @@ export default defineRoom<State, Msg>({
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)) {
Expand Down
61 changes: 61 additions & 0 deletions src/games/siege/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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');
});
});
37 changes: 37 additions & 0 deletions src/games/siege/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -67,6 +86,9 @@ export default defineRoom<State, Msg>({

onJoin(player, ctx: HandlerContext<State>) {
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 };
},

Expand All @@ -81,6 +103,21 @@ export default defineRoom<State, Msg>({
onTick(ctx: HandlerContext<State>) {
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));
Expand Down
Loading