/** * Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for * Phase 4 (§12 steps 17-20) to more than one game (`lobby-and-sessions.md` §6 specifies both * shapes and the reasoning behind them). * * ONE DIRECTORY PER GAME (`games//`), plus one top-level `index.json` naming every game so * `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is * still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true * on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history * is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at * the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet. */ import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { SavedGame, TurnTiming } from './session.ts'; import type { Lobby, PlayerSession } from './lobby.ts'; const GAME_FILE = 'game.json'; const TIMINGS_FILE = 'turn-timings.json'; const LOBBY_FILE = 'lobby.json'; const SESSIONS_FILE = 'sessions.json'; const INDEX_FILE = 'index.json'; type PersistedGame = SavedGame & { engineVersion: string }; async function atomicWrite(path: string, text: string): Promise { const tmp = `${path}.tmp`; await writeFile(tmp, text); await rename(tmp, path); } export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise { await mkdir(dataDir, { recursive: true }); const payload: PersistedGame = { engineVersion, ...saved }; await atomicWrite(join(dataDir, GAME_FILE), JSON.stringify(payload, null, 1)); } export type LoadResult = | { found: false } /** The version that wrote the file, for diagnostics — it is no longer what decides. */ | { found: true; saved: SavedGame; storedVersion: string }; /** * READS THE SAVE. DOES NOT JUDGE IT. * * This used to refuse any save whose `engineVersion` was not an exact match for the running one, * on the reasoning that a move legal under old rules may not be legal under new ones (D7). The * reasoning is sound and the test was not: the stamp is the PACKAGE version, which moves for * reasons that have nothing to do with the rules, so four consecutive releases destroyed every * game in progress — one of them a release that changed only how the board is drawn. * * Whether a save still replays is a question with an exact answer, so it is now asked directly: * `tryResumeSession` replays the intents and reports the first one the engine refuses, if any. * The version is kept and reported because it is useful in a failure, but it decides nothing. */ export async function loadGame(dataDir: string): Promise { let text: string; try { text = await readFile(join(dataDir, GAME_FILE), 'utf8'); } catch { return { found: false }; } const payload = JSON.parse(text) as PersistedGame; const { engineVersion, ...saved } = payload; return { found: true, saved, storedVersion: engineVersion }; } /** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at * this scale rather than real appending, same reasoning as `writeGame`. */ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise { await mkdir(dataDir, { recursive: true }); const path = join(dataDir, TIMINGS_FILE); let existing: TurnTiming[]; try { existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[]; } catch { existing = []; } existing.push(timing); await atomicWrite(path, JSON.stringify(existing, null, 1)); } // --------------------------------------------------------------------------- // Phase 4 — more than one game // --------------------------------------------------------------------------- export type GameIndexEntry = { gameId: string; gameCode: string; status: 'lobby' | 'active' | 'finished' }; /** Where one game's own files live. Every function below takes a `gameId` and joins it under here * itself; `writeGame`/`loadGame`/`appendTiming` above do not, and take a directory directly, so a * caller wanting one specific game's `game.json` passes `gameDir(dataDir, gameId)` to those. */ export function gameDir(dataDir: string, gameId: string): string { return join(dataDir, 'games', gameId); } export async function readIndex(dataDir: string): Promise { try { return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[]; } catch { return []; } } async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise { await mkdir(dataDir, { recursive: true }); await atomicWrite(join(dataDir, INDEX_FILE), JSON.stringify(entries, null, 1)); } /** * Adds or updates exactly one game's row, by `gameId` — every caller that changes one game's status * (created, started, finished) wants precisely this, not "replace the whole index", which would * silently drop every other game's row the moment two writes happened close together. */ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise { const entries = await readIndex(dataDir); const i = entries.findIndex((e) => e.gameId === entry.gameId); if (i >= 0) entries[i] = entry; else entries.push(entry); await writeIndex(dataDir, entries); } /** * Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the * index says the game exists, and a delete that did one without the other would either resurrect * it on the next boot or leave `index.json` pointing at nothing. */ export async function removeIndexEntry(dataDir: string, gameId: string): Promise { const entries = await readIndex(dataDir); await writeIndex( dataDir, entries.filter((e) => e.gameId !== gameId), ); } /** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */ export async function deleteGame(dataDir: string, gameId: string): Promise { await rm(gameDir(dataDir, gameId), { recursive: true, force: true }); } export async function writeLobby(dataDir: string, lobby: Lobby): Promise { const dir = gameDir(dataDir, lobby.gameId); await mkdir(dir, { recursive: true }); await atomicWrite(join(dir, LOBBY_FILE), JSON.stringify(lobby, null, 1)); } export async function readLobby(dataDir: string, gameId: string): Promise { try { return JSON.parse(await readFile(join(gameDir(dataDir, gameId), LOBBY_FILE), 'utf8')) as Lobby; } catch { return null; } } /** * Removed once a game starts — a `Lobby` and a running `SavedGame` are mutually exclusive for one * `gameId`, and leaving the file behind would let a restart resurrect a lobby for a game already * under way. Missing already is not an error; `Lobby.Start` calls this exactly once, in the same * request that writes `game.json`. */ export async function deleteLobby(dataDir: string, gameId: string): Promise { try { await unlink(join(gameDir(dataDir, gameId), LOBBY_FILE)); } catch { // Already gone — nothing to do. } } /** Session tokens for one game — `lobby-and-sessions.md` §6: persisted so a token still works after * a server restart, the same reason `game.json` itself is persisted. */ export async function writeSessions(dataDir: string, gameId: string, sessions: PlayerSession[]): Promise { const dir = gameDir(dataDir, gameId); await mkdir(dir, { recursive: true }); await atomicWrite(join(dir, SESSIONS_FILE), JSON.stringify(sessions, null, 1)); } export async function readSessions(dataDir: string, gameId: string): Promise { try { return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[]; } catch { return []; } }