From 7cfffb8ab53f6206d3eb33dc87886f5d859928be Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 23:46:23 -0400 Subject: [PATCH] Persistent games: rooms survive server restarts by log replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + packages/server/src/index.ts | 6 +++ packages/server/src/rooms.ts | 76 +++++++++++++++++++++++++++++++--- packages/server/src/store.ts | 71 +++++++++++++++++++++++++++++++ packages/web/src/App.svelte | 11 +++++ packages/web/src/net.svelte.ts | 44 +++++++++++++++++++- 6 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/store.ts diff --git a/.gitignore b/.gitignore index c3a8769..e5c4c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.local .env .DS_Store +data/ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dcb1429..e56bd15 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,6 +17,7 @@ import { createRoom, getRoom, joinRoom, + loadPersistedRooms, redactFor, runCommand, startGame, @@ -25,6 +26,7 @@ import { } from "./rooms"; const port = Number(process.env.PORT ?? 8787); +loadPersistedRooms(); const wss = new WebSocketServer({ port }); interface Session { @@ -102,6 +104,10 @@ wss.on("connection", (socket) => { session.playerId = name; session.roomId = room.id; send(socket, { type: "seat", playerId: name, token: result.token }); + // Rejoining a running game: replay the chronicle so far. + if (room.state) { + send(socket, { type: "events", events: redactFor(room.events, name) }); + } broadcastRoomState(room); break; } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 69a371b..e20867e 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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; 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; + 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`); +} diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts new file mode 100644 index 0000000..863be76 --- /dev/null +++ b/packages/server/src/store.ts @@ -0,0 +1,71 @@ +// Durable rooms: one append-only JSONL file per room. The first line is the +// room's birth certificate; every later line is a join, a start, or a game +// command. Because the engine is deterministic, replaying a file rebuilds +// the exact game state — server restarts lose nothing. + +import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface RoomMetaLine { + kind: "meta"; + id: string; + hostId: string; + hostToken: string; + seed: number; + createdAt: string; +} + +export interface JoinLine { + kind: "join"; + name: string; + token: string; +} + +export interface StartLine { + kind: "start"; + expansion: boolean; +} + +export interface CommandLine { + kind: "command"; + seq: number; + playerId: string; + command: unknown; + at: string; +} + +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine; + +const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); + +function fileFor(roomId: string): string { + return join(DATA_DIR, `${roomId}.jsonl`); +} + +export function ensureDataDir(): void { + mkdirSync(DATA_DIR, { recursive: true }); +} + +export function appendLine(roomId: string, line: RoomLine): void { + appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8"); +} + +/** Read every persisted room's lines, keyed by room id. */ +export function readAllRooms(): Map { + ensureDataDir(); + const rooms = new Map(); + for (const file of readdirSync(DATA_DIR)) { + if (!file.endsWith(".jsonl")) continue; + const id = file.slice(0, -".jsonl".length); + try { + const lines = readFileSync(join(DATA_DIR, file), "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as RoomLine); + if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines); + } catch (e) { + console.error(`skipping unreadable room file ${file}:`, e); + } + } + return rooms; +} diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1ab23e2..3964654 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -413,6 +413,7 @@ sixth edition {#if net.roomId} room {net.roomId} + {/if} {net.status === "connected" ? "" : "reconnecting…"} @@ -694,6 +695,16 @@ .mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } .mast-status { font-size: 0.8rem; color: #c98a2a; } + .mast-leave { + background: none; + border: none; + color: #8d8672; + font-family: "Archivo Narrow", sans-serif; + font-size: 0.8rem; + text-decoration: underline; + cursor: pointer; + } + .mast-leave:hover { color: #d8d2c0; } .toast { background: #6d2119; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a15eeef..ad516d3 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -131,6 +131,8 @@ function humanize(e: GameEvent): string | null { } } +const SEAT_KEY = "wizwar-seat"; + class Net { status = $state<"disconnected" | "connected">("disconnected"); roomId = $state(null); @@ -144,12 +146,27 @@ class Net { private ws: WebSocket | null = null; private token: string | null = null; + private roomIdPending: string | null = null; connect(): void { if (this.ws) return; const ws = new WebSocket(SERVER_URL); this.ws = ws; - ws.onopen = () => (this.status = "connected"); + ws.onopen = () => { + this.status = "connected"; + // A remembered seat means a game to walk back to (surviving reloads + // AND server restarts — the server replays the room from disk). + const saved = localStorage.getItem(SEAT_KEY); + if (saved && !this.roomId) { + try { + const seat = JSON.parse(saved) as { name: string; roomId: string; token: string }; + this.you = seat.name; + this.token = seat.token; + this.roomIdPending = seat.roomId; + this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token }); + } catch { localStorage.removeItem(SEAT_KEY); } + } + }; ws.onclose = () => { this.status = "disconnected"; this.ws = null; @@ -160,9 +177,17 @@ class Net { switch (msg.type) { case "seat": this.token = msg.token; + if (this.roomIdPending || this.roomId) { + localStorage.setItem(SEAT_KEY, JSON.stringify({ + name: msg.playerId, roomId: this.roomIdPending ?? this.roomId, token: msg.token, + })); + } break; case "room": this.roomId = msg.roomId; + localStorage.setItem(SEAT_KEY, JSON.stringify({ + name: this.you, roomId: msg.roomId, token: this.token, + })); this.players = msg.players; this.hostId = msg.hostId; this.started = msg.started; @@ -177,6 +202,10 @@ class Net { } break; case "error": + if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) { + localStorage.removeItem(SEAT_KEY); + this.roomIdPending = null; + } this.error = msg.message; setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); break; @@ -195,9 +224,22 @@ class Net { join(roomId: string, name: string): void { this.you = name; + this.roomIdPending = roomId.toUpperCase(); this.send({ type: "join", roomId, name, token: this.token }); } + /** Forget the remembered seat and return to the lobby. */ + leave(): void { + localStorage.removeItem(SEAT_KEY); + this.roomId = null; + this.roomIdPending = null; + this.view = null; + this.started = false; + this.players = []; + this.log = []; + this.token = null; + } + start(expansion: boolean): void { this.send({ type: "start", expansion }); }