Persistent games: rooms survive server restarts by log replay

Every room is now an append-only JSONL file (data/rooms/CODE.jsonl):
a birth-certificate meta line, then every join, start, and command.
Because the engine is deterministic, seed + log IS the game — on boot
the server replays each file and reconstructs the exact state, hands,
deck order, and chronicle. Verified live: create, join, play, KILL
the server, restart ("restored 1 room(s) from disk"), rejoin with the
seat token — identical positions, deck count, and hand, with the
chronicle history redelivered redacted per player. The client
remembers its seat (name, room, token) in localStorage and walks back
to the table automatically on connect, clearing the memory if the
seat is stale; a quiet "leave table" control forgets it on purpose.
This is the foundation phase 2 (play-by-turn) sits on: games now wait
indefinitely for their players — and debugging restarts cost nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:46:23 -04:00
co-authored by Claude Fable 5
parent 89961bbb59
commit 7cfffb8ab5
6 changed files with 203 additions and 6 deletions
+71 -5
View File
@@ -1,6 +1,7 @@
// Game rooms: the server is the authority. Each room holds one GameState and
// the append-only command log (the seed + log IS the game — the basis for
// replays and async play). Clients get per-player redacted views and events.
// replays, async play, and crash recovery). Every join, start, and command is
// persisted; on boot, rooms are rebuilt by replaying their files.
import { randomBytes, randomInt } from "node:crypto";
import {
@@ -14,6 +15,7 @@ import {
type GameView,
type PlayerId,
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store";
export interface LoggedCommand {
seq: number;
@@ -29,6 +31,7 @@ export interface Room {
/** Per-player secrets: reclaiming a seat requires the matching token. */
tokens: Map<PlayerId, string>;
seed: number;
expansion: boolean;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
@@ -54,11 +57,20 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
players: [hostId],
tokens: new Map([[hostId, token]]),
seed: randomInt(0, 0xffffffff),
expansion: false,
state: null,
log: [],
events: [],
};
rooms.set(room.id, room);
appendLine(room.id, {
kind: "meta",
id: room.id,
hostId,
hostToken: token,
seed: room.seed,
createdAt: new Date().toISOString(),
});
return { room, token };
}
@@ -81,11 +93,11 @@ export function joinRoom(
const fresh = randomBytes(16).toString("hex");
room.players.push(playerId);
room.tokens.set(playerId, fresh);
appendLine(room.id, { kind: "join", name: playerId, token: fresh });
return { token: fresh };
}
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
function startInMemory(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
const n = room.players.length;
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
const { state, events } = createGame({
@@ -93,11 +105,20 @@ export function startGame(room: Room, expansion: boolean): { events: GameEvent[]
seed: room.seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
});
room.expansion = expansion;
room.state = state;
room.events.push(...events);
return { events };
}
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const result = startInMemory(room, expansion);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion });
return result;
}
export function runCommand(
room: Room,
playerId: PlayerId,
@@ -107,13 +128,15 @@ export function runCommand(
const result = applyCommand(room.state, playerId, command);
if (!result.ok) return { error: result.error };
room.state = result.state;
room.log.push({
const logged: LoggedCommand = {
seq: room.log.length,
playerId,
command,
at: new Date().toISOString(),
});
};
room.log.push(logged);
room.events.push(...result.events);
appendLine(room.id, { kind: "command", ...logged });
return { events: result.events };
}
@@ -124,3 +147,46 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
try {
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
const room: Room = {
id,
hostId: meta.hostId,
players: [meta.hostId],
tokens: new Map([[meta.hostId, meta.hostToken]]),
seed: meta.seed,
expansion: false,
state: null,
log: [],
events: [],
};
for (const line of lines.slice(1)) {
if (line.kind === "join") {
room.players.push(line.name);
room.tokens.set(line.name, line.token);
} else if (line.kind === "start") {
const r = startInMemory(room, line.expansion);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "command") {
if (!room.state) throw new Error("command before start in log");
const result = applyCommand(room.state, line.playerId, line.command as Command);
if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`);
room.state = result.state;
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
room.events.push(...result.events);
}
}
rooms.set(id, room);
restored++;
} catch (e) {
console.error(`could not restore room ${id}:`, e);
}
}
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
}