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
+39 -9
View File
@@ -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 })),
};
}