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

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

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

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

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

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

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

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

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
This commit is contained in:
Jesse
2026-08-21 05:25:47 -04:00
parent c3c5cbfeec
commit e76bd77099
17 changed files with 1420 additions and 125 deletions
+66
View File
@@ -19,6 +19,72 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
17-20, fully specified in `docs/architecture/lobby-and-sessions.md`). Phases 0-3 shipped in v0.4.0
and v0.5.0; a real server existed but nobody could reach it without a hand-built URL. This is what
makes it a game you can actually create or join.
### The server hosts more than one game, and knows who you are across a reconnect
`src/server/lobby.ts` is new: pure logic, no sockets, no filesystem, the same split `session.ts`
draws for a running game. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, plus a
speakable game code (`RAIL-4471` style) and the player cap.
`persistence.ts` gained one directory per `gameId` and a top-level index, so `index.ts` resumes
every saved game on boot, not just one. `/api/stream` and `/api/intent` now authenticate by session
**token** instead of `?seat=&secret=` — `lobby-and-sessions.md` §1: the token alone proves identity,
so the join secret's job ends at the lobby door (`/api/lobby/create`/`/api/lobby/join`).
### Bots fill empty seats, never take over a disconnected human (D8)
`session.ts` gained `driveBots()` — after any accepted intent, and once at construction for a
resume that lands exactly on a bot's turn, it plays `developerBot` forward through every
consecutive bot seat before the push goes out. Reuses `legalActions`/`developerBot` wholesale.
`SavedGame` gained `botSeats` so a bot seat survives a restart. Bots are assigned once, at
`Lobby.Start`, and never afterward — a disconnected human's seat waits, exactly as before.
### Disconnect keeps the seat and says so; reconnect gets a full view, not a tail
`Push` gained an optional `presence` field — connection news about another seat, built entirely by
`http.ts` (which owns the connection table) and never routed through `session.ts` or the engine: a
disconnect is transport news, not a `GameEvent`, and the engine must stay replayable from a seed.
The page shows a small banner naming who has dropped and clears it the moment they reconnect.
Host rights pass to the earliest-joined remaining player if the host's own connection drops before
`Lobby.Start` — tracked by join order rather than seat, since a bot-filled seat never joined at all.
### The client: an actual lobby, not a URL you hand-build
`src/web/lobby.ts`, wired from `main.ts`: create-or-join forms, a live seating screen (host-only bot
toggles and Start button, updated over its own SSE stream), and `localStorage` in place of `?seat=`
for "was I already in a game" — found on load, it reconnects straight through and skips the lobby
screen entirely. A `Multiplayer` button sits beside `New game`; the New Game dialog itself is
untouched and still solitaire-only, its old "needs a server" note repointed at the new button.
### Found only by the live smoke test, not by typechecking
`/api/intent` read its token from the JSON body; `web/session.ts`'s `submit()` — unchanged since
Phase 2 — sends it in the query string, the same as `/api/stream`. Every intent failed `no such
game`. Both sides typecheck cleanly on their own (an HTTP body is `unknown` on the wire), which is
exactly the gap a curl-level smoke test exists to catch: create a lobby, join a second player,
start, submit from both seats (including a wrong-actor rejection and an idempotent resend),
kill and restart the server and reconnect both tokens, start a bot-filled coop lobby and confirm it
never stalls waiting on the bot, and watch a live stream receive a disconnect/reconnect presence
notice for another seat.
### Doc fix
`multiplayer.md`'s decision table (D18) said the player cap was 6; `lobby-and-sessions.md` §2 —
more detailed, and what `test/multiplayer.test.ts` actually exercises — says 2-4 with the reasoning
for it. The two had quietly drifted apart; "6" was never implemented or tested anywhere. D18 now
reads 2-4.
**Not verified: an actual browser** walking through the lobby screens — none is available in this
environment, the same limitation Phase 2's `RemoteSession` shipped under. 656 tests, 0 failures.
---
## 0.5.0 — 2026-08-21
Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one