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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user