/** * 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(); const initialLobbies = new Map(); const initialSessions = new Map(); 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.'); }