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:
+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 })),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user