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

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

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

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

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

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

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

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

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
This commit is contained in:
Jesse
2026-08-21 05:25:47 -04:00
parent c3c5cbfeec
commit e76bd77099
17 changed files with 1420 additions and 125 deletions
+60 -1
View File
@@ -494,7 +494,7 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken.
- [x] **Multiplayer proper — Phases 0, 1 and 2 done (v0.4.0, 2026-08-20), Phases 3–6 to go.** The
- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
@@ -579,6 +579,65 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
restarted, server logged the refusal and started with no active game (confirmed via `POST
/api/game` succeeding rather than 409ing).
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
`lobby-and-sessions.md` in full:
- `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
(`playerCountAllowed`) — see the doc-fix note below.
- **The server now holds more than one game.** `persistence.ts` gained one directory per
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
directory directly.
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
answer before any push necessarily arrives, and the caller already has it from the
join/create/start response.
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained
`driveBots()`: after any accepted intent (and once at construction, for a resume that lands
exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat
before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic.
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
list rather than seat order, since a bot-filled seat has no join time of its own.
- **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news
about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never
routed through `session.ts` or the engine, since a disconnect is transport news about a
connection, not a `GameEvent`. The page shows a banner naming who has dropped
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
new button.
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
second player, start, submit intents from both (including the wrong-actor rejection and an
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
observed on an open stream. **Not verified: an actual browser** walking through the lobby
screens — none is available in this environment, the same limitation Phase 2's `RemoteSession`
shipped under.
---
## Other