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:
@@ -19,6 +19,72 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.5.1 — 2026-08-21
|
||||
|
||||
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
|
||||
17-20, fully specified in `docs/architecture/lobby-and-sessions.md`). Phases 0-3 shipped in v0.4.0
|
||||
and v0.5.0; a real server existed but nobody could reach it without a hand-built URL. This is what
|
||||
makes it a game you can actually create or join.
|
||||
|
||||
### The server hosts more than one game, and knows who you are across a reconnect
|
||||
|
||||
`src/server/lobby.ts` is new: pure logic, no sockets, no filesystem, the same split `session.ts`
|
||||
draws for a running game. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, plus a
|
||||
speakable game code (`RAIL-4471` style) and the player cap.
|
||||
|
||||
`persistence.ts` gained one directory per `gameId` and a top-level index, so `index.ts` resumes
|
||||
every saved game on boot, not just one. `/api/stream` and `/api/intent` now authenticate by session
|
||||
**token** instead of `?seat=&secret=` — `lobby-and-sessions.md` §1: the token alone proves identity,
|
||||
so the join secret's job ends at the lobby door (`/api/lobby/create`/`/api/lobby/join`).
|
||||
|
||||
### Bots fill empty seats, never take over a disconnected human (D8)
|
||||
|
||||
`session.ts` gained `driveBots()` — after any accepted intent, and once at construction for a
|
||||
resume that lands exactly on a bot's turn, it plays `developerBot` forward through every
|
||||
consecutive bot seat before the push goes out. Reuses `legalActions`/`developerBot` wholesale.
|
||||
`SavedGame` gained `botSeats` so a bot seat survives a restart. Bots are assigned once, at
|
||||
`Lobby.Start`, and never afterward — a disconnected human's seat waits, exactly as before.
|
||||
|
||||
### Disconnect keeps the seat and says so; reconnect gets a full view, not a tail
|
||||
|
||||
`Push` gained an optional `presence` field — connection news about another seat, built entirely by
|
||||
`http.ts` (which owns the connection table) and never routed through `session.ts` or the engine: a
|
||||
disconnect is transport news, not a `GameEvent`, and the engine must stay replayable from a seed.
|
||||
The page shows a small banner naming who has dropped and clears it the moment they reconnect.
|
||||
Host rights pass to the earliest-joined remaining player if the host's own connection drops before
|
||||
`Lobby.Start` — tracked by join order rather than seat, since a bot-filled seat never joined at all.
|
||||
|
||||
### The client: an actual lobby, not a URL you hand-build
|
||||
|
||||
`src/web/lobby.ts`, wired from `main.ts`: create-or-join forms, a live seating screen (host-only bot
|
||||
toggles and Start button, updated over its own SSE stream), and `localStorage` in place of `?seat=`
|
||||
for "was I already in a game" — found on load, it reconnects straight through and skips the lobby
|
||||
screen entirely. A `Multiplayer` button sits beside `New game`; the New Game dialog itself is
|
||||
untouched and still solitaire-only, its old "needs a server" note repointed at the new button.
|
||||
|
||||
### Found only by the live smoke test, not by typechecking
|
||||
|
||||
`/api/intent` read its token from the JSON body; `web/session.ts`'s `submit()` — unchanged since
|
||||
Phase 2 — sends it in the query string, the same as `/api/stream`. Every intent failed `no such
|
||||
game`. Both sides typecheck cleanly on their own (an HTTP body is `unknown` on the wire), which is
|
||||
exactly the gap a curl-level smoke test exists to catch: create a lobby, join a second player,
|
||||
start, submit from both seats (including a wrong-actor rejection and an idempotent resend),
|
||||
kill and restart the server and reconnect both tokens, start a bot-filled coop lobby and confirm it
|
||||
never stalls waiting on the bot, and watch a live stream receive a disconnect/reconnect presence
|
||||
notice for another seat.
|
||||
|
||||
### Doc fix
|
||||
|
||||
`multiplayer.md`'s decision table (D18) said the player cap was 6; `lobby-and-sessions.md` §2 —
|
||||
more detailed, and what `test/multiplayer.test.ts` actually exercises — says 2-4 with the reasoning
|
||||
for it. The two had quietly drifted apart; "6" was never implemented or tested anywhere. D18 now
|
||||
reads 2-4.
|
||||
|
||||
**Not verified: an actual browser** walking through the lobby screens — none is available in this
|
||||
environment, the same limitation Phase 2's `RemoteSession` shipped under. 656 tests, 0 failures.
|
||||
|
||||
---
|
||||
|
||||
## 0.5.0 — 2026-08-21
|
||||
|
||||
Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one
|
||||
|
||||
@@ -494,7 +494,7 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
|
||||
wired and read, and fire only against these cards. Until then those three are dormant by
|
||||
design rather than broken.
|
||||
- [x] **Multiplayer proper — Phases 0, 1 and 2 done (v0.4.0, 2026-08-20), Phases 3–6 to go.** The
|
||||
- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The
|
||||
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
|
||||
|
||||
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
|
||||
@@ -579,6 +579,65 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
restarted, server logged the refusal and started with no active game (confirmed via `POST
|
||||
/api/game` succeeding rather than 409ing).
|
||||
|
||||
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
|
||||
`lobby-and-sessions.md` in full:
|
||||
|
||||
- `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
|
||||
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
|
||||
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
|
||||
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
|
||||
(`playerCountAllowed`) — see the doc-fix note below.
|
||||
- **The server now holds more than one game.** `persistence.ts` gained one directory per
|
||||
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
|
||||
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
|
||||
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
|
||||
directory directly.
|
||||
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
|
||||
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
|
||||
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
|
||||
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
|
||||
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
|
||||
answer before any push necessarily arrives, and the caller already has it from the
|
||||
join/create/start response.
|
||||
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained
|
||||
`driveBots()`: after any accepted intent (and once at construction, for a resume that lands
|
||||
exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat
|
||||
before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic.
|
||||
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
|
||||
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
|
||||
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
|
||||
list rather than seat order, since a bot-filled seat has no join time of its own.
|
||||
- **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news
|
||||
about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never
|
||||
routed through `session.ts` or the engine, since a disconnect is transport news about a
|
||||
connection, not a `GameEvent`. The page shows a banner naming who has dropped
|
||||
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
|
||||
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
|
||||
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
|
||||
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
|
||||
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
|
||||
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
|
||||
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
|
||||
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
|
||||
new button.
|
||||
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
|
||||
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
|
||||
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
|
||||
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
|
||||
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
|
||||
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
|
||||
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
|
||||
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
|
||||
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
|
||||
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
|
||||
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
|
||||
second player, start, submit intents from both (including the wrong-actor rejection and an
|
||||
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
|
||||
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
|
||||
observed on an open stream. **Not verified: an actual browser** walking through the lobby
|
||||
screens — none is available in this environment, the same limitation Phase 2's `RemoteSession`
|
||||
shipped under.
|
||||
|
||||
---
|
||||
|
||||
## Other
|
||||
|
||||
@@ -338,7 +338,7 @@ all it needs.
|
||||
| **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. |
|
||||
| **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. |
|
||||
| **D17** | **Undo is solitaire-only** | Other players have seen the result. |
|
||||
| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. |
|
||||
| **D18** | **Player cap 2-4** for competitive/coop (solitaire is 1) | Per `lobby-and-sessions.md` §2 — sized to what is actually exercised (`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish), not to a guess. This entry read "6" until Phase 4 (v0.5.1); that number was never implemented or tested anywhere and the two docs had drifted apart. Raise it once somebody has played a bigger game and reported back, not before. |
|
||||
| **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. |
|
||||
| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. |
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+278
-64
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12).
|
||||
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12),
|
||||
* extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game.
|
||||
*
|
||||
* Plain `node:http`, no framework: the project has zero runtime dependencies
|
||||
* (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than
|
||||
* reaching for a bundler — this matches that everywhere-else choice rather than introducing the
|
||||
* first framework dependency for one route table.
|
||||
*
|
||||
* All the game logic lives in `session.ts`; this file is deliberately thin — routing, the join-secret
|
||||
* gate, SSE mechanics, and static file serving for the built client (`dist/`, D16: the server serves
|
||||
* the client, which is what makes same-origin work with no CORS).
|
||||
* All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing,
|
||||
* the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE
|
||||
* mechanics, and static file serving for the built client (`dist/`, D16: the server serves the
|
||||
* client, which is what makes same-origin work with no CORS).
|
||||
*
|
||||
* TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token
|
||||
* already proves "I am the player who was in this game," which is the only identity claim `/api/stream`
|
||||
* and `/api/intent` need — the join secret's job ends at the lobby door.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
@@ -19,23 +25,43 @@ import { extname, join, normalize } from 'node:path';
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { appendTiming, writeGame } from './persistence.ts';
|
||||
import {
|
||||
appendTiming,
|
||||
deleteLobby,
|
||||
gameDir,
|
||||
upsertIndexEntry,
|
||||
writeGame,
|
||||
writeLobby,
|
||||
writeSessions,
|
||||
} from './persistence.ts';
|
||||
import { createSession } from './session.ts';
|
||||
import type { GameSession, Push } from './session.ts';
|
||||
import {
|
||||
createLobby,
|
||||
freshGameCode,
|
||||
joinLobby,
|
||||
reassignHost,
|
||||
setBotSeat,
|
||||
startLobby,
|
||||
} from './lobby.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
export type ServerOptions = {
|
||||
port: number;
|
||||
bindAddress: string;
|
||||
/** D14 — a server-wide secret, passed out of band. Gates every `/api/*` route. */
|
||||
/** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door;
|
||||
* once a player is through it and holds a token, the token alone authenticates them. */
|
||||
joinSecret: string;
|
||||
/** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */
|
||||
distDir: string;
|
||||
/** Where `game.json`/`turn-timings.json` live (Phase 3). */
|
||||
/** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */
|
||||
dataDir: string;
|
||||
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
|
||||
engineVersion: string;
|
||||
/** Already reconstructed by `index.ts`'s load-on-start, or `null` for a fresh server. */
|
||||
initialSession: GameSession | null;
|
||||
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
|
||||
initialGames: Map<string, GameSession>;
|
||||
initialLobbies: Map<string, Lobby>;
|
||||
initialSessions: Map<string, PlayerSession>;
|
||||
};
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
@@ -82,92 +108,272 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta
|
||||
* mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this).
|
||||
*
|
||||
* `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is
|
||||
* closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream
|
||||
* simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry.
|
||||
*/
|
||||
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
|
||||
|
||||
export function startServer(opts: ServerOptions): void {
|
||||
let session: GameSession | null = opts.initialSession;
|
||||
// One open SSE response per seat — a second connection from the same seat replaces the first
|
||||
// rather than fanning out to both (Phase 2 has no concept of "the same seat from two tabs").
|
||||
const connections = new Map<PlayerIndex, ServerResponse>();
|
||||
const eventIds = new Map<PlayerIndex, number>();
|
||||
const games = opts.initialGames;
|
||||
const lobbies = opts.initialLobbies;
|
||||
const sessions = opts.initialSessions;
|
||||
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
|
||||
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
||||
|
||||
function checkSecret(url: URL, res: ServerResponse): boolean {
|
||||
if (url.searchParams.get('secret') === opts.joinSecret) return true;
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return false;
|
||||
// One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a
|
||||
// lobby still being seated — a second connection from the same seat/token replaces the first
|
||||
// rather than fanning out to both (no concept yet of "the same seat from two tabs").
|
||||
const gameConnections = new Map<string, Map<PlayerIndex, ServerResponse>>();
|
||||
const gameEventIds = new Map<string, Map<PlayerIndex, number>>();
|
||||
const lobbyConnections = new Map<string, Map<string, ServerResponse>>();
|
||||
|
||||
function writeSse(res: ServerResponse, id: number, data: unknown): void {
|
||||
res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function writeSse(seat: PlayerIndex, push: Push): void {
|
||||
const res = connections.get(seat);
|
||||
if (!res) return; // that seat is not currently connected — Phase 4's reconnect story, not this one
|
||||
const id = (eventIds.get(seat) ?? 0) + 1;
|
||||
eventIds.set(seat, id);
|
||||
res.write(`id: ${id}\ndata: ${JSON.stringify(push)}\n\n`);
|
||||
function nextEventId(gameId: string, seat: PlayerIndex): number {
|
||||
const ids = gameEventIds.get(gameId) ?? new Map<PlayerIndex, number>();
|
||||
const id = (ids.get(seat) ?? 0) + 1;
|
||||
ids.set(seat, id);
|
||||
gameEventIds.set(gameId, ids);
|
||||
return id;
|
||||
}
|
||||
|
||||
function broadcast(pushes: Map<PlayerIndex, Push>): void {
|
||||
for (const [seat, push] of pushes) writeSse(seat, push);
|
||||
function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): void {
|
||||
const conns = gameConnections.get(gameId);
|
||||
if (!conns) return;
|
||||
for (const [seat, push] of pushes) {
|
||||
const res = conns.get(seat);
|
||||
if (res) writeSse(res, nextEventId(gameId, seat), push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go
|
||||
* through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently
|
||||
* connected seat of the same game as a presence-only push (an empty board delta, no menu, no new
|
||||
* lines) rather than inventing a second SSE event type — one message shape for the client to parse.
|
||||
*/
|
||||
function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void {
|
||||
const conns = gameConnections.get(gameId);
|
||||
if (!conns) return;
|
||||
for (const [other, res] of conns) {
|
||||
if (other === seat) continue;
|
||||
const push: Push = { menu: null, lines: [], presence: { seat, connected } };
|
||||
writeSse(res, nextEventId(gameId, other), push);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastLobby(gameId: string): void {
|
||||
const lobby = lobbies.get(gameId);
|
||||
const conns = lobbyConnections.get(gameId);
|
||||
if (!lobby || !conns) return;
|
||||
for (const [token, res] of conns) {
|
||||
const ps = sessions.get(token);
|
||||
if (!ps) continue;
|
||||
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLobby(lobby: Lobby): Promise<void> {
|
||||
lobbies.set(lobby.gameId, lobby);
|
||||
gameCodes.set(lobby.gameCode, lobby.gameId);
|
||||
await writeLobby(opts.dataDir, lobby);
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' });
|
||||
}
|
||||
|
||||
async function persistSession(ps: PlayerSession): Promise<void> {
|
||||
sessions.set(ps.token, ps);
|
||||
const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId);
|
||||
await writeSessions(opts.dataDir, ps.gameId, all);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
|
||||
if (url.pathname === '/api/game' && req.method === 'POST') {
|
||||
if (!checkSecret(url, res)) return;
|
||||
if (session) {
|
||||
sendJson(res, 409, { error: 'a game already exists on this server' });
|
||||
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
|
||||
|
||||
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string };
|
||||
if (body.secret !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
const body = (await readJson(req)) as { config?: GameConfig; playerNames?: string[]; seed?: number };
|
||||
if (!body.config || !Array.isArray(body.playerNames) || body.playerNames.length < 1) {
|
||||
sendJson(res, 400, { error: 'expected { config, playerNames }' });
|
||||
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
|
||||
sendJson(res, 400, { error: 'expected { secret, config, displayName }' });
|
||||
return;
|
||||
}
|
||||
session = createSession(body.seed ?? Math.floor(Math.random() * 1e9), body.config, body.playerNames);
|
||||
// Persisted immediately, empty history and all — a crash one second after creation should
|
||||
// still resume as "the game exists, day 1, nobody has moved" rather than vanish entirely.
|
||||
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
|
||||
sendJson(res, 200, { ok: true, playerCount: session.playerCount });
|
||||
const gameCode = freshGameCode((code) => gameCodes.has(code));
|
||||
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode);
|
||||
await persistLobby(lobby);
|
||||
await persistSession(session);
|
||||
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
if (!checkSecret(url, res)) return;
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no game yet' });
|
||||
if (url.pathname === '/api/lobby/join' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string };
|
||||
if (body.secret !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
|
||||
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
|
||||
sendJson(res, 400, { error: 'bad or missing ?seat=' });
|
||||
if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
|
||||
sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
const gameId = gameCodes.get(body.gameCode.trim().toUpperCase());
|
||||
const lobby = gameId ? lobbies.get(gameId) : undefined;
|
||||
if (!lobby) {
|
||||
// A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a
|
||||
// typo gets, which tells a latecomer "that game is gone" without leaking which case it was.
|
||||
sendJson(res, 404, { error: 'no open lobby with that code' });
|
||||
return;
|
||||
}
|
||||
const result = joinLobby(lobby, body.displayName.trim());
|
||||
if (!result.ok) {
|
||||
sendJson(res, 409, { error: result.code });
|
||||
return;
|
||||
}
|
||||
await persistLobby(result.lobby);
|
||||
await persistSession(result.session);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, { gameId: lobby.gameId, token: result.session.token, player: result.session.player });
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Lobby: seating, once inside (token-authenticated) ------------------------------------
|
||||
|
||||
if (url.pathname === '/api/lobby/bot' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean };
|
||||
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
if (lobby.hostToken !== ps.token) {
|
||||
sendJson(res, 403, { error: 'NOT_HOST' });
|
||||
return;
|
||||
}
|
||||
if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') {
|
||||
sendJson(res, 400, { error: 'expected { token, seat, filled }' });
|
||||
return;
|
||||
}
|
||||
const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled);
|
||||
await persistLobby(updated);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/lobby/start' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { token?: string };
|
||||
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
const result = startLobby(lobby, ps.token);
|
||||
if (!result.ok) {
|
||||
sendJson(res, 409, { error: result.code });
|
||||
return;
|
||||
}
|
||||
const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats);
|
||||
games.set(lobby.gameId, session);
|
||||
lobbies.delete(lobby.gameId);
|
||||
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
|
||||
// forward from here. `started: true` on one last message, THEN close, is what lets a
|
||||
// still-open lobby tab tell "the game began" apart from a network hiccup `EventSource`
|
||||
// would otherwise silently retry through.
|
||||
for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) {
|
||||
const watcherPs = sessions.get(watcherToken);
|
||||
if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush);
|
||||
watcherRes.end();
|
||||
}
|
||||
lobbyConnections.delete(lobby.gameId);
|
||||
|
||||
await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion);
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' });
|
||||
await deleteLobby(opts.dataDir, lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/lobby/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
|
||||
if (!ps || !lobby) {
|
||||
sendJson(res, 404, { error: 'no such lobby' });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const conns = lobbyConnections.get(lobby.gameId) ?? new Map<string, ServerResponse>();
|
||||
conns.set(token, res);
|
||||
lobbyConnections.set(lobby.gameId, conns);
|
||||
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const live = lobbyConnections.get(lobby.gameId);
|
||||
if (live?.get(token) === res) live.delete(token);
|
||||
// `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player
|
||||
// if the host's connection closes before start. `lobbies.get` again, not the captured
|
||||
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
|
||||
const current = lobbies.get(lobby.gameId);
|
||||
if (current && current.hostToken === token) {
|
||||
void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId));
|
||||
}
|
||||
});
|
||||
connections.set(seat, res);
|
||||
writeSse(seat, session.connect(seat));
|
||||
return;
|
||||
}
|
||||
|
||||
// -- The running game (token-authenticated) ------------------------------------------------
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
const { gameId, player: seat } = ps;
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const conns = gameConnections.get(gameId) ?? new Map<PlayerIndex, ServerResponse>();
|
||||
conns.set(seat, res);
|
||||
gameConnections.set(gameId, conns);
|
||||
writeSse(res, nextEventId(gameId, seat), session.connect(seat));
|
||||
broadcastPresence(gameId, seat, true);
|
||||
// Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a
|
||||
// silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a
|
||||
// real event (EventSource ignores lines starting with `:`), so it costs the client nothing.
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
if (connections.get(seat) === res) connections.delete(seat);
|
||||
const live = gameConnections.get(gameId);
|
||||
if (live?.get(seat) === res) live.delete(seat);
|
||||
broadcastPresence(gameId, seat, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/intent' && req.method === 'POST') {
|
||||
if (!checkSecret(url, res)) return;
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no game yet' });
|
||||
return;
|
||||
}
|
||||
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
|
||||
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
|
||||
sendJson(res, 400, { error: 'bad or missing ?seat=' });
|
||||
// Token comes from the QUERY STRING, matching `/api/stream` and matching what
|
||||
// `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`)
|
||||
// — the body carries only what changes per call, `{ seq, intent }`.
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
const body = (await readJson(req)) as { seq?: number; intent?: Intent };
|
||||
@@ -175,15 +381,23 @@ export function startServer(opts: ServerOptions): void {
|
||||
sendJson(res, 400, { error: 'expected { seq, intent }' });
|
||||
return;
|
||||
}
|
||||
const result = session.intent(seat, body.seq, body.intent);
|
||||
const result = session.intent(ps.player, body.seq, body.intent);
|
||||
if (result.accepted) {
|
||||
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
||||
// this scale, not just "applied in memory" (§12 step 14).
|
||||
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
|
||||
if (result.timing) await appendTiming(opts.dataDir, result.timing);
|
||||
const dir = gameDir(opts.dataDir, ps.gameId);
|
||||
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
||||
if (result.timing) await appendTiming(dir, result.timing);
|
||||
if (session.exportSave().status === 'finished') {
|
||||
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
||||
// carried forward here rather than left blank — `gameCodes` is the only place still
|
||||
// holding it once a lobby's own record is gone.
|
||||
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
||||
if (result.accepted) broadcast(result.pushes);
|
||||
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+48
-17
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start.
|
||||
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start and
|
||||
* Phase 4 (§12 steps 17-20) to resume every saved game and lobby, not just one.
|
||||
*
|
||||
* Run with: node src/server/index.ts
|
||||
*
|
||||
@@ -11,9 +12,10 @@ import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from './http.ts';
|
||||
import { loadGame } from './persistence.ts';
|
||||
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
|
||||
import { resumeSession } from './session.ts';
|
||||
import type { GameSession } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
const port = Number(process.env['PORT'] ?? 8081);
|
||||
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
|
||||
@@ -31,21 +33,50 @@ if (!joinSecret) {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
|
||||
|
||||
let initialSession: GameSession | null = null;
|
||||
const initialGames = new Map<string, GameSession>();
|
||||
const initialLobbies = new Map<string, Lobby>();
|
||||
const initialSessions = new Map<string, PlayerSession>();
|
||||
|
||||
const loaded = await loadGame(dataDir, engineVersion);
|
||||
if (loaded.found && loaded.ok) {
|
||||
initialSession = resumeSession(loaded.saved);
|
||||
console.log(`Resumed a saved game from ${dataDir} (${loaded.saved.history.length} intents replayed).`);
|
||||
} else if (loaded.found && !loaded.ok) {
|
||||
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
|
||||
// under. The file is left untouched: rolling the running version back would let it load again.
|
||||
console.error(
|
||||
`Refusing to load ${dataDir}/game.json: it was saved under engine version ` +
|
||||
`${loaded.storedVersion}, this server is running ${engineVersion}. Starting with no active ` +
|
||||
`game. The saved file has not been touched.`,
|
||||
);
|
||||
const index = await readIndex(dataDir);
|
||||
for (const entry of index) {
|
||||
const sessions = await readSessions(dataDir, entry.gameId);
|
||||
for (const s of sessions) initialSessions.set(s.token, s);
|
||||
|
||||
if (entry.status === 'lobby') {
|
||||
const lobby = await readLobby(dataDir, entry.gameId);
|
||||
if (lobby) initialLobbies.set(entry.gameId, lobby);
|
||||
continue;
|
||||
}
|
||||
|
||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
|
||||
if (loaded.found && loaded.ok) {
|
||||
initialGames.set(entry.gameId, resumeSession(loaded.saved));
|
||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||
} else if (loaded.found && !loaded.ok) {
|
||||
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
|
||||
// under. The file is left untouched: rolling the running version back would let it load again.
|
||||
console.error(
|
||||
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
|
||||
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
|
||||
`will not appear as an active game until the version matches again.`,
|
||||
);
|
||||
}
|
||||
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
|
||||
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
|
||||
}
|
||||
|
||||
startServer({ port, bindAddress, joinSecret, distDir, dataDir, engineVersion, initialSession });
|
||||
console.log(`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir}`);
|
||||
startServer({
|
||||
port,
|
||||
bindAddress,
|
||||
joinSecret,
|
||||
distDir,
|
||||
dataDir,
|
||||
engineVersion,
|
||||
initialGames,
|
||||
initialLobbies,
|
||||
initialSessions,
|
||||
});
|
||||
console.log(
|
||||
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
|
||||
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -1,22 +1,26 @@
|
||||
/**
|
||||
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15;
|
||||
* `lobby-and-sessions.md` §6 specifies the exact shape and reasoning).
|
||||
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for
|
||||
* Phase 4 (§12 steps 17-20) to more than one game (`lobby-and-sessions.md` §6 specifies both
|
||||
* shapes and the reasoning behind them).
|
||||
*
|
||||
* One game per process (Phase 2's scope, unchanged) — two files in `DATA_DIR`, no index and no
|
||||
* `gameId`, both deferred to Phase 4's multi-game generalization same as the server core deferred
|
||||
* them. Every write is a full-file atomic rewrite (write to `.tmp`, `rename` over the real path)
|
||||
* rather than true on-disk appending: §6 says the storage mechanism is genuinely open as long as the
|
||||
* LOGICAL history is never rewritten or reordered, which a full rewrite of an always-growing array
|
||||
* satisfies — and at the measured scale (~350 intents, a few hundred bytes per game) there is nothing
|
||||
* to optimize yet.
|
||||
* ONE DIRECTORY PER GAME (`games/<gameId>/`), plus one top-level `index.json` naming every game so
|
||||
* `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is
|
||||
* still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true
|
||||
* on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history
|
||||
* is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at
|
||||
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { SavedGame, TurnTiming } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
const GAME_FILE = 'game.json';
|
||||
const TIMINGS_FILE = 'turn-timings.json';
|
||||
const LOBBY_FILE = 'lobby.json';
|
||||
const SESSIONS_FILE = 'sessions.json';
|
||||
const INDEX_FILE = 'index.json';
|
||||
|
||||
type PersistedGame = SavedGame & { engineVersion: string };
|
||||
|
||||
@@ -67,3 +71,86 @@ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise
|
||||
existing.push(timing);
|
||||
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4 — more than one game
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GameIndexEntry = { gameId: string; gameCode: string; status: 'lobby' | 'active' | 'finished' };
|
||||
|
||||
/** Where one game's own files live. Every function below takes a `gameId` and joins it under here
|
||||
* itself; `writeGame`/`loadGame`/`appendTiming` above do not, and take a directory directly, so a
|
||||
* caller wanting one specific game's `game.json` passes `gameDir(dataDir, gameId)` to those. */
|
||||
export function gameDir(dataDir: string, gameId: string): string {
|
||||
return join(dataDir, 'games', gameId);
|
||||
}
|
||||
|
||||
export async function readIndex(dataDir: string): Promise<GameIndexEntry[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await atomicWrite(join(dataDir, INDEX_FILE), JSON.stringify(entries, null, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates exactly one game's row, by `gameId` — every caller that changes one game's status
|
||||
* (created, started, finished) wants precisely this, not "replace the whole index", which would
|
||||
* silently drop every other game's row the moment two writes happened close together.
|
||||
*/
|
||||
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
|
||||
const entries = await readIndex(dataDir);
|
||||
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
||||
if (i >= 0) entries[i] = entry;
|
||||
else entries.push(entry);
|
||||
await writeIndex(dataDir, entries);
|
||||
}
|
||||
|
||||
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
|
||||
const dir = gameDir(dataDir, lobby.gameId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await atomicWrite(join(dir, LOBBY_FILE), JSON.stringify(lobby, null, 1));
|
||||
}
|
||||
|
||||
export async function readLobby(dataDir: string, gameId: string): Promise<Lobby | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), LOBBY_FILE), 'utf8')) as Lobby;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed once a game starts — a `Lobby` and a running `SavedGame` are mutually exclusive for one
|
||||
* `gameId`, and leaving the file behind would let a restart resurrect a lobby for a game already
|
||||
* under way. Missing already is not an error; `Lobby.Start` calls this exactly once, in the same
|
||||
* request that writes `game.json`.
|
||||
*/
|
||||
export async function deleteLobby(dataDir: string, gameId: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(gameDir(dataDir, gameId), LOBBY_FILE));
|
||||
} catch {
|
||||
// Already gone — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/** Session tokens for one game — `lobby-and-sessions.md` §6: persisted so a token still works after
|
||||
* a server restart, the same reason `game.json` itself is persisted. */
|
||||
export async function writeSessions(dataDir: string, gameId: string, sessions: PlayerSession[]): Promise<void> {
|
||||
const dir = gameDir(dataDir, gameId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await atomicWrite(join(dir, SESSIONS_FILE), JSON.stringify(sessions, null, 1));
|
||||
}
|
||||
|
||||
export async function readSessions(dataDir: string, gameId: string): Promise<PlayerSession[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+76
-5
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
@@ -27,13 +28,28 @@ import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
export type Push = {
|
||||
frame: FrameDelta;
|
||||
/**
|
||||
* Absent on a presence-only push (below) — there is no board delta to send when nothing about the
|
||||
* GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame
|
||||
* for a seat that may not even be in this game's connection table (a lobby-stage notice has none).
|
||||
*/
|
||||
frame?: FrameDelta;
|
||||
/** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */
|
||||
menu: Menu | null;
|
||||
/** Narration since the LAST push to this specific seat, not the whole game's log. */
|
||||
lines: { text: string; tone: string }[];
|
||||
/**
|
||||
* Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md`
|
||||
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
|
||||
* through the engine or the shared narration log (which must stay replayable from a seed). Built
|
||||
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
|
||||
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
|
||||
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
|
||||
*/
|
||||
presence?: { seat: PlayerIndex; connected: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,6 +75,13 @@ export type SavedGame = {
|
||||
history: Intent[];
|
||||
status: 'active' | 'finished';
|
||||
createdAt: number;
|
||||
/**
|
||||
* Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby
|
||||
* time only, never mid-game). Persisted so a bot seat is still a bot after a server restart —
|
||||
* `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean
|
||||
* guessing from a display name rather than reading a fact.
|
||||
*/
|
||||
botSeats: PlayerIndex[];
|
||||
};
|
||||
|
||||
export type IntentResult =
|
||||
@@ -67,6 +90,7 @@ export type IntentResult =
|
||||
|
||||
export type GameSession = {
|
||||
readonly playerCount: number;
|
||||
isBot(seat: PlayerIndex): boolean;
|
||||
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
|
||||
connect(seat: PlayerIndex): Push;
|
||||
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
|
||||
@@ -76,7 +100,12 @@ export type GameSession = {
|
||||
|
||||
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
|
||||
|
||||
function buildSession(game: Game, playerNames: string[], createdAt: number): GameSession {
|
||||
function buildSession(
|
||||
game: Game,
|
||||
playerNames: string[],
|
||||
createdAt: number,
|
||||
botSeats: Set<PlayerIndex>,
|
||||
): GameSession {
|
||||
const lastSeq = new Map<PlayerIndex, number>();
|
||||
const lastFrame = new Map<PlayerIndex, Frame>();
|
||||
const sentLines = new Map<PlayerIndex, number>();
|
||||
@@ -137,8 +166,40 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends.
|
||||
*
|
||||
* `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never
|
||||
* takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at
|
||||
* `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a
|
||||
* client with a `seq` to dedupe against, and its choice is by construction always legal (`check`
|
||||
* would only ever confirm what `legalActions` already promised).
|
||||
*
|
||||
* Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded —
|
||||
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
|
||||
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
|
||||
* on a bot's turn) and once after every accepted human intent.
|
||||
*/
|
||||
function driveBots(): void {
|
||||
let guard = 0;
|
||||
for (;;) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null || !botSeats.has(actor)) return;
|
||||
if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`);
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) return;
|
||||
const choice = developerBot.choose(game.state, actor, options);
|
||||
const ok = submit(game, choice);
|
||||
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
|
||||
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
|
||||
settleTiming();
|
||||
}
|
||||
}
|
||||
driveBots();
|
||||
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
isBot: (seat) => botSeats.has(seat),
|
||||
|
||||
connect(seat) {
|
||||
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
|
||||
@@ -169,6 +230,10 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
|
||||
|
||||
lastSeq.set(seat, seq);
|
||||
const timing = settleTiming();
|
||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
||||
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
|
||||
// in one push regardless of how many turns that took.
|
||||
driveBots();
|
||||
return { accepted: true, pushes: pushesForAll(), timing };
|
||||
},
|
||||
|
||||
@@ -180,13 +245,19 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
|
||||
history: [...game.history],
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
botSeats: [...botSeats],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession {
|
||||
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now());
|
||||
export function createSession(
|
||||
seed: number,
|
||||
config: GameConfig,
|
||||
playerNames: string[],
|
||||
botSeats: PlayerIndex[] = [],
|
||||
): GameSession {
|
||||
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,5 +267,5 @@ export function createSession(seed: number, config: GameConfig, playerNames: str
|
||||
*/
|
||||
export function resumeSession(saved: SavedGame): GameSession {
|
||||
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
|
||||
return buildSession(game, saved.playerNames, saved.createdAt);
|
||||
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats));
|
||||
}
|
||||
|
||||
@@ -85,6 +85,33 @@ export const SOLO_CONFIG: GameConfig = {
|
||||
houseRules: DEFAULT_HOUSE_RULES,
|
||||
};
|
||||
|
||||
/**
|
||||
* The lobby's (`lobby.ts`) starting point for a Competitive or Co-op `Lobby.Create` — same formula
|
||||
* `SOLO_CONFIG` uses, at a nominal player count. `minCombinedRevenue` is necessarily a guess at
|
||||
* create time: nobody is seated yet, so there is no real headcount to size it against. It stays a
|
||||
* guess rather than being fixed up at `Lobby.Start`, matching what the New Game dialog already told
|
||||
* players before there was a lobby at all ("assumes a 4-player table until there's a lobby to ask
|
||||
* who's actually seated") — Phase 5 or a follow-up can revisit sizing it to the seats actually
|
||||
* filled once that is worth the complexity.
|
||||
*/
|
||||
export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = 4): GameConfig {
|
||||
return {
|
||||
mode,
|
||||
days: DEFAULT_DAYS,
|
||||
minCombinedRevenue: collectiveRevenueFloor(players, DEFAULT_DAYS),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: mode === 'competitive',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
houseRules: DEFAULT_HOUSE_RULES,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
|
||||
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
+85
-13
@@ -25,9 +25,18 @@ import type { NewGameOptions } from './game.ts';
|
||||
import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||
/**
|
||||
* The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh
|
||||
* `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from
|
||||
* `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between
|
||||
* browsers; this is a credential for THIS origin's server and must never be treated as one.
|
||||
*/
|
||||
const REMOTE_KEY = 'station-master.remote.v1';
|
||||
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
@@ -298,27 +307,58 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
function loadRemote(): LobbyReady | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMOTE_KEY);
|
||||
return raw ? (JSON.parse(raw) as LobbyReady) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
|
||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
||||
* ever flashes before `start()` decides which one this load actually needs. */
|
||||
function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
||||
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
||||
}
|
||||
|
||||
/**
|
||||
* `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and
|
||||
* URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from
|
||||
* whatever the server was configured with, not from this browser's URL or `localStorage`.
|
||||
* The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s
|
||||
* `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an
|
||||
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
|
||||
* so it is always written back here before anything else happens.
|
||||
*/
|
||||
function beginRemote(ready: LobbyReady): void {
|
||||
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
|
||||
showScreen('gameui');
|
||||
session = createRemoteSession(ready.token, ready.seat);
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
||||
session.subscribe(render);
|
||||
}
|
||||
|
||||
/**
|
||||
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
|
||||
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
|
||||
* cannot produce one. `start()`'s job is only to decide which of three screens this load is: back
|
||||
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
|
||||
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
|
||||
*/
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const seatParam = params.get('seat');
|
||||
|
||||
if (seatParam !== null) {
|
||||
const seat = Number(seatParam) as PlayerIndex;
|
||||
session = createRemoteSession(seat, params.get('secret') ?? '');
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
||||
session.subscribe(render);
|
||||
const remembered = loadRemote();
|
||||
if (remembered) {
|
||||
beginRemote(remembered);
|
||||
return;
|
||||
}
|
||||
|
||||
showScreen('gameui');
|
||||
const requested = params.get('seed');
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
@@ -354,6 +394,23 @@ function applyCapabilities(): void {
|
||||
hide('undo', c.undo);
|
||||
hide('savefile', c.saveLocal);
|
||||
hide('newgame', c.newGame);
|
||||
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
|
||||
// page offers — same reasoning as `newgame`, and the same capability answers both.
|
||||
hide('multiplayer', c.newGame);
|
||||
}
|
||||
|
||||
/**
|
||||
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
|
||||
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
|
||||
* has anything to report), and empty again the moment everyone reports back in — `#presence:empty`
|
||||
* collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read.
|
||||
*/
|
||||
function renderPresence(f: Frame): void {
|
||||
const away = session
|
||||
.presence()
|
||||
.filter((p) => !p.connected)
|
||||
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`);
|
||||
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
@@ -379,6 +436,7 @@ function render(): void {
|
||||
}
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||
@@ -1180,6 +1238,20 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
|
||||
* deal the same game again and look like the button had done nothing.
|
||||
*/
|
||||
const multiplayerBtn = document.getElementById('multiplayer');
|
||||
if (multiplayerBtn) {
|
||||
multiplayerBtn.onclick = () => {
|
||||
// Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same
|
||||
// reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session.
|
||||
if (!isLocal(session)) return;
|
||||
const f = session.view();
|
||||
const started = f.status === 'active' && (f.day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return;
|
||||
showScreen('lobby');
|
||||
runLobby(beginRemote);
|
||||
};
|
||||
}
|
||||
|
||||
const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
|
||||
+60
-1
@@ -73,6 +73,14 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
padding:10px 12px;margin-bottom:12px}
|
||||
#lobby{max-width:640px;margin:0 auto;padding:14px}
|
||||
#lobby h2{margin-top:0}
|
||||
#lobby h3{margin-bottom:2px}
|
||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
|
||||
@@ -210,6 +218,50 @@ ul.blocked li{padding:2px 0}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
||||
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
||||
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
|
||||
`#newgamedlg` at the very end of the body is solitaire-only and untouched by any of this. -->
|
||||
<div id="lobby" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||
|
||||
<section id="lb-secret-section">
|
||||
<h2>Join secret</h2>
|
||||
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
|
||||
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
|
||||
</section>
|
||||
|
||||
<section id="lb-choice-section">
|
||||
<h2>Create or join a game</h2>
|
||||
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
|
||||
|
||||
<h3>Create a new game</h3>
|
||||
<p class="ng-note">You become the host — you choose the mode and, once everyone's seated, start the game. 2 to 4 players.</p>
|
||||
<label class="ng-radio"><input type="radio" name="lb-mode" value="competitive" checked>
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
|
||||
<button id="lb-create">Create game</button>
|
||||
<p class="dim" id="lb-create-err" role="alert"></p>
|
||||
|
||||
<h3>Join a game</h3>
|
||||
<p class="ng-note">Ask whoever created the game for its code.</p>
|
||||
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
|
||||
<button id="lb-join">Join game</button>
|
||||
<p class="dim" id="lb-join-err" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
|
||||
<section id="lb-seating-section" hidden>
|
||||
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2>
|
||||
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p>
|
||||
<div id="lb-seats"></div>
|
||||
<button id="lb-start" disabled>Start game</button>
|
||||
<p class="dim" id="lb-start-note"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="gameui" hidden>
|
||||
<div class="topbar">
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
@@ -233,6 +285,7 @@ ul.blocked li{padding:2px 0}
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
@@ -247,6 +300,10 @@ ul.blocked li{padding:2px 0}
|
||||
what you miss when the automatic phases run between two clicks. -->
|
||||
<div id="phasenote"></div>
|
||||
<div id="announce"></div>
|
||||
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
|
||||
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
|
||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||
<div id="presence"></div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
@@ -314,6 +371,8 @@ ul.blocked li{padding:2px 0}
|
||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
||||
==================================================================== -->
|
||||
</div><!-- /gameui -->
|
||||
|
||||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||||
<form method="dialog" id="newgameform">
|
||||
<h2 class="big" id="ng-title">New game</h2>
|
||||
@@ -365,7 +424,7 @@ ul.blocked li{padding:2px 0}
|
||||
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Multiplayer needs a server — coming soon.</span>
|
||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
|
||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||
</menu>
|
||||
|
||||
+39
-9
@@ -93,6 +93,15 @@ export type Session = {
|
||||
takeAnnouncement(): string | null;
|
||||
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
||||
justDrawn(): string | null;
|
||||
/**
|
||||
* Which OTHER seats are currently connected, as last reported by the server — always empty for a
|
||||
* `LocalSession` (there is nobody else to track). `lobby-and-sessions.md` §5: a disconnect keeps
|
||||
* the seat and waits; this is what lets the page say why, instead of going quiet with no
|
||||
* explanation. Reflects the last presence push for each seat the server has ever mentioned, not
|
||||
* only the ones currently disconnected — a seat that reconnects updates its own entry rather than
|
||||
* disappearing, so the page can tell "never heard from" apart from "was here, then left."
|
||||
*/
|
||||
presence(): { seat: PlayerIndex; connected: boolean }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,6 +172,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
return text;
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
presence: () => [],
|
||||
|
||||
seed: () => game.seed,
|
||||
save: () => toSave(game),
|
||||
@@ -191,36 +201,55 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
|
||||
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
|
||||
* so this file never depends on anything under `src/server/` even at the type level. */
|
||||
type Push = { frame: FrameDelta; menu: Menu | null; lines: { text: string; tone: string }[] };
|
||||
type Push = {
|
||||
frame?: FrameDelta;
|
||||
menu: Menu | null;
|
||||
lines: { text: string; tone: string }[];
|
||||
presence?: { seat: PlayerIndex; connected: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* A session backed by a server (Phase 2). Holds no authoritative state — no deck order, no other
|
||||
* seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities` are all `false`:
|
||||
* undo would have to un-see what other players already saw, a local save is meaningless when the
|
||||
* server is the store, and dealing a new game is the lobby's job (Phase 4).
|
||||
* A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
|
||||
* order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
|
||||
* are all `false`: undo would have to un-see what other players already saw, a local save is
|
||||
* meaningless when the server is the store, and dealing a new game is the lobby's job.
|
||||
*
|
||||
* `token` is `lobby-and-sessions.md` §1's session token, issued at `Lobby.Create`/`Lobby.Join` and
|
||||
* stored by the caller (`main.ts`, in `localStorage`) — it alone proves identity to `/api/stream`
|
||||
* and `/api/intent`, so this function no longer takes a join secret at all; that gate belongs to
|
||||
* the lobby endpoints only. `seat` still has to be passed in rather than learned from a push,
|
||||
* because the very FIRST thing this session needs — `seat()` — has to answer before any push has
|
||||
* necessarily arrived; the caller already knows it from the join/create/start response.
|
||||
*
|
||||
* Construction is synchronous (the `Session` interface has no async surface), but the first real
|
||||
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
|
||||
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
||||
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
||||
*/
|
||||
export function createRemoteSession(seat: PlayerIndex, secret: string): Session {
|
||||
export function createRemoteSession(token: string, seat: PlayerIndex): Session {
|
||||
let frame: Frame | null = null;
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
const presence = new Map<PlayerIndex, boolean>();
|
||||
let nextSeq = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
};
|
||||
|
||||
const qs = `seat=${seat}&secret=${encodeURIComponent(secret)}`;
|
||||
const qs = `token=${encodeURIComponent(token)}`;
|
||||
const source = new EventSource(`/api/stream?${qs}`);
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
frame = applyDelta(frame, push.frame);
|
||||
menu = push.menu;
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
||||
// per `session.ts`'s `Push`) updates the board or the menu.
|
||||
if (push.frame) {
|
||||
frame = applyDelta(frame, push.frame);
|
||||
menu = push.menu;
|
||||
}
|
||||
lines = [...lines, ...push.lines];
|
||||
if (push.presence) presence.set(push.presence.seat, push.presence.connected);
|
||||
changed();
|
||||
};
|
||||
|
||||
@@ -259,5 +288,6 @@ export function createRemoteSession(seat: PlayerIndex, secret: string): Session
|
||||
takeScheduled: () => null,
|
||||
takeAnnouncement: () => null,
|
||||
justDrawn: () => null,
|
||||
presence: () => [...presence].map(([s, connected]) => ({ seat: s, connected })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const saved: SavedGame = {
|
||||
history: [{ type: 'localOps.choose', option: 'draw' }],
|
||||
status: 'active',
|
||||
createdAt: 1000,
|
||||
botSeats: [],
|
||||
};
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -28,8 +28,8 @@ describe('the game session host', () => {
|
||||
it('gives a fresh connect a full Frame — nothing nulled', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
const push = session.connect(0 as PlayerIndex);
|
||||
assert.notEqual(push.frame.cells, null, 'a first connect nulled the board');
|
||||
assert.notEqual(push.frame.division, null, 'a first connect nulled the division');
|
||||
assert.notEqual(push.frame!.cells, null, 'a first connect nulled the board');
|
||||
assert.notEqual(push.frame!.division, null, 'a first connect nulled the division');
|
||||
});
|
||||
|
||||
it('only the current actor gets a real Menu; every other seat gets null', () => {
|
||||
@@ -100,7 +100,7 @@ describe('the game session host', () => {
|
||||
// Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled
|
||||
// on this push for a seat that already had them from `connect`.
|
||||
const push = result.pushes.get(actor)!;
|
||||
assert.equal(push.frame.division, null, 'the board was resent even though nothing on it changed');
|
||||
assert.equal(push.frame!.division, null, 'the board was resent even though nothing on it changed');
|
||||
});
|
||||
|
||||
it('only sends narration NEW since the last push to that specific seat', () => {
|
||||
@@ -159,6 +159,42 @@ describe('turn timings (lobby-and-sessions.md §5)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot seats (Phase 4 — D8, lobby-and-sessions.md §2)', () => {
|
||||
it('a bot never becomes the observable current actor — it plays before anyone can see it waiting', () => {
|
||||
// Seat 0 acts first (the opening Superintendent), so marking it a bot exercises `driveBots()`
|
||||
// at CONSTRUCTION time — before any external `intent()` has run at all.
|
||||
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
|
||||
assert.equal(session.isBot(0 as PlayerIndex), true);
|
||||
assert.equal(session.isBot(1 as PlayerIndex), false);
|
||||
|
||||
const botPush = session.connect(0 as PlayerIndex);
|
||||
const humanPush = session.connect(1 as PlayerIndex);
|
||||
assert.equal(botPush.menu, null, 'the bot seat was handed a real decision to make');
|
||||
assert.notEqual(humanPush.menu, null, 'nobody was left with a turn to take — the bot never played');
|
||||
});
|
||||
|
||||
it('botSeats round-trips through exportSave/resumeSession', () => {
|
||||
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
|
||||
const resumed = resumeSession(session.exportSave());
|
||||
assert.equal(resumed.isBot(0 as PlayerIndex), true);
|
||||
assert.equal(resumed.connect(0 as PlayerIndex).menu, null, 'a resumed bot seat still never gets a real decision');
|
||||
});
|
||||
|
||||
it('a bot seat is driven forward after a human intent too, not only at construction', () => {
|
||||
// Two bots and one human: whichever of the two non-human seats comes up next after the human's
|
||||
// own move must be played automatically, with no external `intent()` for either of them.
|
||||
const session = createSession(11, config, ['Alice', 'Bot', 'Bot'], [1 as PlayerIndex, 2 as PlayerIndex]);
|
||||
const before = session.exportSave().history.length;
|
||||
const applied = session.intent(0 as PlayerIndex, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(applied.accepted, true);
|
||||
assert.equal(session.connect(1 as PlayerIndex).menu, null, 'bot seat 1 was left with a real decision');
|
||||
assert.equal(session.connect(2 as PlayerIndex).menu, null, 'bot seat 2 was left with a real decision');
|
||||
// Not a strict proof either bot actually moved (the human's own turn may not have ended yet),
|
||||
// but the history can only ever have grown, never shrunk, and never rejected mid-drive.
|
||||
assert.ok(session.exportSave().history.length >= before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
|
||||
it('exportSave carries enough to reconstruct the exact same game', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
|
||||
Reference in New Issue
Block a user