Files
station-master/src/server/persistence.ts
T
Jesse 2fbfe11977 v0.5.3 — a table you size yourself, and games an administrator can see and end
Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.

The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.

That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.

And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.

/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.

Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.

SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.

index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.

Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.

Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
2026-08-21 14:53:53 -04:00

175 lines
7.2 KiB
TypeScript

/**
* 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/<gameId>/`), 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<void> {
const tmp = `${path}.tmp`;
await writeFile(tmp, text);
await rename(tmp, path);
}
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
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 }
| { found: true; ok: true; saved: SavedGame }
/** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */
| { found: true; ok: false; storedVersion: string; currentVersion: string };
export async function loadGame(dataDir: string, currentVersion: string): Promise<LoadResult> {
let text: string;
try {
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
} catch {
return { found: false };
}
const payload = JSON.parse(text) as PersistedGame;
if (payload.engineVersion !== currentVersion) {
return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion };
}
const { engineVersion: _engineVersion, ...saved } = payload;
return { found: true, ok: true, saved };
}
/** 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<void> {
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<GameIndexEntry[]> {
try {
return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[];
} catch {
return [];
}
}
async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<void> {
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<void> {
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<void> {
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<void> {
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
}
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
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<Lobby | null> {
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<void> {
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<void> {
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<PlayerSession[]> {
try {
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[];
} catch {
return [];
}
}