diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index ef9cb42..5624fa7 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/deploy/replay-ledgers.ts @@ -39,7 +39,10 @@ async function main(): Promise { try { for (const line of lines) { if (line.t === 'seat') seats.push(line); - else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); + else if (line.t === 'unseat') { + const i = seats.findIndex((s) => s.id === line.id); + if (i >= 0) seats.splice(i, 1); + } else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options); else if (line.t === 'turn' && state) { state = game.resolve(state, line.inputs); diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts new file mode 100644 index 0000000..e64b858 --- /dev/null +++ b/template/server/src/rooms.test.ts @@ -0,0 +1,46 @@ +import { appendFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { game, type Input, type State } from '../../src/lib/game'; +import type { GameSpec } from '../../src/lib/game/spec'; +import { Rooms } from './rooms'; +import { Store } from './store'; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'rooms-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** A table of the demo game: the host and a second person, the game begun. */ +function table(spec: GameSpec = game) { + const rooms = new Rooms(spec, new Store(dir)); + const host = rooms.create('Ann', 2); + const guest = rooms.join(host.room, 'Bo'); + rooms.begin(host.room, host.token); + return { rooms, room: host.room, ann: host.token, bo: guest.token }; +} + +describe('rooms', () => { + it('resolves a round once every seat has moved, and refuses a move for a round already played', () => { + const { rooms, room, ann, bo } = table(); + rooms.submit(room, ann, { pick: 5 }, 0); + expect(rooms.waitingOn(room)).toEqual(['B']); + rooms.submit(room, bo, { pick: 2 }, 0); + expect(room.resolved).toBe(1); + expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); + }); + + it('leaves a room with an unreadable ledger unloaded and opens the rest', () => { + const { room } = table(); + appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn'); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const reopened = new Rooms(game, new Store(dir)); + expect(errors).toHaveBeenCalledWith(expect.stringMatching(/ZZZZ: ledger unreadable/)); + expect(reopened.get(room.id).state).not.toBeNull(); + errors.mockRestore(); + }); +}); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index b159a57..5d3604c 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -120,7 +120,14 @@ export class Rooms { private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' } ) { for (const id of store.roomIds()) { - const room = this.replay(id, store.read(id)); + let room: Room | null; + try { + room = this.replay(id, store.read(id)); + } catch (e) { + // One unreadable ledger (a torn last line) keeps its room closed, not the server down. + console.error(`room ${id}: ledger unreadable, left unloaded: ${e instanceof Error ? e.message : String(e)}`); + continue; + } if (!room) continue; this.rooms.set(id, room); // A table that stopped with a bot's move owed picks up where it left off. diff --git a/template/server/src/tally.ts b/template/server/src/tally.ts index 51922ca..f356db5 100644 --- a/template/server/src/tally.ts +++ b/template/server/src/tally.ts @@ -30,7 +30,12 @@ export class Tallies { const names = new Set(); let firstStart = Infinity; for (const id of this.store.roomIds()) { - const lines = this.store.read(id); + let lines: LedgerLine[]; + try { + lines = this.store.read(id); + } catch { + continue; // an unreadable ledger is left out of the count, as its room is left unloaded + } if (lines[0]?.t !== 'room') continue; t.tablesOpened += 1; const seats: { id: SeatId; name: string; bot: boolean }[] = []; @@ -44,8 +49,10 @@ export class Tallies { seats.push(line); if (line.bot) hasBot = true; else names.add(line.name.toLowerCase()); - } else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); - else if (line.t === 'start') { + } else if (line.t === 'unseat') { + const i = seats.findIndex((s) => s.id === line.id); + if (i >= 0) seats.splice(i, 1); + } else if (line.t === 'start') { t.gamesBegun += 1; if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; diff --git a/template/vite.config.ts b/template/vite.config.ts index 721a030..9b8c1e4 100644 --- a/template/vite.config.ts +++ b/template/vite.config.ts @@ -21,6 +21,6 @@ export default defineConfig({ } }, test: { - include: ['src/**/*.test.ts'] + include: ['src/**/*.test.ts', 'server/src/**/*.test.ts'] } });