diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f6f7e..ee14f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,72 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.5.1 — 2026-08-21 + +Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps +17-20, fully specified in `docs/architecture/lobby-and-sessions.md`). Phases 0-3 shipped in v0.4.0 +and v0.5.0; a real server existed but nobody could reach it without a hand-built URL. This is what +makes it a game you can actually create or join. + +### The server hosts more than one game, and knows who you are across a reconnect + +`src/server/lobby.ts` is new: pure logic, no sockets, no filesystem, the same split `session.ts` +draws for a running game. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, plus a +speakable game code (`RAIL-4471` style) and the player cap. + +`persistence.ts` gained one directory per `gameId` and a top-level index, so `index.ts` resumes +every saved game on boot, not just one. `/api/stream` and `/api/intent` now authenticate by session +**token** instead of `?seat=&secret=` — `lobby-and-sessions.md` §1: the token alone proves identity, +so the join secret's job ends at the lobby door (`/api/lobby/create`/`/api/lobby/join`). + +### Bots fill empty seats, never take over a disconnected human (D8) + +`session.ts` gained `driveBots()` — after any accepted intent, and once at construction for a +resume that lands exactly on a bot's turn, it plays `developerBot` forward through every +consecutive bot seat before the push goes out. Reuses `legalActions`/`developerBot` wholesale. +`SavedGame` gained `botSeats` so a bot seat survives a restart. Bots are assigned once, at +`Lobby.Start`, and never afterward — a disconnected human's seat waits, exactly as before. + +### Disconnect keeps the seat and says so; reconnect gets a full view, not a tail + +`Push` gained an optional `presence` field — connection news about another seat, built entirely by +`http.ts` (which owns the connection table) and never routed through `session.ts` or the engine: a +disconnect is transport news, not a `GameEvent`, and the engine must stay replayable from a seed. +The page shows a small banner naming who has dropped and clears it the moment they reconnect. +Host rights pass to the earliest-joined remaining player if the host's own connection drops before +`Lobby.Start` — tracked by join order rather than seat, since a bot-filled seat never joined at all. + +### The client: an actual lobby, not a URL you hand-build + +`src/web/lobby.ts`, wired from `main.ts`: create-or-join forms, a live seating screen (host-only bot +toggles and Start button, updated over its own SSE stream), and `localStorage` in place of `?seat=` +for "was I already in a game" — found on load, it reconnects straight through and skips the lobby +screen entirely. A `Multiplayer` button sits beside `New game`; the New Game dialog itself is +untouched and still solitaire-only, its old "needs a server" note repointed at the new button. + +### Found only by the live smoke test, not by typechecking + +`/api/intent` read its token from the JSON body; `web/session.ts`'s `submit()` — unchanged since +Phase 2 — sends it in the query string, the same as `/api/stream`. Every intent failed `no such +game`. Both sides typecheck cleanly on their own (an HTTP body is `unknown` on the wire), which is +exactly the gap a curl-level smoke test exists to catch: create a lobby, join a second player, +start, submit from both seats (including a wrong-actor rejection and an idempotent resend), +kill and restart the server and reconnect both tokens, start a bot-filled coop lobby and confirm it +never stalls waiting on the bot, and watch a live stream receive a disconnect/reconnect presence +notice for another seat. + +### Doc fix + +`multiplayer.md`'s decision table (D18) said the player cap was 6; `lobby-and-sessions.md` §2 — +more detailed, and what `test/multiplayer.test.ts` actually exercises — says 2-4 with the reasoning +for it. The two had quietly drifted apart; "6" was never implemented or tested anywhere. D18 now +reads 2-4. + +**Not verified: an actual browser** walking through the lobby screens — none is available in this +environment, the same limitation Phase 2's `RemoteSession` shipped under. 656 tests, 0 failures. + +--- + ## 0.5.0 — 2026-08-21 Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one diff --git a/TODO.md b/TODO.md index 42719f7..1a81533 100644 --- a/TODO.md +++ b/TODO.md @@ -494,7 +494,7 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite **three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are wired and read, and fire only against these cards. Until then those three are dormant by design rather than broken. -- [x] **Multiplayer proper — Phases 0, 1 and 2 done (v0.4.0, 2026-08-20), Phases 3–6 to go.** The +- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass: - `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a @@ -579,6 +579,65 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite restarted, server logged the refusal and started with no active game (confirmed via `POST /api/game` succeeding rather than 409ing). + **Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and + `lobby-and-sessions.md` in full: + + - `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts` + already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a + speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a + dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap + (`playerCountAllowed`) — see the doc-fix note below. + - **The server now holds more than one game.** `persistence.ts` gained one directory per + `gameId` (`games//`) plus a top-level `index.json` naming every game, so `index.ts` + can resume all of them on boot rather than the one `game.json` Phase 3 assumed. + `writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a + directory directly. + - **Session tokens replace `?seat=&secret=` on the running-game routes.** + `lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and + `/api/intent` now read `?token=` and the join secret's job ends at the lobby door + (`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes + `(token, seat)` — `seat` still passed in rather than learned from a push, since it has to + answer before any push necessarily arrives, and the caller already has it from the + join/create/start response. + - **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained + `driveBots()`: after any accepted intent (and once at construction, for a resume that lands + exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat + before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic. + `SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart. + - **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection + closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token + list rather than seat order, since a bot-filled seat has no join time of its own. + - **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news + about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never + routed through `session.ts` or the engine, since a disconnect is transport news about a + connection, not a `GameEvent`. The page shows a banner naming who has dropped + (`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed + no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`. + - **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join + forms, a live seating screen (host-only bot toggles and Start button, updated over a new + `/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a + game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby + entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog + itself is untouched, still solitaire-only, its old "needs a server" note repointed at the + new button. + - **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent` + read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase + 2 — sends it in the query string, same as `/api/stream`. Every request failed `no such + game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which + is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`. + - **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 — + which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises). + The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4. + - Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats, + host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two + bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a + second player, start, submit intents from both (including the wrong-actor rejection and an + idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby + starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice + observed on an open stream. **Not verified: an actual browser** walking through the lobby + screens — none is available in this environment, the same limitation Phase 2's `RemoteSession` + shipped under. + --- ## Other diff --git a/docs/architecture/multiplayer.md b/docs/architecture/multiplayer.md index d7f5a8e..2b3fc76 100644 --- a/docs/architecture/multiplayer.md +++ b/docs/architecture/multiplayer.md @@ -338,7 +338,7 @@ all it needs. | **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. | | **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. | | **D17** | **Undo is solitaire-only** | Other players have seen the result. | -| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. | +| **D18** | **Player cap 2-4** for competitive/coop (solitaire is 1) | Per `lobby-and-sessions.md` §2 — sized to what is actually exercised (`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish), not to a guess. This entry read "6" until Phase 4 (v0.5.1); that number was never implemented or tested anywhere and the two docs had drifted apart. Raise it once somebody has played a bigger game and reported back, not before. | | **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. | | **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. | diff --git a/package.json b/package.json index af1bc6f..9badd87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.0", + "version": "0.5.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/http.ts b/src/server/http.ts index da483f3..bbf60f8 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -1,14 +1,20 @@ /** - * The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12). + * The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12), + * extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game. * * Plain `node:http`, no framework: the project has zero runtime dependencies * (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than * reaching for a bundler — this matches that everywhere-else choice rather than introducing the * first framework dependency for one route table. * - * All the game logic lives in `session.ts`; this file is deliberately thin — routing, the join-secret - * gate, SSE mechanics, and static file serving for the built client (`dist/`, D16: the server serves - * the client, which is what makes same-origin work with no CORS). + * All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing, + * the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE + * mechanics, and static file serving for the built client (`dist/`, D16: the server serves the + * client, which is what makes same-origin work with no CORS). + * + * TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token + * already proves "I am the player who was in this game," which is the only identity claim `/api/stream` + * and `/api/intent` need — the join secret's job ends at the lobby door. */ import { createServer } from 'node:http'; @@ -19,23 +25,43 @@ import { extname, join, normalize } from 'node:path'; import type { Intent } from '../engine/intents.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts'; -import { appendTiming, writeGame } from './persistence.ts'; +import { + appendTiming, + deleteLobby, + gameDir, + upsertIndexEntry, + writeGame, + writeLobby, + writeSessions, +} from './persistence.ts'; import { createSession } from './session.ts'; import type { GameSession, Push } from './session.ts'; +import { + createLobby, + freshGameCode, + joinLobby, + reassignHost, + setBotSeat, + startLobby, +} from './lobby.ts'; +import type { Lobby, PlayerSession } from './lobby.ts'; export type ServerOptions = { port: number; bindAddress: string; - /** D14 — a server-wide secret, passed out of band. Gates every `/api/*` route. */ + /** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door; + * once a player is through it and holds a token, the token alone authenticates them. */ joinSecret: string; /** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */ distDir: string; - /** Where `game.json`/`turn-timings.json` live (Phase 3). */ + /** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */ dataDir: string; /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ engineVersion: string; - /** Already reconstructed by `index.ts`'s load-on-start, or `null` for a fresh server. */ - initialSession: GameSession | null; + /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */ + initialGames: Map; + initialLobbies: Map; + initialSessions: Map; }; const MIME: Record = { @@ -82,92 +108,272 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse } } +/** + * What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta + * mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this). + * + * `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is + * closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream + * simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry. + */ +type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean }; + export function startServer(opts: ServerOptions): void { - let session: GameSession | null = opts.initialSession; - // One open SSE response per seat — a second connection from the same seat replaces the first - // rather than fanning out to both (Phase 2 has no concept of "the same seat from two tabs"). - const connections = new Map(); - const eventIds = new Map(); + const games = opts.initialGames; + const lobbies = opts.initialLobbies; + const sessions = opts.initialSessions; + const gameCodes = new Map(); // gameCode -> gameId, for /api/lobby/join + for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); - function checkSecret(url: URL, res: ServerResponse): boolean { - if (url.searchParams.get('secret') === opts.joinSecret) return true; - sendJson(res, 403, { error: 'bad or missing secret' }); - return false; + // One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a + // lobby still being seated — a second connection from the same seat/token replaces the first + // rather than fanning out to both (no concept yet of "the same seat from two tabs"). + const gameConnections = new Map>(); + const gameEventIds = new Map>(); + const lobbyConnections = new Map>(); + + function writeSse(res: ServerResponse, id: number, data: unknown): void { + res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`); } - function writeSse(seat: PlayerIndex, push: Push): void { - const res = connections.get(seat); - if (!res) return; // that seat is not currently connected — Phase 4's reconnect story, not this one - const id = (eventIds.get(seat) ?? 0) + 1; - eventIds.set(seat, id); - res.write(`id: ${id}\ndata: ${JSON.stringify(push)}\n\n`); + function nextEventId(gameId: string, seat: PlayerIndex): number { + const ids = gameEventIds.get(gameId) ?? new Map(); + const id = (ids.get(seat) ?? 0) + 1; + ids.set(seat, id); + gameEventIds.set(gameId, ids); + return id; } - function broadcast(pushes: Map): void { - for (const [seat, push] of pushes) writeSse(seat, push); + function broadcastGame(gameId: string, pushes: Map): void { + const conns = gameConnections.get(gameId); + if (!conns) return; + for (const [seat, push] of pushes) { + const res = conns.get(seat); + if (res) writeSse(res, nextEventId(gameId, seat), push); + } + } + + /** + * Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go + * through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently + * connected seat of the same game as a presence-only push (an empty board delta, no menu, no new + * lines) rather than inventing a second SSE event type — one message shape for the client to parse. + */ + function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void { + const conns = gameConnections.get(gameId); + if (!conns) return; + for (const [other, res] of conns) { + if (other === seat) continue; + const push: Push = { menu: null, lines: [], presence: { seat, connected } }; + writeSse(res, nextEventId(gameId, other), push); + } + } + + function broadcastLobby(gameId: string): void { + const lobby = lobbies.get(gameId); + const conns = lobbyConnections.get(gameId); + if (!lobby || !conns) return; + for (const [token, res] of conns) { + const ps = sessions.get(token); + if (!ps) continue; + writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); + } + } + + async function persistLobby(lobby: Lobby): Promise { + lobbies.set(lobby.gameId, lobby); + gameCodes.set(lobby.gameCode, lobby.gameId); + await writeLobby(opts.dataDir, lobby); + await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' }); + } + + async function persistSession(ps: PlayerSession): Promise { + sessions.set(ps.token, ps); + const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId); + await writeSessions(opts.dataDir, ps.gameId, all); } const server = createServer((req, res) => { void (async () => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); - if (url.pathname === '/api/game' && req.method === 'POST') { - if (!checkSecret(url, res)) return; - if (session) { - sendJson(res, 409, { error: 'a game already exists on this server' }); + // -- Lobby: creating and joining (the door — join-secret gated) -------------------------- + + if (url.pathname === '/api/lobby/create' && req.method === 'POST') { + const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string }; + if (body.secret !== opts.joinSecret) { + sendJson(res, 403, { error: 'bad or missing secret' }); return; } - const body = (await readJson(req)) as { config?: GameConfig; playerNames?: string[]; seed?: number }; - if (!body.config || !Array.isArray(body.playerNames) || body.playerNames.length < 1) { - sendJson(res, 400, { error: 'expected { config, playerNames }' }); + if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { + sendJson(res, 400, { error: 'expected { secret, config, displayName }' }); return; } - session = createSession(body.seed ?? Math.floor(Math.random() * 1e9), body.config, body.playerNames); - // Persisted immediately, empty history and all — a crash one second after creation should - // still resume as "the game exists, day 1, nobody has moved" rather than vanish entirely. - await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion); - sendJson(res, 200, { ok: true, playerCount: session.playerCount }); + const gameCode = freshGameCode((code) => gameCodes.has(code)); + const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode); + await persistLobby(lobby); + await persistSession(session); + sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); return; } - if (url.pathname === '/api/stream' && req.method === 'GET') { - if (!checkSecret(url, res)) return; - if (!session) { - sendJson(res, 404, { error: 'no game yet' }); + if (url.pathname === '/api/lobby/join' && req.method === 'POST') { + const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string }; + if (body.secret !== opts.joinSecret) { + sendJson(res, 403, { error: 'bad or missing secret' }); return; } - const seat = Number(url.searchParams.get('seat')) as PlayerIndex; - if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) { - sendJson(res, 400, { error: 'bad or missing ?seat=' }); + if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') { + sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' }); return; } - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + const gameId = gameCodes.get(body.gameCode.trim().toUpperCase()); + const lobby = gameId ? lobbies.get(gameId) : undefined; + if (!lobby) { + // A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a + // typo gets, which tells a latecomer "that game is gone" without leaking which case it was. + sendJson(res, 404, { error: 'no open lobby with that code' }); + return; + } + const result = joinLobby(lobby, body.displayName.trim()); + if (!result.ok) { + sendJson(res, 409, { error: result.code }); + return; + } + await persistLobby(result.lobby); + await persistSession(result.session); + broadcastLobby(lobby.gameId); + sendJson(res, 200, { gameId: lobby.gameId, token: result.session.token, player: result.session.player }); + return; + } + + // -- Lobby: seating, once inside (token-authenticated) ------------------------------------ + + if (url.pathname === '/api/lobby/bot' && req.method === 'POST') { + const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean }; + const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; + const lobby = ps ? lobbies.get(ps.gameId) : undefined; + if (!ps || !lobby) { + sendJson(res, 404, { error: 'no such lobby' }); + return; + } + if (lobby.hostToken !== ps.token) { + sendJson(res, 403, { error: 'NOT_HOST' }); + return; + } + if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') { + sendJson(res, 400, { error: 'expected { token, seat, filled }' }); + return; + } + const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled); + await persistLobby(updated); + broadcastLobby(lobby.gameId); + sendJson(res, 200, { ok: true }); + return; + } + + if (url.pathname === '/api/lobby/start' && req.method === 'POST') { + const body = (await readJson(req)) as { token?: string }; + const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; + const lobby = ps ? lobbies.get(ps.gameId) : undefined; + if (!ps || !lobby) { + sendJson(res, 404, { error: 'no such lobby' }); + return; + } + const result = startLobby(lobby, ps.token); + if (!result.ok) { + sendJson(res, 409, { error: result.code }); + return; + } + const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats); + games.set(lobby.gameId, session); + lobbies.delete(lobby.gameId); + // Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game + // forward from here. `started: true` on one last message, THEN close, is what lets a + // still-open lobby tab tell "the game began" apart from a network hiccup `EventSource` + // would otherwise silently retry through. + for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) { + const watcherPs = sessions.get(watcherToken); + if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush); + watcherRes.end(); + } + lobbyConnections.delete(lobby.gameId); + + await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion); + await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' }); + await deleteLobby(opts.dataDir, lobby.gameId); + sendJson(res, 200, { ok: true }); + return; + } + + if (url.pathname === '/api/lobby/stream' && req.method === 'GET') { + const token = url.searchParams.get('token') ?? ''; + const ps = sessions.get(token); + const lobby = ps ? lobbies.get(ps.gameId) : undefined; + if (!ps || !lobby) { + sendJson(res, 404, { error: 'no such lobby' }); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const conns = lobbyConnections.get(lobby.gameId) ?? new Map(); + conns.set(token, res); + lobbyConnections.set(lobby.gameId, conns); + writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + req.on('close', () => { + clearInterval(heartbeat); + const live = lobbyConnections.get(lobby.gameId); + if (live?.get(token) === res) live.delete(token); + // `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player + // if the host's connection closes before start. `lobbies.get` again, not the captured + // `lobby`, because it may have changed (another join, another bot toggle) since connect. + const current = lobbies.get(lobby.gameId); + if (current && current.hostToken === token) { + void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId)); + } }); - connections.set(seat, res); - writeSse(seat, session.connect(seat)); + return; + } + + // -- The running game (token-authenticated) ------------------------------------------------ + + if (url.pathname === '/api/stream' && req.method === 'GET') { + const token = url.searchParams.get('token') ?? ''; + const ps = sessions.get(token); + const session = ps ? games.get(ps.gameId) : undefined; + if (!ps || !session) { + sendJson(res, 404, { error: 'no such game' }); + return; + } + const { gameId, player: seat } = ps; + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const conns = gameConnections.get(gameId) ?? new Map(); + conns.set(seat, res); + gameConnections.set(gameId, conns); + writeSse(res, nextEventId(gameId, seat), session.connect(seat)); + broadcastPresence(gameId, seat, true); // Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a // silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a // real event (EventSource ignores lines starting with `:`), so it costs the client nothing. const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); req.on('close', () => { clearInterval(heartbeat); - if (connections.get(seat) === res) connections.delete(seat); + const live = gameConnections.get(gameId); + if (live?.get(seat) === res) live.delete(seat); + broadcastPresence(gameId, seat, false); }); return; } if (url.pathname === '/api/intent' && req.method === 'POST') { - if (!checkSecret(url, res)) return; - if (!session) { - sendJson(res, 404, { error: 'no game yet' }); - return; - } - const seat = Number(url.searchParams.get('seat')) as PlayerIndex; - if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) { - sendJson(res, 400, { error: 'bad or missing ?seat=' }); + // Token comes from the QUERY STRING, matching `/api/stream` and matching what + // `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`) + // — the body carries only what changes per call, `{ seq, intent }`. + const token = url.searchParams.get('token') ?? ''; + const ps = sessions.get(token); + const session = ps ? games.get(ps.gameId) : undefined; + if (!ps || !session) { + sendJson(res, 404, { error: 'no such game' }); return; } const body = (await readJson(req)) as { seq?: number; intent?: Intent }; @@ -175,15 +381,23 @@ export function startServer(opts: ServerOptions): void { sendJson(res, 400, { error: 'expected { seq, intent }' }); return; } - const result = session.intent(seat, body.seq, body.intent); + const result = session.intent(ps.player, body.seq, body.intent); if (result.accepted) { // Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at // this scale, not just "applied in memory" (§12 step 14). - await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion); - if (result.timing) await appendTiming(opts.dataDir, result.timing); + const dir = gameDir(opts.dataDir, ps.gameId); + await writeGame(dir, session.exportSave(), opts.engineVersion); + if (result.timing) await appendTiming(dir, result.timing); + if (session.exportSave().status === 'finished') { + // `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be + // carried forward here rather than left blank — `gameCodes` is the only place still + // holding it once a lobby's own record is gone. + const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? ''; + await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' }); + } } sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code }); - if (result.accepted) broadcast(result.pushes); + if (result.accepted) broadcastGame(ps.gameId, result.pushes); return; } diff --git a/src/server/index.ts b/src/server/index.ts index f5de72b..2e777c1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ /** - * Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start. + * 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 * @@ -11,9 +12,10 @@ import { readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startServer } from './http.ts'; -import { loadGame } from './persistence.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'; @@ -31,21 +33,50 @@ if (!joinSecret) { const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version; -let initialSession: GameSession | null = null; +const initialGames = new Map(); +const initialLobbies = new Map(); +const initialSessions = new Map(); -const loaded = await loadGame(dataDir, engineVersion); -if (loaded.found && loaded.ok) { - initialSession = resumeSession(loaded.saved); - console.log(`Resumed a saved game from ${dataDir} (${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 load ${dataDir}/game.json: it was saved under engine version ` + - `${loaded.storedVersion}, this server is running ${engineVersion}. Starting with no active ` + - `game. The saved file has not been touched.`, - ); +const index = await readIndex(dataDir); +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, distDir, dataDir, engineVersion, initialSession }); -console.log(`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir}`); +startServer({ + port, + bindAddress, + joinSecret, + 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.`, +); diff --git a/src/server/lobby.ts b/src/server/lobby.ts new file mode 100644 index 0000000..c4e41a2 --- /dev/null +++ b/src/server/lobby.ts @@ -0,0 +1,167 @@ +/** + * The lobby — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), fully specified in + * `docs/architecture/lobby-and-sessions.md`. + * + * Pure logic, no sockets, no filesystem, no in-memory registry — same split `session.ts` already + * draws (`http.ts` wires connections and persistence to this; `persistence.ts` is the only thing + * that touches disk). A `Lobby` exists only BEFORE `Lobby.Start`: once the config locks and the + * `GameSession` is built, this module is done with that game — everything after is `session.ts`. + * + * WHY A SEPARATE FILE FROM `session.ts`. `session.ts` is already the "running game" module and is + * large; a lobby's concerns — seating, host rights, the join secret's door versus a game code's + * discovery, config locking — share almost no code with running a Stage clock, and mixing them would + * make both harder to read for no reuse gained. + */ + +import { randomUUID } from 'node:crypto'; + +import type { GameConfig, PlayerIndex } from '../engine/state.ts'; + +/** `lobby-and-sessions.md` §1 — names the PLAYER, not the seat. Issued once, at join, and never + * reissued: it is what makes reconnection work, so it must outlive the connection that first used + * it. */ +export type PlayerSession = { + token: string; + gameId: string; + player: PlayerIndex; + displayName: string; +}; + +export type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null; + +/** A game that has not started. `hostToken` ends its meaning at `Lobby.Start` — nothing here + * survives into the running game except the seat list and the locked `config`. */ +export type Lobby = { + gameId: string; + gameCode: string; + hostToken: string; + config: GameConfig; + seats: LobbySeat[]; + /** Tokens in the order they joined — human seats only, host first — so a departed host's + * replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */ + joinOrder: string[]; + createdAt: number; +}; + +export type CreateResult = { lobby: Lobby; session: PlayerSession }; +export type JoinResult = { ok: true; lobby: Lobby; session: PlayerSession } | { ok: false; code: 'LOBBY_FULL' | 'ALREADY_STARTED' }; +export type StartResult = { ok: true; playerNames: string[]; botSeats: PlayerIndex[] } | { ok: false; code: 'NOT_HOST' | 'BAD_PLAYER_COUNT' }; + +/** + * `lobby-and-sessions.md` §2 — short and speakable, not an account name and not a UUID. A small + * fixed word list rather than a dictionary: this is read aloud across a table or a call, not typed + * from memory, so a handful of unambiguous railroad words serve better than a large random one. + */ +const CODE_WORDS = [ + 'RAIL', 'YARD', 'DEPOT', 'COAL', 'MAIL', 'CABOOSE', 'SIGNAL', 'FREIGHT', + 'TRESTLE', 'HOPPER', 'TENDER', 'SIDING', 'GRADE', 'SPUR', 'WHISTLE', 'TROLLEY', +]; + +function randomCode(): string { + const word = CODE_WORDS[Math.floor(Math.random() * CODE_WORDS.length)]!; + const digits = Math.floor(Math.random() * 10_000) + .toString() + .padStart(4, '0'); + return `${word}-${digits}`; +} + +/** Collision-checked against whatever codes the caller already knows about — `http.ts` holds the + * live registry, so the check lives there rather than this module owning a set of its own. */ +export function freshGameCode(taken: (code: string) => boolean): string { + let code = randomCode(); + while (taken(code)) code = randomCode(); + return code; +} + +/** `lobby-and-sessions.md` §2 — solitaire is exactly 1, competitive/coop are 2-4. Not a rules + * limit: the engine will build a Division for any seat count. This is the lobby judgment, sized to + * what has actually been played and tested (`test/multiplayer.test.ts` exercises 2, 3 and 4). */ +export function playerCountAllowed(mode: GameConfig['mode'], count: number): boolean { + return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4; +} + +/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */ +export function createLobby(config: GameConfig, hostDisplayName: string, gameCode: string): CreateResult { + const gameId = randomUUID(); + const token = randomUUID(); + const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName }; + const lobby: Lobby = { + gameId, + gameCode, + hostToken: token, + config, + seats: [{ kind: 'human', token, displayName: hostDisplayName }], + joinOrder: [token], + createdAt: Date.now(), + }; + return { lobby, session }; +} + +/** Joins the first empty seat, or extends the seat list if every seat so far is filled and the + * mode's cap allows one more (`lobby-and-sessions.md` §2's 2-4 is enforced at `Lobby.Start`, not + * here — a 5th join before anyone starts is refused outright since there is no seat it could ever + * play in, but the running count is checked against `playerCountAllowed` at every join too, so a + * lobby can never grow the seats array past what could legally start). */ +export function joinLobby(lobby: Lobby, displayName: string): JoinResult { + const cap = lobby.config.mode === 'solitaire' ? 1 : 4; + const empty = lobby.seats.findIndex((s) => s === null); + const seatIndex = empty >= 0 ? empty : lobby.seats.length; + if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' }; + + const token = randomUUID(); + const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName }; + const seats = [...lobby.seats]; + seats[seatIndex] = { kind: 'human', token, displayName }; + return { + ok: true, + lobby: { ...lobby, seats, joinOrder: [...lobby.joinOrder, token] }, + session, + }; +} + +/** Host-only in effect (`http.ts` checks the caller's token against `hostToken` before calling + * this) — marks an empty seat as bot-filled, or clears one back to empty. Never touches an occupied + * human seat; the host removes a person by them leaving, not by overwriting their seat. */ +export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby { + const seats = [...lobby.seats]; + while (seats.length <= seat) seats.push(null); + if (filled) { + if (seats[seat] !== null) return lobby; + seats[seat] = { kind: 'bot' }; + } else { + if (seats[seat]?.kind !== 'bot') return lobby; + seats[seat] = null; + } + return { ...lobby, seats }; +} + +/** + * The host's connection closed before `Lobby.Start`. `lobby-and-sessions.md` §2: rights pass to the + * earliest-joined remaining player — remaining means still occupying a seat, not necessarily still + * connected, since a momentary drop should not also cost the NEXT person the host chair. Returns + * the lobby unchanged if the departing token was not the host, or if no other human seat exists. + */ +export function reassignHost(lobby: Lobby, departingToken: string): Lobby { + if (lobby.hostToken !== departingToken) return lobby; + const stillSeated = new Set( + lobby.seats.flatMap((s) => (s?.kind === 'human' ? [s.token] : [])), + ); + const next = lobby.joinOrder.find((t) => t !== departingToken && stillSeated.has(t)); + return next ? { ...lobby, hostToken: next } : lobby; +} + +/** + * Locks the config, checks the player count, and hands back exactly what `session.ts`'s + * `createSession` needs — this function does not call it, so `lobby.ts` never depends on + * `session.ts` (the dependency runs the other way: `http.ts` calls both). + */ +export function startLobby(lobby: Lobby, callerToken: string): StartResult { + if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' }; + const filled = lobby.seats.filter((s) => s !== null); + if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) { + return { ok: false, code: 'BAD_PLAYER_COUNT' }; + } + const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot')); + const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : [])); + return { ok: true, playerNames, botSeats }; +} diff --git a/src/server/persistence.ts b/src/server/persistence.ts index f186d55..76ac1af 100644 --- a/src/server/persistence.ts +++ b/src/server/persistence.ts @@ -1,22 +1,26 @@ /** - * Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15; - * `lobby-and-sessions.md` §6 specifies the exact shape and reasoning). + * 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 game per process (Phase 2's scope, unchanged) — two files in `DATA_DIR`, no index and no - * `gameId`, both deferred to Phase 4's multi-game generalization same as the server core deferred - * them. Every write is 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. + * ONE DIRECTORY PER GAME (`games//`), 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, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, 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 }; @@ -67,3 +71,86 @@ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise 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 { + try { + return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[]; + } catch { + return []; + } +} + +async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise { + 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 { + 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); +} + +export async function writeLobby(dataDir: string, lobby: Lobby): Promise { + 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 { + 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 { + 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 { + 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 { + try { + return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[]; + } catch { + return []; + } +} diff --git a/src/server/session.ts b/src/server/session.ts index 72930b9..240f075 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -19,6 +19,7 @@ */ import { check } from '../engine/apply.ts'; +import { legalActions } from '../engine/legal.ts'; import type { Intent } from '../engine/intents.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts'; @@ -27,13 +28,28 @@ import { deltaFrame } from '../sim/frame-delta.ts'; import type { FrameDelta } from '../sim/frame-delta.ts'; import { snapshot } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts'; +import { developerBot } from '../sim/bot.ts'; export type Push = { - frame: FrameDelta; + /** + * Absent on a presence-only push (below) — there is no board delta to send when nothing about the + * GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame + * for a seat that may not even be in this game's connection table (a lobby-stage notice has none). + */ + frame?: FrameDelta; /** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */ menu: Menu | null; /** Narration since the LAST push to this specific seat, not the whole game's log. */ lines: { text: string; tone: string }[]; + /** + * Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md` + * §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go + * through the engine or the shared narration log (which must stay replayable from a seed). Built + * and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never + * sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no + * `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`. + */ + presence?: { seat: PlayerIndex; connected: boolean }; }; /** @@ -59,6 +75,13 @@ export type SavedGame = { history: Intent[]; status: 'active' | 'finished'; createdAt: number; + /** + * Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby + * time only, never mid-game). Persisted so a bot seat is still a bot after a server restart — + * `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean + * guessing from a display name rather than reading a fact. + */ + botSeats: PlayerIndex[]; }; export type IntentResult = @@ -67,6 +90,7 @@ export type IntentResult = export type GameSession = { readonly playerCount: number; + isBot(seat: PlayerIndex): boolean; /** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */ connect(seat: PlayerIndex): Push; intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; @@ -76,7 +100,12 @@ export type GameSession = { type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number }; -function buildSession(game: Game, playerNames: string[], createdAt: number): GameSession { +function buildSession( + game: Game, + playerNames: string[], + createdAt: number, + botSeats: Set, +): GameSession { const lastSeq = new Map(); const lastFrame = new Map(); const sentLines = new Map(); @@ -137,8 +166,40 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam return closed; } + /** + * PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends. + * + * `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never + * takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at + * `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a + * client with a `seq` to dedupe against, and its choice is by construction always legal (`check` + * would only ever confirm what `legalActions` already promised). + * + * Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded — + * `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides + * in zero wall-clock time by definition. Called once at construction (a resume could land exactly + * on a bot's turn) and once after every accepted human intent. + */ + function driveBots(): void { + let guard = 0; + for (;;) { + const actor = currentActor(game); + if (actor === null || !botSeats.has(actor)) return; + if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`); + const options = legalActions(game.state, actor); + if (options.length === 0) return; + const choice = developerBot.choose(game.state, actor, options); + const ok = submit(game, choice); + /* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */ + if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`); + settleTiming(); + } + } + driveBots(); + return { playerCount: playerNames.length, + isBot: (seat) => botSeats.has(seat), connect(seat) { // A (re)connect always starts from a clean slate — no cache to trust across a lost connection @@ -169,6 +230,10 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam lastSeq.set(seat, seq); const timing = settleTiming(); + // Any bot due to act now plays out entirely before this push goes back — the delta mechanism + // diffs against whatever was last sent, so it captures the bots' moves along with the human's + // in one push regardless of how many turns that took. + driveBots(); return { accepted: true, pushes: pushesForAll(), timing }; }, @@ -180,13 +245,19 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam history: [...game.history], status: game.state.status === 'finished' ? 'finished' : 'active', createdAt, + botSeats: [...botSeats], }; }, }; } -export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession { - return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now()); +export function createSession( + seed: number, + config: GameConfig, + playerNames: string[], + botSeats: PlayerIndex[] = [], +): GameSession { + return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats)); } /** @@ -196,5 +267,5 @@ export function createSession(seed: number, config: GameConfig, playerNames: str */ export function resumeSession(saved: SavedGame): GameSession { const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); - return buildSession(game, saved.playerNames, saved.createdAt); + return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats)); } diff --git a/src/web/game.ts b/src/web/game.ts index a256237..34efc7a 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -85,6 +85,33 @@ export const SOLO_CONFIG: GameConfig = { houseRules: DEFAULT_HOUSE_RULES, }; +/** + * The lobby's (`lobby.ts`) starting point for a Competitive or Co-op `Lobby.Create` — same formula + * `SOLO_CONFIG` uses, at a nominal player count. `minCombinedRevenue` is necessarily a guess at + * create time: nobody is seated yet, so there is no real headcount to size it against. It stays a + * guess rather than being fixed up at `Lobby.Start`, matching what the New Game dialog already told + * players before there was a lobby at all ("assumes a 4-player table until there's a lobby to ask + * who's actually seated") — Phase 5 or a follow-up can revisit sizing it to the seats actually + * filled once that is worth the complexity. + */ +export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = 4): GameConfig { + return { + mode, + days: DEFAULT_DAYS, + minCombinedRevenue: collectiveRevenueFloor(players, DEFAULT_DAYS), + maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, + maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, + pvpCardsAllowed: mode === 'competitive', + optionalRules: { + reducedVisibility: false, + sisterTrains: false, + employeeRotation: false, + emergencyToolbox: false, + }, + houseRules: DEFAULT_HOUSE_RULES, + }; +} + /** * Everything the New Game dialog can set for a solitaire game — the opening deal and the three * revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20. diff --git a/src/web/lobby.ts b/src/web/lobby.ts new file mode 100644 index 0000000..3858105 --- /dev/null +++ b/src/web/lobby.ts @@ -0,0 +1,169 @@ +/** + * The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20). + * + * Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, creating or joining a + * game by code, and the seating screen up to `Lobby.Start`. `main.ts` calls `runLobby` once, at + * `start()`, only when there is no stored session to reconnect with — see `main.ts`'s own comment + * on why a stored `{token, gameId, seat}` skips this module entirely. + * + * MIRRORS SERVER TYPES RATHER THAN IMPORTING THEM, same choice `web/session.ts` already made for + * `Push`: this file must never depend on anything under `src/server/`, even at the type level, since + * it ships to the browser and the server does not. + */ + +import type { GameConfig, PlayerIndex } from '../engine/state.ts'; +import { defaultMultiplayerConfig } from './game.ts'; + +export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex }; + +type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null; +type Lobby = { + gameId: string; + gameCode: string; + hostToken: string; + config: GameConfig; + seats: LobbySeat[]; + joinOrder: string[]; + createdAt: number; +}; +type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean }; + +/** Per-origin, same reasoning `lobby-and-sessions.md` §1 gives for the session token itself — a + * secret typed at one address means nothing at another. */ +const SECRET_KEY = 'stationmaster-joinsecret'; + +const $ = (id: string): T => document.getElementById(id) as T; + +async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record }> { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +/** + * Shows `#lobby`, drives it through creating or joining a game and then seating, and calls + * `onReady` exactly once — the instant `Lobby.Start` fires, from WHICHEVER browser tab started it. + * Never calls back more than once; the caller is expected to tear this screen down (`main.ts` hides + * `#lobby` and shows `#gameui`) as its very first action inside `onReady`. + */ +export function runLobby(onReady: (r: LobbyReady) => void): void { + $('lobby').hidden = false; + $('lb-secret').value = localStorage.getItem(SECRET_KEY) ?? ''; + + let source: EventSource | null = null; + + function setError(id: string, message: string): void { + $(id).textContent = message; + } + + function secret(): string { + const value = $('lb-secret').value; + localStorage.setItem(SECRET_KEY, value); + return value; + } + + function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void { + $('lb-gamecode').textContent = `— code ${lobby.gameCode}`; + const isHost = lobby.hostToken === token; + const cap = lobby.config.mode === 'solitaire' ? 1 : 4; + + let html = ''; + for (let seat = 0; seat < cap; seat++) { + const occupant = lobby.seats[seat] ?? null; + const isYou = occupant?.kind === 'human' && occupant.token === token; + const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken; + const who = + occupant === null + ? '— empty —' + : occupant.kind === 'bot' + ? 'Bot' + : `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`; + let action = ''; + if (isHost) { + if (occupant === null) action = ``; + else if (occupant.kind === 'bot') action = ``; + } + html += `
Seat ${seat}${who}${action}
`; + } + $('lb-seats').innerHTML = html; + + for (const btn of Array.from($('lb-seats').querySelectorAll('.lb-bot-add'))) { + btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true }); + } + for (const btn of Array.from($('lb-seats').querySelectorAll('.lb-bot-remove'))) { + btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false }); + } + + const filled = lobby.seats.filter((s) => s !== null).length; + const noGaps = filled === lobby.seats.length; + const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4; + const startBtn = $('lb-start'); + startBtn.hidden = !isHost; + startBtn.disabled = !(noGaps && legalCount); + $('lb-start-note').textContent = isHost + ? noGaps && legalCount + ? '' + : 'Needs 2–4 seated players (human or bot), no empty seats in between.' + : 'Waiting for the host to start the game.'; + startBtn.onclick = () => { + void postJson('/api/lobby/start', { token }).then(({ status, body }) => { + if (status !== 200) setError('lb-start-note', String(body['error'] ?? 'could not start')); + }); + }; + } + + function enterSeating(gameId: string, token: string): void { + $('lb-choice-section').hidden = true; + $('lb-seating-section').hidden = false; + source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`); + source.onmessage = (ev: MessageEvent) => { + const push = JSON.parse(ev.data) as LobbyPush; + if (push.started) { + source?.close(); + onReady({ token, gameId, seat: push.you }); + return; + } + renderSeating(push.lobby, push.you, token); + }; + } + + $('lb-create').onclick = () => { + const displayName = $('lb-name').value.trim(); + const mode = ($('lb-choice-section').querySelector('input[name="lb-mode"]:checked')?.value ?? + 'competitive') as 'competitive' | 'coop'; + if (displayName === '') { + setError('lb-create-err', 'enter a display name first'); + return; + } + void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then( + ({ status, body }) => { + if (status !== 200) { + setError('lb-create-err', String(body['error'] ?? 'could not create the game')); + return; + } + setError('lb-create-err', ''); + enterSeating(body['gameId'] as string, body['token'] as string); + }, + ); + }; + + $('lb-join').onclick = () => { + const displayName = $('lb-name').value.trim(); + const gameCode = $('lb-code').value.trim(); + if (displayName === '' || gameCode === '') { + setError('lb-join-err', 'enter a display name and a game code'); + return; + } + void postJson('/api/lobby/join', { secret: secret(), gameCode, displayName }).then(({ status, body }) => { + if (status !== 200) { + setError('lb-join-err', String(body['error'] ?? 'could not join that game')); + return; + } + setError('lb-join-err', ''); + enterSeating(body['gameId'] as string, body['token'] as string); + }); + }; +} diff --git a/src/web/main.ts b/src/web/main.ts index c10e35f..ba77064 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -25,9 +25,18 @@ import type { NewGameOptions } from './game.ts'; import type { LocalSession, Session } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts'; import type { PlayerIndex } from '../engine/state.ts'; +import { runLobby } from './lobby.ts'; +import type { LobbyReady } from './lobby.ts'; const SAVE_KEY = 'station-master.save.v1'; const SETTINGS_KEY = 'station-master.settings.v1'; +/** + * The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh + * `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from + * `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between + * browsers; this is a credential for THIS origin's server and must never be treated as one. + */ +const REMOTE_KEY = 'station-master.remote.v1'; /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; @@ -298,27 +307,58 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s return `?${params}`; } +function loadRemote(): LobbyReady | null { + try { + const raw = localStorage.getItem(REMOTE_KEY); + return raw ? (JSON.parse(raw) as LobbyReady) : null; + } catch { + return null; + } +} + +/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4) + * and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither + * ever flashes before `start()` decides which one this load actually needs. */ +function showScreen(which: 'lobby' | 'gameui'): void { + document.getElementById('lobby')!.hidden = which !== 'lobby'; + document.getElementById('gameui')!.hidden = which !== 'gameui'; +} + /** - * `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and - * URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from - * whatever the server was configured with, not from this browser's URL or `localStorage`. + * The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s + * `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an + * earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1), + * so it is always written back here before anything else happens. + */ +function beginRemote(ready: LobbyReady): void { + localStorage.setItem(REMOTE_KEY, JSON.stringify(ready)); + showScreen('gameui'); + session = createRemoteSession(ready.token, ready.seat); + applyCapabilities(); + // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first + // real Frame only exists once the SSE connection's first push arrives, so the first render waits + // for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on + // `createRemoteSession` explains why `view()` would otherwise throw). + session.subscribe(render); +} + +/** + * NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through + * `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL + * cannot produce one. `start()`'s job is only to decide which of three screens this load is: back + * into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the + * common case and the only one a bare page load has ever needed a decision for), or the lobby. */ function start(): void { const params = new URLSearchParams(location.search); - const seatParam = params.get('seat'); - if (seatParam !== null) { - const seat = Number(seatParam) as PlayerIndex; - session = createRemoteSession(seat, params.get('secret') ?? ''); - applyCapabilities(); - // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first - // real Frame only exists once the SSE connection's first push arrives, so the first render waits - // for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on - // `createRemoteSession` explains why `view()` would otherwise throw). - session.subscribe(render); + const remembered = loadRemote(); + if (remembered) { + beginRemote(remembered); return; } + showScreen('gameui'); const requested = params.get('seed'); // A seed in the URL makes a game shareable and reproducible: same link, same deal. const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); @@ -354,6 +394,23 @@ function applyCapabilities(): void { hide('undo', c.undo); hide('savefile', c.saveLocal); hide('newgame', c.newGame); + // Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this + // page offers — same reasoning as `newgame`, and the same capability answers both. + hide('multiplayer', c.newGame); +} + +/** + * `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table + * has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never + * has anything to report), and empty again the moment everyone reports back in — `#presence:empty` + * collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read. + */ +function renderPresence(f: Frame): void { + const away = session + .presence() + .filter((p) => !p.connected) + .map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`); + $('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`; } function render(): void { @@ -379,6 +436,7 @@ function render(): void { } renderTurnChart(f); + renderPresence(f); $('revenue').textContent = String(f.revenue); /** * THE OBJECTIVE, WITHOUT THE COMMENTARY. @@ -1180,6 +1238,20 @@ if (saveBtn) saveBtn.onclick = downloadSave; * Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would * deal the same game again and look like the button had done nothing. */ +const multiplayerBtn = document.getElementById('multiplayer'); +if (multiplayerBtn) { + multiplayerBtn.onclick = () => { + // Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same + // reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session. + if (!isLocal(session)) return; + const f = session.view(); + const started = f.status === 'active' && (f.day > 1 || f.stage > 1); + if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return; + showScreen('lobby'); + runLobby(beginRemote); + }; +} + const newBtn = document.getElementById('newgame'); const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null; if (newBtn && dlg) { diff --git a/src/web/play.html b/src/web/play.html index fbe5457..78491a3 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -73,6 +73,14 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p @media(max-width:1100px){main{grid-template-columns:1fr}} section{background:var(--panel);border:1px solid var(--line);border-radius:7px; padding:10px 12px;margin-bottom:12px} +#lobby{max-width:640px;margin:0 auto;padding:14px} +#lobby h2{margin-top:0} +#lobby h3{margin-bottom:2px} +.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)} +.lb-seat:last-child{border-bottom:none} +.lb-seat .who{flex:1} +#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide} +#presence:empty{display:none} /* division strip */ #division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px} .node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto} @@ -210,6 +218,50 @@ ul.blocked li{padding:2px 0} + + + +