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 ## 0.5.0 — 2026-08-21
Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one 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 **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 wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken. 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: 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 - `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 restarted, server logged the refusal and started with no active game (confirmed via `POST
/api/game` succeeding rather than 409ing). /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 ## 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. | | **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. | | **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. | | **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. | | **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. | | **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", "name": "station-master",
"version": "0.5.0", "version": "0.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "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 * 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 * (`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 * reaching for a bundler — this matches that everywhere-else choice rather than introducing the
* first framework dependency for one route table. * first framework dependency for one route table.
* *
* All the game logic lives in `session.ts`; this file is deliberately thin — routing, the join-secret * All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing,
* gate, SSE mechanics, and static file serving for the built client (`dist/`, D16: the server serves * the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE
* the client, which is what makes same-origin work with no CORS). * 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'; 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 { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.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 { createSession } from './session.ts';
import type { GameSession, Push } 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 = { export type ServerOptions = {
port: number; port: number;
bindAddress: string; 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; joinSecret: string;
/** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */ /** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */
distDir: string; 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; dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string; engineVersion: string;
/** Already reconstructed by `index.ts`'s load-on-start, or `null` for a fresh server. */ /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
initialSession: GameSession | null; initialGames: Map<string, GameSession>;
initialLobbies: Map<string, Lobby>;
initialSessions: Map<string, PlayerSession>;
}; };
const MIME: Record<string, string> = { 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 { export function startServer(opts: ServerOptions): void {
let session: GameSession | null = opts.initialSession; const games = opts.initialGames;
// One open SSE response per seat — a second connection from the same seat replaces the first const lobbies = opts.initialLobbies;
// rather than fanning out to both (Phase 2 has no concept of "the same seat from two tabs"). const sessions = opts.initialSessions;
const connections = new Map<PlayerIndex, ServerResponse>(); const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
const eventIds = new Map<PlayerIndex, number>(); for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
function checkSecret(url: URL, res: ServerResponse): boolean { // One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a
if (url.searchParams.get('secret') === opts.joinSecret) return true; // lobby still being seated — a second connection from the same seat/token replaces the first
sendJson(res, 403, { error: 'bad or missing secret' }); // rather than fanning out to both (no concept yet of "the same seat from two tabs").
return false; 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 { function nextEventId(gameId: string, seat: PlayerIndex): number {
const res = connections.get(seat); const ids = gameEventIds.get(gameId) ?? new Map<PlayerIndex, number>();
if (!res) return; // that seat is not currently connected — Phase 4's reconnect story, not this one const id = (ids.get(seat) ?? 0) + 1;
const id = (eventIds.get(seat) ?? 0) + 1; ids.set(seat, id);
eventIds.set(seat, id); gameEventIds.set(gameId, ids);
res.write(`id: ${id}\ndata: ${JSON.stringify(push)}\n\n`); return id;
} }
function broadcast(pushes: Map<PlayerIndex, Push>): void { function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): void {
for (const [seat, push] of pushes) writeSse(seat, push); 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) => { const server = createServer((req, res) => {
void (async () => { void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
if (url.pathname === '/api/game' && req.method === 'POST') { // -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (!checkSecret(url, res)) return;
if (session) { if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
sendJson(res, 409, { error: 'a game already exists on this server' }); 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; return;
} }
const body = (await readJson(req)) as { config?: GameConfig; playerNames?: string[]; seed?: number }; if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
if (!body.config || !Array.isArray(body.playerNames) || body.playerNames.length < 1) { sendJson(res, 400, { error: 'expected { secret, config, displayName }' });
sendJson(res, 400, { error: 'expected { config, playerNames }' });
return; return;
} }
session = createSession(body.seed ?? Math.floor(Math.random() * 1e9), body.config, body.playerNames); const gameCode = freshGameCode((code) => gameCodes.has(code));
// Persisted immediately, empty history and all — a crash one second after creation should const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode);
// still resume as "the game exists, day 1, nobody has moved" rather than vanish entirely. await persistLobby(lobby);
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion); await persistSession(session);
sendJson(res, 200, { ok: true, playerCount: session.playerCount }); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
return; return;
} }
if (url.pathname === '/api/stream' && req.method === 'GET') { if (url.pathname === '/api/lobby/join' && req.method === 'POST') {
if (!checkSecret(url, res)) return; const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string };
if (!session) { if (body.secret !== opts.joinSecret) {
sendJson(res, 404, { error: 'no game yet' }); sendJson(res, 403, { error: 'bad or missing secret' });
return; return;
} }
const seat = Number(url.searchParams.get('seat')) as PlayerIndex; if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) { sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' });
sendJson(res, 400, { error: 'bad or missing ?seat=' });
return; return;
} }
res.writeHead(200, { const gameId = gameCodes.get(body.gameCode.trim().toUpperCase());
'Content-Type': 'text/event-stream', const lobby = gameId ? lobbies.get(gameId) : undefined;
'Cache-Control': 'no-cache', if (!lobby) {
Connection: 'keep-alive', // 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); return;
writeSse(seat, session.connect(seat)); }
// -- 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 // 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 // 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. // real event (EventSource ignores lines starting with `:`), so it costs the client nothing.
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
req.on('close', () => { req.on('close', () => {
clearInterval(heartbeat); 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; return;
} }
if (url.pathname === '/api/intent' && req.method === 'POST') { if (url.pathname === '/api/intent' && req.method === 'POST') {
if (!checkSecret(url, res)) return; // Token comes from the QUERY STRING, matching `/api/stream` and matching what
if (!session) { // `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`)
sendJson(res, 404, { error: 'no game yet' }); // — the body carries only what changes per call, `{ seq, intent }`.
return; const token = url.searchParams.get('token') ?? '';
} const ps = sessions.get(token);
const seat = Number(url.searchParams.get('seat')) as PlayerIndex; const session = ps ? games.get(ps.gameId) : undefined;
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) { if (!ps || !session) {
sendJson(res, 400, { error: 'bad or missing ?seat=' }); sendJson(res, 404, { error: 'no such game' });
return; return;
} }
const body = (await readJson(req)) as { seq?: number; intent?: Intent }; 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 }' }); sendJson(res, 400, { error: 'expected { seq, intent }' });
return; return;
} }
const result = session.intent(seat, body.seq, body.intent); const result = session.intent(ps.player, body.seq, body.intent);
if (result.accepted) { if (result.accepted) {
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at // Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
// this scale, not just "applied in memory" (§12 step 14). // this scale, not just "applied in memory" (§12 step 14).
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion); const dir = gameDir(opts.dataDir, ps.gameId);
if (result.timing) await appendTiming(opts.dataDir, result.timing); 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 }); 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; 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 * Run with: node src/server/index.ts
* *
@@ -11,9 +12,10 @@ import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { startServer } from './http.ts'; 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 { resumeSession } from './session.ts';
import type { GameSession } from './session.ts'; import type { GameSession } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081); const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0'; 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 root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version; 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) { if (loaded.found && loaded.ok) {
initialSession = resumeSession(loaded.saved); initialGames.set(entry.gameId, resumeSession(loaded.saved));
console.log(`Resumed a saved game from ${dataDir} (${loaded.saved.history.length} intents replayed).`); console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
} else if (loaded.found && !loaded.ok) { } else if (loaded.found && !loaded.ok) {
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded // 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. // under. The file is left untouched: rolling the running version back would let it load again.
console.error( console.error(
`Refusing to load ${dataDir}/game.json: it was saved under engine version ` + `Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
`${loaded.storedVersion}, this server is running ${engineVersion}. Starting with no active ` + `${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
`game. The saved file has not been touched.`, `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 }); startServer({
console.log(`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir}`); 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; * Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for
* `lobby-and-sessions.md` §6 specifies the exact shape and reasoning). * 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 * ONE DIRECTORY PER GAME (`games/<gameId>/`), plus one top-level `index.json` naming every game so
* `gameId`, both deferred to Phase 4's multi-game generalization same as the server core deferred * `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is
* them. Every write is a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) * still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true
* rather than true on-disk appending: §6 says the storage mechanism is genuinely open as long as the * on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history
* LOGICAL history is never rewritten or reordered, which a full rewrite of an always-growing array * is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at
* satisfies — and at the measured scale (~350 intents, a few hundred bytes per game) there is nothing * the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
* 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 { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts'; import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const GAME_FILE = 'game.json'; const GAME_FILE = 'game.json';
const TIMINGS_FILE = 'turn-timings.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 }; type PersistedGame = SavedGame & { engineVersion: string };
@@ -67,3 +71,86 @@ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise
existing.push(timing); existing.push(timing);
await atomicWrite(path, JSON.stringify(existing, null, 1)); 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 { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.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 type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts'; import { snapshot } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = { 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). */ /** 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; menu: Menu | null;
/** Narration since the LAST push to this specific seat, not the whole game's log. */ /** Narration since the LAST push to this specific seat, not the whole game's log. */
lines: { text: string; tone: string }[]; 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[]; history: Intent[];
status: 'active' | 'finished'; status: 'active' | 'finished';
createdAt: number; 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 = export type IntentResult =
@@ -67,6 +90,7 @@ export type IntentResult =
export type GameSession = { export type GameSession = {
readonly playerCount: number; readonly playerCount: number;
isBot(seat: PlayerIndex): boolean;
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */ /** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
connect(seat: PlayerIndex): Push; connect(seat: PlayerIndex): Push;
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; 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 }; 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 lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>(); const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>(); const sentLines = new Map<PlayerIndex, number>();
@@ -137,8 +166,40 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
return closed; 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 { return {
playerCount: playerNames.length, playerCount: playerNames.length,
isBot: (seat) => botSeats.has(seat),
connect(seat) { connect(seat) {
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection // 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); lastSeq.set(seat, seq);
const timing = settleTiming(); 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 }; return { accepted: true, pushes: pushesForAll(), timing };
}, },
@@ -180,13 +245,19 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
history: [...game.history], history: [...game.history],
status: game.state.status === 'finished' ? 'finished' : 'active', status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt, createdAt,
botSeats: [...botSeats],
}; };
}, },
}; };
} }
export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession { export function createSession(
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now()); 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 { export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); 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, 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 * 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. * 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 type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.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 SAVE_KEY = 'station-master.save.v1';
const SETTINGS_KEY = 'station-master.settings.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. */ /** 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; 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}`; return `?${params}`;
} }
/** function loadRemote(): LobbyReady | null {
* `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and try {
* URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from const raw = localStorage.getItem(REMOTE_KEY);
* whatever the server was configured with, not from this browser's URL or `localStorage`. return raw ? (JSON.parse(raw) as LobbyReady) : null;
*/ } catch {
function start(): void { return null;
const params = new URLSearchParams(location.search); }
const seatParam = params.get('seat'); }
if (seatParam !== null) { /** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
const seat = Number(seatParam) as PlayerIndex; * and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
session = createRemoteSession(seat, params.get('secret') ?? ''); * 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(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // 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 // 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 // for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw). // `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render); 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; return;
} }
showScreen('gameui');
const requested = params.get('seed'); const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal. // 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); const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
@@ -354,6 +394,23 @@ function applyCapabilities(): void {
hide('undo', c.undo); hide('undo', c.undo);
hide('savefile', c.saveLocal); hide('savefile', c.saveLocal);
hide('newgame', c.newGame); 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 { function render(): void {
@@ -379,6 +436,7 @@ function render(): void {
} }
renderTurnChart(f); renderTurnChart(f);
renderPresence(f);
$('revenue').textContent = String(f.revenue); $('revenue').textContent = String(f.revenue);
/** /**
* THE OBJECTIVE, WITHOUT THE COMMENTARY. * 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 * 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. * 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 newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null; const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) { 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}} @media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px; section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px} 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 strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px} #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} .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> </head>
<body> <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"> <div class="topbar">
<header> <header>
<b><a href="./index.html" class="home">Station Master</a></b> <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="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="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="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> <a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span> <span class="dim build" title="what is actually deployed">__BUILD__</span>
</header> </header>
@@ -247,6 +300,10 @@ ul.blocked li{padding:2px 0}
what you miss when the automatic phases run between two clicks. --> what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div> <div id="phasenote"></div>
<div id="announce"></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> <main>
<div> <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 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. 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"> <dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform"> <form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2> <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> <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"> <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="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button> <button value="deal" id="ng-deal" type="submit">Deal</button>
</menu> </menu>
+37 -7
View File
@@ -93,6 +93,15 @@ export type Session = {
takeAnnouncement(): string | null; takeAnnouncement(): string | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */ /** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null; 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; return text;
}, },
justDrawn: () => game.justDrawn, justDrawn: () => game.justDrawn,
presence: () => [],
seed: () => game.seed, seed: () => game.seed,
save: () => toSave(game), 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, /** 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. */ * 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 * A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
* seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities` are all `false`: * order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
* undo would have to un-see what other players already saw, a local save is meaningless when the * are all `false`: undo would have to un-see what other players already saw, a local save is
* server is the store, and dealing a new game is the lobby's job (Phase 4). * 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 * 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 * `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` * 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). * 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 frame: Frame | null = null;
let menu: Menu | null = null; let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = []; let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, boolean>();
let nextSeq = 1; let nextSeq = 1;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const changed = (): void => { const changed = (): void => {
for (const fn of [...listeners]) fn(); 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}`); const source = new EventSource(`/api/stream?${qs}`);
source.onmessage = (ev: MessageEvent<string>) => { source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push; 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); frame = applyDelta(frame, push.frame);
menu = push.menu; menu = push.menu;
}
lines = [...lines, ...push.lines]; lines = [...lines, ...push.lines];
if (push.presence) presence.set(push.presence.seat, push.presence.connected);
changed(); changed();
}; };
@@ -259,5 +288,6 @@ export function createRemoteSession(seat: PlayerIndex, secret: string): Session
takeScheduled: () => null, takeScheduled: () => null,
takeAnnouncement: () => null, takeAnnouncement: () => null,
justDrawn: () => 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' }], history: [{ type: 'localOps.choose', option: 'draw' }],
status: 'active', status: 'active',
createdAt: 1000, createdAt: 1000,
botSeats: [],
}; };
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> { 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', () => { it('gives a fresh connect a full Frame — nothing nulled', () => {
const session = createSession(42, config, ['Alice', 'Bob']); const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex); const push = session.connect(0 as PlayerIndex);
assert.notEqual(push.frame.cells, null, 'a first connect nulled the board'); 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!.division, null, 'a first connect nulled the division');
}); });
it('only the current actor gets a real Menu; every other seat gets null', () => { 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 // 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`. // on this push for a seat that already had them from `connect`.
const push = result.pushes.get(actor)!; 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', () => { 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)', () => { describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
it('exportSave carries enough to reconstruct the exact same game', () => { it('exportSave carries enough to reconstruct the exact same game', () => {
const session = createSession(42, config, ['Alice', 'Bob']); const session = createSession(42, config, ['Alice', 'Bob']);