A room whose ledger cannot be read is logged and left unloaded, not the server down; the server has tests of its own
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2810076677
commit
faa2845e70
@@ -39,7 +39,10 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.t === 'seat') seats.push(line);
|
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 === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||||
else if (line.t === 'turn' && state) {
|
else if (line.t === 'turn' && state) {
|
||||||
state = game.resolve(state, line.inputs);
|
state = game.resolve(state, line.inputs);
|
||||||
|
|||||||
@@ -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<State, Input> = 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,7 +120,14 @@ export class Rooms<State, Input> {
|
|||||||
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
||||||
) {
|
) {
|
||||||
for (const id of store.roomIds()) {
|
for (const id of store.roomIds()) {
|
||||||
const room = this.replay(id, store.read(id));
|
let room: Room<State> | 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;
|
if (!room) continue;
|
||||||
this.rooms.set(id, room);
|
this.rooms.set(id, room);
|
||||||
// A table that stopped with a bot's move owed picks up where it left off.
|
// A table that stopped with a bot's move owed picks up where it left off.
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ export class Tallies<State, Input> {
|
|||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
let firstStart = Infinity;
|
let firstStart = Infinity;
|
||||||
for (const id of this.store.roomIds()) {
|
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;
|
if (lines[0]?.t !== 'room') continue;
|
||||||
t.tablesOpened += 1;
|
t.tablesOpened += 1;
|
||||||
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
|
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
|
||||||
@@ -44,8 +49,10 @@ export class Tallies<State, Input> {
|
|||||||
seats.push(line);
|
seats.push(line);
|
||||||
if (line.bot) hasBot = true;
|
if (line.bot) hasBot = true;
|
||||||
else names.add(line.name.toLowerCase());
|
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 === 'unseat') {
|
||||||
else if (line.t === 'start') {
|
const i = seats.findIndex((s) => s.id === line.id);
|
||||||
|
if (i >= 0) seats.splice(i, 1);
|
||||||
|
} else if (line.t === 'start') {
|
||||||
t.gamesBegun += 1;
|
t.gamesBegun += 1;
|
||||||
if (hasBot) t.gamesWithBot += 1;
|
if (hasBot) t.gamesWithBot += 1;
|
||||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts']
|
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user