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
+76 -5
View File
@@ -19,6 +19,7 @@
*/
import { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
@@ -27,13 +28,28 @@ import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = {
frame: FrameDelta;
/**
* Absent on a presence-only push (below) — there is no board delta to send when nothing about the
* GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame
* for a seat that may not even be in this game's connection table (a lobby-stage notice has none).
*/
frame?: FrameDelta;
/** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */
menu: Menu | null;
/** Narration since the LAST push to this specific seat, not the whole game's log. */
lines: { text: string; tone: string }[];
/**
* Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md`
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
* through the engine or the shared narration log (which must stay replayable from a seed). Built
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
*/
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
@@ -59,6 +75,13 @@ export type SavedGame = {
history: Intent[];
status: 'active' | 'finished';
createdAt: number;
/**
* Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby
* time only, never mid-game). Persisted so a bot seat is still a bot after a server restart —
* `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean
* guessing from a display name rather than reading a fact.
*/
botSeats: PlayerIndex[];
};
export type IntentResult =
@@ -67,6 +90,7 @@ export type IntentResult =
export type GameSession = {
readonly playerCount: number;
isBot(seat: PlayerIndex): boolean;
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
connect(seat: PlayerIndex): Push;
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
@@ -76,7 +100,12 @@ export type GameSession = {
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
function buildSession(game: Game, playerNames: string[], createdAt: number): GameSession {
function buildSession(
game: Game,
playerNames: string[],
createdAt: number,
botSeats: Set<PlayerIndex>,
): GameSession {
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
@@ -137,8 +166,40 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
return closed;
}
/**
* PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends.
*
* `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never
* takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at
* `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a
* client with a `seq` to dedupe against, and its choice is by construction always legal (`check`
* would only ever confirm what `legalActions` already promised).
*
* Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded —
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
* on a bot's turn) and once after every accepted human intent.
*/
function driveBots(): void {
let guard = 0;
for (;;) {
const actor = currentActor(game);
if (actor === null || !botSeats.has(actor)) return;
if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`);
const options = legalActions(game.state, actor);
if (options.length === 0) return;
const choice = developerBot.choose(game.state, actor, options);
const ok = submit(game, choice);
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
settleTiming();
}
}
driveBots();
return {
playerCount: playerNames.length,
isBot: (seat) => botSeats.has(seat),
connect(seat) {
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
@@ -169,6 +230,10 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
lastSeq.set(seat, seq);
const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
// in one push regardless of how many turns that took.
driveBots();
return { accepted: true, pushes: pushesForAll(), timing };
},
@@ -180,13 +245,19 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
history: [...game.history],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
botSeats: [...botSeats],
};
},
};
}
export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now());
export function createSession(
seed: number,
config: GameConfig,
playerNames: string[],
botSeats: PlayerIndex[] = [],
): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats));
}
/**
@@ -196,5 +267,5 @@ export function createSession(seed: number, config: GameConfig, playerNames: str
*/
export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
return buildSession(game, saved.playerNames, saved.createdAt);
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats));
}