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 }); }