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 -3
View File
@@ -28,8 +28,8 @@ describe('the game session host', () => {
it('gives a fresh connect a full Frame — nothing nulled', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
assert.notEqual(push.frame.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame.division, null, 'a first connect nulled the division');
assert.notEqual(push.frame!.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame!.division, null, 'a first connect nulled the division');
});
it('only the current actor gets a real Menu; every other seat gets null', () => {
@@ -100,7 +100,7 @@ describe('the game session host', () => {
// Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled
// on this push for a seat that already had them from `connect`.
const push = result.pushes.get(actor)!;
assert.equal(push.frame.division, null, 'the board was resent even though nothing on it changed');
assert.equal(push.frame!.division, null, 'the board was resent even though nothing on it changed');
});
it('only sends narration NEW since the last push to that specific seat', () => {
@@ -159,6 +159,42 @@ describe('turn timings (lobby-and-sessions.md §5)', () => {
});
});
describe('bot seats (Phase 4 — D8, lobby-and-sessions.md §2)', () => {
it('a bot never becomes the observable current actor — it plays before anyone can see it waiting', () => {
// Seat 0 acts first (the opening Superintendent), so marking it a bot exercises `driveBots()`
// at CONSTRUCTION time — before any external `intent()` has run at all.
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
assert.equal(session.isBot(0 as PlayerIndex), true);
assert.equal(session.isBot(1 as PlayerIndex), false);
const botPush = session.connect(0 as PlayerIndex);
const humanPush = session.connect(1 as PlayerIndex);
assert.equal(botPush.menu, null, 'the bot seat was handed a real decision to make');
assert.notEqual(humanPush.menu, null, 'nobody was left with a turn to take — the bot never played');
});
it('botSeats round-trips through exportSave/resumeSession', () => {
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
const resumed = resumeSession(session.exportSave());
assert.equal(resumed.isBot(0 as PlayerIndex), true);
assert.equal(resumed.connect(0 as PlayerIndex).menu, null, 'a resumed bot seat still never gets a real decision');
});
it('a bot seat is driven forward after a human intent too, not only at construction', () => {
// Two bots and one human: whichever of the two non-human seats comes up next after the human's
// own move must be played automatically, with no external `intent()` for either of them.
const session = createSession(11, config, ['Alice', 'Bot', 'Bot'], [1 as PlayerIndex, 2 as PlayerIndex]);
const before = session.exportSave().history.length;
const applied = session.intent(0 as PlayerIndex, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(applied.accepted, true);
assert.equal(session.connect(1 as PlayerIndex).menu, null, 'bot seat 1 was left with a real decision');
assert.equal(session.connect(2 as PlayerIndex).menu, null, 'bot seat 2 was left with a real decision');
// Not a strict proof either bot actually moved (the human's own turn may not have ended yet),
// but the history can only ever have grown, never shrunk, and never rejected mid-drive.
assert.ok(session.exportSave().history.length >= before + 1);
});
});
describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
it('exportSave carries enough to reconstruct the exact same game', () => {
const session = createSession(42, config, ['Alice', 'Bob']);