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.
99 lines
4.3 KiB
TypeScript
99 lines
4.3 KiB
TypeScript
/**
|
|
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start and
|
|
* Phase 4 (§12 steps 17-20) to resume every saved game and lobby, not just one.
|
|
*
|
|
* Run with: node src/server/index.ts
|
|
*
|
|
* Env-configured, no config file — matches how the rest of this project's dev-side tooling reads
|
|
* `process.env` directly (`scripts/build-web.ts`'s `BUILD_DIST_DIR`).
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer } from './http.ts';
|
|
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
|
|
import { resumeSession } from './session.ts';
|
|
import type { GameSession } from './session.ts';
|
|
import type { Lobby, PlayerSession } from './lobby.ts';
|
|
|
|
const port = Number(process.env['PORT'] ?? 8081);
|
|
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
|
|
const joinSecret = process.env['JOIN_SECRET'];
|
|
/**
|
|
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
|
|
* refusing to boot without one would break every existing deployment and every dev run. Unset
|
|
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
|
|
* capability has to be granted, never merely left ungated.
|
|
*/
|
|
const adminSecret = process.env['ADMIN_SECRET'];
|
|
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
|
|
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
|
|
|
|
if (!joinSecret) {
|
|
console.error('JOIN_SECRET must be set — a server-wide secret, passed out of band (D14).');
|
|
process.exit(1);
|
|
}
|
|
|
|
// `package.json`'s version IS `engineVersion` (§12 step 15) — the same reading `scripts/build-web.ts`'s
|
|
// `buildStamp()` already does, just from `src/server/` rather than the repo root script directory.
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
|
|
|
|
const initialGames = new Map<string, GameSession>();
|
|
const initialLobbies = new Map<string, Lobby>();
|
|
const initialSessions = new Map<string, PlayerSession>();
|
|
|
|
const index = await readIndex(dataDir);
|
|
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
|
|
// opens, and a log that only reports each game once it is done gives no warning of how much is
|
|
// still to come.
|
|
const resumable = index.filter((e) => e.status !== 'finished').length;
|
|
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
|
|
for (const entry of index) {
|
|
const sessions = await readSessions(dataDir, entry.gameId);
|
|
for (const s of sessions) initialSessions.set(s.token, s);
|
|
|
|
if (entry.status === 'lobby') {
|
|
const lobby = await readLobby(dataDir, entry.gameId);
|
|
if (lobby) initialLobbies.set(entry.gameId, lobby);
|
|
continue;
|
|
}
|
|
|
|
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
|
|
if (loaded.found && loaded.ok) {
|
|
initialGames.set(entry.gameId, resumeSession(loaded.saved));
|
|
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
|
} else if (loaded.found && !loaded.ok) {
|
|
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
|
|
// under. The file is left untouched: rolling the running version back would let it load again.
|
|
console.error(
|
|
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
|
|
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
|
|
`will not appear as an active game until the version matches again.`,
|
|
);
|
|
}
|
|
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
|
|
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
|
|
}
|
|
|
|
startServer({
|
|
port,
|
|
bindAddress,
|
|
joinSecret,
|
|
adminSecret,
|
|
distDir,
|
|
dataDir,
|
|
engineVersion,
|
|
initialGames,
|
|
initialLobbies,
|
|
initialSessions,
|
|
});
|
|
console.log(
|
|
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
|
|
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
|
|
);
|
|
if (!adminSecret) {
|
|
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
|
|
}
|