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.
175 lines
7.2 KiB
TypeScript
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 [];
|
|
}
|
|
}
|