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
+27
View File
@@ -85,6 +85,33 @@ export const SOLO_CONFIG: GameConfig = {
houseRules: DEFAULT_HOUSE_RULES,
};
/**
* The lobby's (`lobby.ts`) starting point for a Competitive or Co-op `Lobby.Create` — same formula
* `SOLO_CONFIG` uses, at a nominal player count. `minCombinedRevenue` is necessarily a guess at
* create time: nobody is seated yet, so there is no real headcount to size it against. It stays a
* guess rather than being fixed up at `Lobby.Start`, matching what the New Game dialog already told
* players before there was a lobby at all ("assumes a 4-player table until there's a lobby to ask
* who's actually seated") — Phase 5 or a follow-up can revisit sizing it to the seats actually
* filled once that is worth the complexity.
*/
export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = 4): GameConfig {
return {
mode,
days: DEFAULT_DAYS,
minCombinedRevenue: collectiveRevenueFloor(players, DEFAULT_DAYS),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: mode === 'competitive',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
houseRules: DEFAULT_HOUSE_RULES,
};
}
/**
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
+169
View File
@@ -0,0 +1,169 @@
/**
* The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20).
*
* Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, creating or joining a
* game by code, and the seating screen up to `Lobby.Start`. `main.ts` calls `runLobby` once, at
* `start()`, only when there is no stored session to reconnect with — see `main.ts`'s own comment
* on why a stored `{token, gameId, seat}` skips this module entirely.
*
* MIRRORS SERVER TYPES RATHER THAN IMPORTING THEM, same choice `web/session.ts` already made for
* `Push`: this file must never depend on anything under `src/server/`, even at the type level, since
* it ships to the browser and the server does not.
*/
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { defaultMultiplayerConfig } from './game.ts';
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
type Lobby = {
gameId: string;
gameCode: string;
hostToken: string;
config: GameConfig;
seats: LobbySeat[];
joinOrder: string[];
createdAt: number;
};
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
/** Per-origin, same reasoning `lobby-and-sessions.md` §1 gives for the session token itself — a
* secret typed at one address means nothing at another. */
const SECRET_KEY = 'stationmaster-joinsecret';
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/**
* Shows `#lobby`, drives it through creating or joining a game and then seating, and calls
* `onReady` exactly once — the instant `Lobby.Start` fires, from WHICHEVER browser tab started it.
* Never calls back more than once; the caller is expected to tear this screen down (`main.ts` hides
* `#lobby` and shows `#gameui`) as its very first action inside `onReady`.
*/
export function runLobby(onReady: (r: LobbyReady) => void): void {
$('lobby').hidden = false;
$<HTMLInputElement>('lb-secret').value = localStorage.getItem(SECRET_KEY) ?? '';
let source: EventSource | null = null;
function setError(id: string, message: string): void {
$(id).textContent = message;
}
function secret(): string {
const value = $<HTMLInputElement>('lb-secret').value;
localStorage.setItem(SECRET_KEY, value);
return value;
}
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`;
const isHost = lobby.hostToken === token;
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
let html = '';
for (let seat = 0; seat < cap; seat++) {
const occupant = lobby.seats[seat] ?? null;
const isYou = occupant?.kind === 'human' && occupant.token === token;
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
const who =
occupant === null
? '<span class="dim">— empty —</span>'
: occupant.kind === 'bot'
? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
let action = '';
if (isHost) {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
}
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`;
}
$('lb-seats').innerHTML = html;
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
}
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-remove'))) {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
}
const filled = lobby.seats.filter((s) => s !== null).length;
const noGaps = filled === lobby.seats.length;
const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4;
const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost;
startBtn.disabled = !(noGaps && legalCount);
$('lb-start-note').textContent = isHost
? noGaps && legalCount
? ''
: 'Needs 2–4 seated players (human or bot), no empty seats in between.'
: 'Waiting for the host to start the game.';
startBtn.onclick = () => {
void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
if (status !== 200) setError('lb-start-note', String(body['error'] ?? 'could not start'));
});
};
}
function enterSeating(gameId: string, token: string): void {
$('lb-choice-section').hidden = true;
$('lb-seating-section').hidden = false;
source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`);
source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as LobbyPush;
if (push.started) {
source?.close();
onReady({ token, gameId, seat: push.you });
return;
}
renderSeating(push.lobby, push.you, token);
};
}
$<HTMLButtonElement>('lb-create').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
'competitive') as 'competitive' | 'coop';
if (displayName === '') {
setError('lb-create-err', 'enter a display name first');
return;
}
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then(
({ status, body }) => {
if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
return;
}
setError('lb-create-err', '');
enterSeating(body['gameId'] as string, body['token'] as string);
},
);
};
$<HTMLButtonElement>('lb-join').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const gameCode = $<HTMLInputElement>('lb-code').value.trim();
if (displayName === '' || gameCode === '') {
setError('lb-join-err', 'enter a display name and a game code');
return;
}
void postJson('/api/lobby/join', { secret: secret(), gameCode, displayName }).then(({ status, body }) => {
if (status !== 200) {
setError('lb-join-err', String(body['error'] ?? 'could not join that game'));
return;
}
setError('lb-join-err', '');
enterSeating(body['gameId'] as string, body['token'] as string);
});
};
}
+85 -13
View File
@@ -25,9 +25,18 @@ import type { NewGameOptions } from './game.ts';
import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
const SAVE_KEY = 'station-master.save.v1';
const SETTINGS_KEY = 'station-master.settings.v1';
/**
* The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh
* `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from
* `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between
* browsers; this is a credential for THIS origin's server and must never be treated as one.
*/
const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
@@ -298,27 +307,58 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
return `?${params}`;
}
function loadRemote(): LobbyReady | null {
try {
const raw = localStorage.getItem(REMOTE_KEY);
return raw ? (JSON.parse(raw) as LobbyReady) : null;
} catch {
return null;
}
}
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
* ever flashes before `start()` decides which one this load actually needs. */
function showScreen(which: 'lobby' | 'gameui'): void {
document.getElementById('lobby')!.hidden = which !== 'lobby';
document.getElementById('gameui')!.hidden = which !== 'gameui';
}
/**
* `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and
* URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from
* whatever the server was configured with, not from this browser's URL or `localStorage`.
* The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s
* `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
* so it is always written back here before anything else happens.
*/
function beginRemote(ready: LobbyReady): void {
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
showScreen('gameui');
session = createRemoteSession(ready.token, ready.seat);
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render);
}
/**
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
* cannot produce one. `start()`'s job is only to decide which of three screens this load is: back
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/
function start(): void {
const params = new URLSearchParams(location.search);
const seatParam = params.get('seat');
if (seatParam !== null) {
const seat = Number(seatParam) as PlayerIndex;
session = createRemoteSession(seat, params.get('secret') ?? '');
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render);
const remembered = loadRemote();
if (remembered) {
beginRemote(remembered);
return;
}
showScreen('gameui');
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
@@ -354,6 +394,23 @@ function applyCapabilities(): void {
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both.
hide('multiplayer', c.newGame);
}
/**
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
* has anything to report), and empty again the moment everyone reports back in — `#presence:empty`
* collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read.
*/
function renderPresence(f: Frame): void {
const away = session
.presence()
.filter((p) => !p.connected)
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`);
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
}
function render(): void {
@@ -379,6 +436,7 @@ function render(): void {
}
renderTurnChart(f);
renderPresence(f);
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -1180,6 +1238,20 @@ if (saveBtn) saveBtn.onclick = downloadSave;
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
* deal the same game again and look like the button had done nothing.
*/
const multiplayerBtn = document.getElementById('multiplayer');
if (multiplayerBtn) {
multiplayerBtn.onclick = () => {
// Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same
// reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session.
if (!isLocal(session)) return;
const f = session.view();
const started = f.status === 'active' && (f.day > 1 || f.stage > 1);
if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return;
showScreen('lobby');
runLobby(beginRemote);
};
}
const newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
+60 -1
View File
@@ -73,6 +73,14 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
#lobby{max-width:640px;margin:0 auto;padding:14px}
#lobby h2{margin-top:0}
#lobby h3{margin-bottom:2px}
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
.lb-seat:last-child{border-bottom:none}
.lb-seat .who{flex:1}
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
#presence:empty{display:none}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
@@ -210,6 +218,50 @@ ul.blocked li{padding:2px 0}
</head>
<body>
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
`#newgamedlg` at the very end of the body is solitaire-only and untouched by any of this. -->
<div id="lobby" hidden>
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
<section id="lb-secret-section">
<h2>Join secret</h2>
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
</section>
<section id="lb-choice-section">
<h2>Create or join a game</h2>
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
<h3>Create a new game</h3>
<p class="ng-note">You become the host — you choose the mode and, once everyone's seated, start the game. 2 to 4 players.</p>
<label class="ng-radio"><input type="radio" name="lb-mode" value="competitive" checked>
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p>
<h3>Join a game</h3>
<p class="ng-note">Ask whoever created the game for its code.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-join">Join game</button>
<p class="dim" id="lb-join-err" role="alert"></p>
</section>
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
<section id="lb-seating-section" hidden>
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2>
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p>
<div id="lb-seats"></div>
<button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p>
</section>
</div>
<div id="gameui" hidden>
<div class="topbar">
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
@@ -233,6 +285,7 @@ ul.blocked li{padding:2px 0}
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -247,6 +300,10 @@ ul.blocked li{padding:2px 0}
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<div id="announce"></div>
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
<div id="presence"></div>
<main>
<div>
@@ -314,6 +371,8 @@ ul.blocked li{padding:2px 0}
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
==================================================================== -->
</div><!-- /gameui -->
<dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
@@ -365,7 +424,7 @@ ul.blocked li{padding:2px 0}
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Multiplayer needs a server — coming soon.</span>
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>
+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 })),
};
}