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:
co-authored by
Claude Fable 5
parent
89961bbb59
commit
7cfffb8ab5
@@ -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<string, RoomLine[]> {
|
||||
ensureDataDir();
|
||||
const rooms = new Map<string, RoomLine[]>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user