v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection

A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.

The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.

Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.

Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.

Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.

Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.

Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
This commit is contained in:
Jesse
2026-08-21 05:25:47 -04:00
parent c3c5cbfeec
commit e76bd77099
17 changed files with 1420 additions and 125 deletions
+66
View File
@@ -19,6 +19,72 @@ page as `v0.1.0 · <sha> · <date>`, 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
+60 -1
View File
@@ -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/<gameId>/`) 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
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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",
+278 -64
View File
@@ -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<string, GameSession>;
initialLobbies: Map<string, Lobby>;
initialSessions: Map<string, PlayerSession>;
};
const MIME: Record<string, string> = {
@@ -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<PlayerIndex, ServerResponse>();
const eventIds = new Map<PlayerIndex, number>();
const games = opts.initialGames;
const lobbies = opts.initialLobbies;
const sessions = opts.initialSessions;
const gameCodes = new Map<string, string>(); // 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<string, Map<PlayerIndex, ServerResponse>>();
const gameEventIds = new Map<string, Map<PlayerIndex, number>>();
const lobbyConnections = new Map<string, Map<string, ServerResponse>>();
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<PlayerIndex, number>();
const id = (ids.get(seat) ?? 0) + 1;
ids.set(seat, id);
gameEventIds.set(gameId, ids);
return id;
}
function broadcast(pushes: Map<PlayerIndex, Push>): void {
for (const [seat, push] of pushes) writeSse(seat, push);
function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): 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<void> {
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<void> {
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<string, ServerResponse>();
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<PlayerIndex, ServerResponse>();
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;
}
+42 -11
View File
@@ -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<string, GameSession>();
const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>();
const loaded = await loadGame(dataDir, engineVersion);
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) {
initialSession = resumeSession(loaded.saved);
console.log(`Resumed a saved game from ${dataDir} (${loaded.saved.history.length} intents replayed).`);
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 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.`,
`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.`,
);
+167
View File
@@ -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 };
}
+97 -10
View File
@@ -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/<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, 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<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);
}
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 [];
}
}
+76 -5
View File
@@ -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<PlayerIndex>,
): GameSession {
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
@@ -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));
}
+27
View File
@@ -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.
+169
View File
@@ -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 $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
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<string, unknown> };
}
/**
* 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;
$<HTMLInputElement>('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 = $<HTMLInputElement>('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
? '<span class="dim">— empty —</span>'
: occupant.kind === 'bot'
? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
let action = '';
if (isHost) {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
}
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`;
}
$('lb-seats').innerHTML = html;
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.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<HTMLButtonElement>('.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 = $<HTMLButtonElement>('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<string>) => {
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);
};
}
$<HTMLButtonElement>('lb-create').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('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);
},
);
};
$<HTMLButtonElement>('lb-join').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const gameCode = $<HTMLInputElement>('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);
});
};
}
+83 -11
View File
@@ -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}`;
}
/**
* `?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`.
*/
function start(): void {
const params = new URLSearchParams(location.search);
const seatParam = params.get('seat');
function loadRemote(): LobbyReady | null {
try {
const raw = localStorage.getItem(REMOTE_KEY);
return raw ? (JSON.parse(raw) as LobbyReady) : null;
} catch {
return null;
}
}
if (seatParam !== null) {
const seat = Number(seatParam) as PlayerIndex;
session = createRemoteSession(seat, params.get('secret') ?? '');
/** 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';
}
/**
* 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 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) {
+60 -1
View File
@@ -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}
</head>
<body>
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
`#newgamedlg` at the very end of the body is solitaire-only and untouched by any of this. -->
<div id="lobby" hidden>
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
<section id="lb-secret-section">
<h2>Join secret</h2>
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
</section>
<section id="lb-choice-section">
<h2>Create or join a game</h2>
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
<h3>Create a new game</h3>
<p class="ng-note">You become the host — you choose the mode and, once everyone's seated, start the game. 2 to 4 players.</p>
<label class="ng-radio"><input type="radio" name="lb-mode" value="competitive" checked>
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p>
<h3>Join a game</h3>
<p class="ng-note">Ask whoever created the game for its code.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-join">Join game</button>
<p class="dim" id="lb-join-err" role="alert"></p>
</section>
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
<section id="lb-seating-section" hidden>
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2>
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p>
<div id="lb-seats"></div>
<button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p>
</section>
</div>
<div id="gameui" hidden>
<div class="topbar">
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
@@ -233,6 +285,7 @@ ul.blocked li{padding:2px 0}
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -247,6 +300,10 @@ ul.blocked li{padding:2px 0}
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<div id="announce"></div>
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
<div id="presence"></div>
<main>
<div>
@@ -314,6 +371,8 @@ ul.blocked li{padding:2px 0}
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
==================================================================== -->
</div><!-- /gameui -->
<dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
@@ -365,7 +424,7 @@ ul.blocked li{padding:2px 0}
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Multiplayer needs a server — coming soon.</span>
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>
+37 -7
View File
@@ -93,6 +93,15 @@ export type Session = {
takeAnnouncement(): string | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
/**
* Which OTHER seats are currently connected, as last reported by the server — always empty for a
* `LocalSession` (there is nobody else to track). `lobby-and-sessions.md` §5: a disconnect keeps
* the seat and waits; this is what lets the page say why, instead of going quiet with no
* explanation. Reflects the last presence push for each seat the server has ever mentioned, not
* only the ones currently disconnected — a seat that reconnects updates its own entry rather than
* disappearing, so the page can tell "never heard from" apart from "was here, then left."
*/
presence(): { seat: PlayerIndex; connected: boolean }[];
};
/**
@@ -163,6 +172,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
return text;
},
justDrawn: () => game.justDrawn,
presence: () => [],
seed: () => game.seed,
save: () => toSave(game),
@@ -191,36 +201,55 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
* so this file never depends on anything under `src/server/` even at the type level. */
type Push = { frame: FrameDelta; menu: Menu | null; lines: { text: string; tone: string }[] };
type Push = {
frame?: FrameDelta;
menu: Menu | null;
lines: { text: string; tone: string }[];
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
* A session backed by a server (Phase 2). Holds no authoritative state — no deck order, no other
* seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities` are all `false`:
* undo would have to un-see what other players already saw, a local save is meaningless when the
* server is the store, and dealing a new game is the lobby's job (Phase 4).
* A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
* order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
* are all `false`: undo would have to un-see what other players already saw, a local save is
* meaningless when the server is the store, and dealing a new game is the lobby's job.
*
* `token` is `lobby-and-sessions.md` §1's session token, issued at `Lobby.Create`/`Lobby.Join` and
* stored by the caller (`main.ts`, in `localStorage`) — it alone proves identity to `/api/stream`
* and `/api/intent`, so this function no longer takes a join secret at all; that gate belongs to
* the lobby endpoints only. `seat` still has to be passed in rather than learned from a push,
* because the very FIRST thing this session needs — `seat()` — has to answer before any push has
* necessarily arrived; the caller already knows it from the join/create/start response.
*
* Construction is synchronous (the `Session` interface has no async surface), but the first real
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
*/
export function createRemoteSession(seat: PlayerIndex, secret: string): Session {
export function createRemoteSession(token: string, seat: PlayerIndex): Session {
let frame: Frame | null = null;
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, boolean>();
let nextSeq = 1;
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
const qs = `seat=${seat}&secret=${encodeURIComponent(secret)}`;
const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`);
source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push;
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
// per `session.ts`'s `Push`) updates the board or the menu.
if (push.frame) {
frame = applyDelta(frame, push.frame);
menu = push.menu;
}
lines = [...lines, ...push.lines];
if (push.presence) presence.set(push.presence.seat, push.presence.connected);
changed();
};
@@ -259,5 +288,6 @@ export function createRemoteSession(seat: PlayerIndex, secret: string): Session
takeScheduled: () => null,
takeAnnouncement: () => null,
justDrawn: () => null,
presence: () => [...presence].map(([s, connected]) => ({ seat: s, connected })),
};
}
+206
View File
@@ -0,0 +1,206 @@
/**
* The lobby (`src/server/lobby.ts`) — pure logic, no sockets, no filesystem, exercised directly.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { GameConfig } from '../../src/engine/state.ts';
import {
createLobby,
freshGameCode,
joinLobby,
playerCountAllowed,
reassignHost,
setBotSeat,
startLobby,
} from '../../src/server/lobby.ts';
const competitive: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
describe('creating and joining', () => {
it('the creator is the host, takes seat 0, and is first in join order', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001');
assert.equal(session.player, 0);
assert.equal(lobby.hostToken, session.token);
assert.equal(lobby.seats.length, 1);
assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' });
assert.deepEqual(lobby.joinOrder, [session.token]);
});
it('fills the next empty seat, in order', () => {
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001');
const j2 = joinLobby(l1, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
assert.equal(j2.session.player, 1);
const j3 = joinLobby(j2.lobby, 'Carol');
assert.ok(j3.ok);
if (!j3.ok) return;
assert.equal(j3.session.player, 2);
assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]);
});
it('refuses a 5th join to a competitive lobby (cap 4)', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001').lobby;
for (const name of ['Bob', 'Carol', 'Dave']) {
const r = joinLobby(lobby, name);
assert.ok(r.ok);
if (r.ok) lobby = r.lobby;
}
const fifth = joinLobby(lobby, 'Eve');
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
});
it('refuses a 2nd join to a solitaire lobby (cap 1)', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002');
const second = joinLobby(lobby, 'Bob');
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
});
it('rejoins into a seat an earlier player vacated, not past the end', () => {
// Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat)
// is exactly as joinable as one nobody ever filled.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003').lobby;
lobby = setBotSeat(lobby, 1, true);
lobby = setBotSeat(lobby, 1, false);
const r = joinLobby(lobby, 'Bob');
assert.ok(r.ok);
if (!r.ok) return;
assert.equal(r.session.player, 1, 'should take the reopened seat 1, not append at seat 1 anyway by coincidence — check seat 2 stays empty');
assert.equal(r.lobby.seats.length, 2);
});
});
describe('bot seats', () => {
it('fills only an empty seat, and clears only a bot seat', () => {
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004');
const withBot = setBotSeat(l0, 1, true);
assert.deepEqual(withBot.seats[1], { kind: 'bot' });
// Filling an already-bot seat again is a no-op, not a crash.
const stillBot = setBotSeat(withBot, 1, true);
assert.deepEqual(stillBot.seats[1], { kind: 'bot' });
// Clearing seat 0 (a human) does nothing — a host is removed by leaving, not overwritten.
const untouched = setBotSeat(withBot, 0, false);
assert.deepEqual(untouched.seats[0], l0.seats[0]);
const cleared = setBotSeat(withBot, 1, false);
assert.equal(cleared.seats[1], null);
});
});
describe('host transfer', () => {
it('passes to the earliest-joined remaining human seat when the host departs', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005').lobby;
const hostToken = lobby.hostToken;
const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
lobby = j2.lobby;
// Alice's own seat is cleared (simulating her leaving the table entirely, not just dropping
// her connection) so the transfer target is unambiguous in this test.
lobby = { ...lobby, seats: [null, lobby.seats[1]!] };
const after = reassignHost(lobby, hostToken);
assert.equal(after.hostToken, j2.session.token);
});
it('does nothing when the departing token is not the host', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006');
const after = reassignHost(lobby, 'not-a-real-token');
assert.equal(after, lobby);
});
it('leaves hostToken alone when no other human seat exists', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007');
const after = reassignHost(lobby, session.token);
assert.equal(after.hostToken, session.token);
});
});
describe('starting', () => {
it('refuses a non-host caller', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008');
joinLobby(lobby, 'Bob');
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
});
it('refuses to start with a gap in the seats', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0009').lobby;
const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
const j3 = joinLobby(j2.lobby, 'Carol');
assert.ok(j3.ok);
if (!j3.ok) return;
lobby = { ...j3.lobby, seats: [j3.lobby.seats[0]!, null, j3.lobby.seats[2]!] };
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('refuses a solo human in a competitive lobby (needs 2-4)', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010');
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011').lobby;
lobby = setBotSeat(lobby, 1, true);
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] });
});
it('starts a solitaire lobby of exactly 1', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012');
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] });
});
});
describe('playerCountAllowed', () => {
it('solitaire is exactly 1', () => {
assert.equal(playerCountAllowed('solitaire', 1), true);
assert.equal(playerCountAllowed('solitaire', 0), false);
assert.equal(playerCountAllowed('solitaire', 2), false);
});
it('competitive and coop are 2-4', () => {
for (const mode of ['competitive', 'coop'] as const) {
assert.equal(playerCountAllowed(mode, 1), false);
assert.equal(playerCountAllowed(mode, 2), true);
assert.equal(playerCountAllowed(mode, 4), true);
assert.equal(playerCountAllowed(mode, 5), false);
}
});
});
describe('game codes', () => {
it('skips codes the caller marks taken', () => {
let calls = 0;
const code = freshGameCode((c) => {
calls++;
return calls < 3; // taken twice, free on the third
});
assert.equal(typeof code, 'string');
assert.equal(calls, 3);
});
it('is speakable — a word, a dash, four digits', () => {
const code = freshGameCode(() => false);
assert.match(code, /^[A-Z]+-\d{4}$/);
});
});
+1
View File
@@ -30,6 +30,7 @@ const saved: SavedGame = {
history: [{ type: 'localOps.choose', option: 'draw' }],
status: 'active',
createdAt: 1000,
botSeats: [],
};
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
+39 -3
View File
@@ -28,8 +28,8 @@ describe('the game session host', () => {
it('gives a fresh connect a full Frame — nothing nulled', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
assert.notEqual(push.frame.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame.division, null, 'a first connect nulled the division');
assert.notEqual(push.frame!.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame!.division, null, 'a first connect nulled the division');
});
it('only the current actor gets a real Menu; every other seat gets null', () => {
@@ -100,7 +100,7 @@ describe('the game session host', () => {
// Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled
// on this push for a seat that already had them from `connect`.
const push = result.pushes.get(actor)!;
assert.equal(push.frame.division, null, 'the board was resent even though nothing on it changed');
assert.equal(push.frame!.division, null, 'the board was resent even though nothing on it changed');
});
it('only sends narration NEW since the last push to that specific seat', () => {
@@ -159,6 +159,42 @@ describe('turn timings (lobby-and-sessions.md §5)', () => {
});
});
describe('bot seats (Phase 4 — D8, lobby-and-sessions.md §2)', () => {
it('a bot never becomes the observable current actor — it plays before anyone can see it waiting', () => {
// Seat 0 acts first (the opening Superintendent), so marking it a bot exercises `driveBots()`
// at CONSTRUCTION time — before any external `intent()` has run at all.
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
assert.equal(session.isBot(0 as PlayerIndex), true);
assert.equal(session.isBot(1 as PlayerIndex), false);
const botPush = session.connect(0 as PlayerIndex);
const humanPush = session.connect(1 as PlayerIndex);
assert.equal(botPush.menu, null, 'the bot seat was handed a real decision to make');
assert.notEqual(humanPush.menu, null, 'nobody was left with a turn to take — the bot never played');
});
it('botSeats round-trips through exportSave/resumeSession', () => {
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
const resumed = resumeSession(session.exportSave());
assert.equal(resumed.isBot(0 as PlayerIndex), true);
assert.equal(resumed.connect(0 as PlayerIndex).menu, null, 'a resumed bot seat still never gets a real decision');
});
it('a bot seat is driven forward after a human intent too, not only at construction', () => {
// Two bots and one human: whichever of the two non-human seats comes up next after the human's
// own move must be played automatically, with no external `intent()` for either of them.
const session = createSession(11, config, ['Alice', 'Bot', 'Bot'], [1 as PlayerIndex, 2 as PlayerIndex]);
const before = session.exportSave().history.length;
const applied = session.intent(0 as PlayerIndex, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(applied.accepted, true);
assert.equal(session.connect(1 as PlayerIndex).menu, null, 'bot seat 1 was left with a real decision');
assert.equal(session.connect(2 as PlayerIndex).menu, null, 'bot seat 2 was left with a real decision');
// Not a strict proof either bot actually moved (the human's own turn may not have ended yet),
// but the history can only ever have grown, never shrunk, and never rejected mid-drive.
assert.ok(session.exportSave().history.length >= before + 1);
});
});
describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
it('exportSave carries enough to reconstruct the exact same game', () => {
const session = createSession(42, config, ['Alice', 'Bob']);