/** * 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 { tryResumeSession } 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)); if (!loaded.found && loaded.corrupt) { // One unreadable file is one game lost, not every game (v0.8.4) — see `loadGame`. console.error(`Skipping ${entry.gameId} (${entry.gameCode}): game.json is unreadable — ${loaded.corrupt}. The file is left untouched.`); continue; } if (loaded.found) { let resumed: ReturnType; try { resumed = tryResumeSession(loaded.saved); } catch (err) { // A save that parses but is not the shape the replay expects (no config, a history entry // that is not an intent) throws inside the engine rather than being refused. Same answer: // this game, not the server. console.error(`Skipping ${entry.gameId} (${entry.gameCode}): the save could not be replayed — ${err instanceof Error ? err.message : String(err)}. The file is left untouched.`); continue; } if (resumed.ok) { initialGames.set(entry.gameId, resumed.session); console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`); } else { /** * The save does not replay under these rules, which is the only thing that has ever actually * mattered — and now the only thing asked. Says which move it choked on, because "some * version differs" was never enough to act on: the file is left untouched, so an operator who * wants the game back can put the previous version on and finish it. */ const f = resumed.failure; console.error( `Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` + `(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` + `version ${loaded.storedVersion}, this server is running ${engineVersion}. The file is ` + `left untouched.`, ); } } // `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.'); }