Files
station-master/src/web/session.ts
T
Jesse.MarkowitzandClaude Fable 5.1 e47cd3d400 v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of
the five server faults were in the one file no test had ever stood up; `http.ts` now has an
end-to-end suite on a real port. CHANGELOG has the reasoning.

SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and
move for whoever took the seat next — revoked now, in memory and on disk. The browser
numbered intents from 1 per page load while the server remembered the seat's last number,
so the first move after a reload was swallowed as a resend — the connect push carries the
count and the client continues from it. Nothing serialised moves within a game and every
write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200),
and the boot's bare `JSON.parse` then took every game down — per-path write queues, a
per-game move queue, and a boot that skips one bad file. An error after the SSE head was
sent crashed the process. Bodies were unbounded before any secret check.

BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed
submit is `false`, not an unhandled rejection. The documentation renderer flattened nested
bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel
promised cars the engine refuses; it asks `acceptsCar` now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:32 -04:00

497 lines
22 KiB
TypeScript

/**
* The boundary between the page and the game.
*
* The page draws a `Frame` and offers a `Menu`, and submits intents. It does not care whether the
* rules are being applied a function call away or across a network — which is the whole point:
*
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
* with no server involved at any point.
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no
* authoritative state at all. It cannot: it has neither the deck order nor the other players'
* hands, and if it did the game would be cheatable.
*
* So this interface is deliberately the SMALLER of the two — everything a remote client could
* possibly offer, and nothing that only a local one can do. What a local session can do beyond it is
* declared in `capabilities`, and the page hides those controls rather than calling them and failing.
*/
import type { Intent } from '../engine/intents.ts';
import type { Frame, PublicFrame } from '../sim/view.ts';
import { publicSnapshot } from '../sim/view.ts';
import { takeSteps } from '../sim/display-step.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { applyDelta } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
import {
actionMenu,
configWith,
currentActor,
fromSave,
handPlayable,
isOutOfTurn,
newGame,
submit,
toSave,
undo,
view,
} from './game.ts';
/**
* What this session can do beyond the common interface.
*
* None of these survive a server. Undo would have to un-see what other players have already seen;
* a local save is meaningless when the server is the store; and dealing a new game is the lobby's
* job. The page reads these rather than assuming, so the same code drives both.
*/
export type Capabilities = {
undo: boolean;
saveLocal: boolean;
newGame: boolean;
};
export type Session = {
/** The board as this seat sees it. */
view(): Frame;
/** What this seat may do right now. */
menu(): Menu;
/** Which seat this client is playing. */
seat(): PlayerIndex;
/** Whose turn it is, or null when the game is over or waiting on nothing. */
actor(): PlayerIndex | null;
/** Which cards in hand are playable right now, in hand order. */
handPlayable(): boolean[];
/**
* Propose an action. Resolves false if the rules refused it.
*
* Async because a remote session must be, even though the local one answers immediately — a page
* written against a synchronous `submit` would have to be rewritten for the server.
*/
submit(intent: Intent): Promise<boolean>;
/** Called whenever something changed and the page should redraw. Returns an unsubscribe. */
subscribe(fn: () => void): () => void;
capabilities: Capabilities;
/**
* WHAT JUST HAPPENED — three transient signals the page uses to draw a moment rather than a state.
*
* They are separate from `view()` because two of them are CONSUMED: a Stage flash and a sound play
* once and are then gone, whereas the Frame can be rebuilt any number of times per render. Putting
* them on the Frame would mean re-flashing on every redraw.
*
* A remote session fills these from the server's pushes rather than from a local event log; the
* page cannot tell the difference. Whether they eventually ride on the Frame as animation hints is
* a Phase 2 question (`docs/architecture/multiplayer.md` D3).
*/
/** The narrated history, newest last. */
lines(): { text: string; tone: string }[];
/** Sounds earned since the last call. Draining. */
takeCues(): string[];
/** The timetable slot the last 1D12 filled, once. Draining. */
takeScheduled(): number | null;
/** A one-line announcement to flash, once. Draining. */
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.
*
* `seen` is what separates the two absences the page must not conflate: a seat that has connected
* at some point and dropped, against one that has never opened the game at all. The server now
* reports every other seat on connect (2026-08-23), so this is answerable at the moment a game
* starts — which is exactly when "is everyone here?" is the question.
*/
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
* WATCHED rather than discovered.
*
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
*
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
* button. Different thing entirely, hence the longer name.
*/
takeDisplaySteps(): DisplayStep[];
/**
* A public frame to start the queue from, once — draining, and non-null only when the queue must
* be RESET rather than advanced.
*
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
*/
takeDisplayReset(): PublicFrame | null;
/**
* Stop listening, for good.
*
* Only a remote session has anything to close, and only one caller needs it: a player LEAVING a
* running game (2026-08-23). Without it the page went back to the lobby with its `EventSource`
* still open, so the server — and therefore every other seat — went on reporting them as present
* at a table they had walked away from.
*/
close?(): void;
};
/**
* Everything a LOCAL session can additionally do. Kept off `Session` so that reaching for one of
* these in shared page code is a type error rather than a runtime surprise against a server.
*/
export type LocalSession = Session & {
readonly game: Game;
seed(): number;
save(): Save;
/** How many intents have been submitted — what the Undo button counts down. */
steps(): number;
/** Steps back one intent by replaying the history without it. Returns false at the start. */
undo(): boolean;
restore(save: Save): void;
};
/**
* A session that owns the engine in this process.
*
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
*
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
* far side of this that the page is allowed to decide. A config carries the mode and the optional
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
* the house rules and the victory-condition dials are what a player picks when they press New game.
*/
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
/**
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
* queue has to be told to start over rather than left holding a chain that no longer continues.
*/
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
return {
get game() {
return game;
},
view: () => view(game),
menu: () => actionMenu(game),
seat: () => 0,
actor: () => currentActor(game),
handPlayable: () => handPlayable(game),
submit: async (intent: Intent) => {
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
if (ok) changed();
return ok;
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
capabilities: { undo: true, saveLocal: true, newGame: true },
lines: () => game.log,
takeCues: () => game.cues.splice(0, game.cues.length),
takeScheduled: () => {
const slot = game.scheduled;
game.scheduled = null;
return slot;
},
takeAnnouncement: () => {
const text = game.announced;
game.announced = null;
return text;
},
justDrawn: () => game.justDrawn,
presence: () => [],
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
// multiplayer game. No separate code path — see `sim/display-step.ts`.
takeDisplaySteps: () => takeSteps(game.display),
takeDisplayReset: () => {
const reset = pendingReset;
pendingReset = null;
return reset;
},
seed: () => game.seed,
save: () => toSave(game),
steps: () => game.history.length,
undo() {
const back = undo(game);
if (!back) return false;
game = back;
// The rebuilt game replays its own history, so every cue and draw in it is old news.
back.cues.length = 0;
back.scheduled = null;
back.justDrawn = null;
back.announced = null;
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
pendingReset = publicSnapshot(back.state);
changed();
return true;
},
restore(save: Save) {
game = fromSave(save);
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
game.announced = null;
pendingReset = publicSnapshot(game.state);
changed();
},
};
}
/** 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 }[];
/** One entry for a change; every other seat at once on the connect push. */
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
steps?: DisplayStep[];
/** The baseline for the step queue, sent on a connect only. */
publicReset?: PublicFrame;
/**
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
*
* A remote session used to return nothing for any of them, so multiplayer had no sound at all, no
* timetable flash when the D12 filled a slot, no announcement when a completed run paid the table,
* and no highlight on the card you had just drawn — four things solitaire has had all along, and
* most of why a multiplayer game felt inert.
*
* `cues` and `announcement` are SHARED events (a collision anywhere, the Stage bell, a train
* leaving the Division) and reach every seat; `justDrawn` is the recipient's own card and nobody
* else's — `test/redaction.test.ts` covers exactly that.
*/
cues?: string[];
scheduled?: number | null;
announcement?: string | null;
justDrawn?: string | null;
/** Where the server's count of this seat's accepted intents stands — connect push only (v0.8.4). */
lastSeq?: number;
};
/**
* 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(
token: string,
seat: PlayerIndex,
/**
* Called once when this session's game is established to be gone for good, so the page can stop
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
* forever and reports nothing, and `frame` never becomes non-null.
*/
onGone?: () => void,
): Session {
let frame: Frame | null = null;
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
let displaySteps: DisplayStep[] = [];
let displayReset: PublicFrame | null = null;
let cues: string[] = [];
let scheduled: number | null = null;
let announcement: string | null = null;
let justDrawnCard: string | null = null;
/**
* NUMBERED FROM WHERE THE SERVER SAYS, NOT FROM 1 (v0.8.4).
*
* This started at 1 on every page load, and the server remembers a seat's last accepted number
* for the life of the game and answers a repeat with "already applied" (`protocol.md` §5). So a
* seat that had made one move, reloaded, and clicked again sent `seq: 1` twice: the server said
* ok, did nothing, pushed nothing, and the click looked dead. The connect push now carries the
* server's count and this continues from it — never backwards, in case a submit is in flight
* across a reconnect.
*/
let nextSeq = 1;
/** One submit in flight at a time — see `submit`. */
let inFlight = false;
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`);
/**
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
*
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
* the cheap question that separates them: only a definite 404 closes the stream and reports the
* game gone, so a flaky network still self-heals.
*/
let reportedGone = false;
source.onerror = () => {
if (reportedGone) return;
void fetch(`/api/session?${qs}`)
.then((r) => {
if (r.status !== 404 || reportedGone) return;
reportedGone = true;
source.close();
onGone?.();
})
.catch(() => {
// The probe itself failed, so this says nothing about the game — leave the retry running.
});
};
source.onmessage = (ev: MessageEvent<string>) => {
let push: Push;
try {
push = JSON.parse(ev.data) as Push;
} catch {
// A push that is not JSON is dropped rather than thrown out of an event handler nothing
// catches; the next push carries a full delta chain from what this seat was last sent.
return;
}
if (push.lastSeq !== undefined) nextSeq = Math.max(nextSeq, push.lastSeq + 1);
// 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];
for (const p of push.presence ?? []) presence.set(p.seat, { connected: p.connected, seen: p.seen });
// Accumulated rather than replaced: two pushes can arrive between two renders, and a cue that
// was earned is a cue that should be heard.
if (push.cues) cues = [...cues, ...push.cues];
if (push.scheduled !== undefined && push.scheduled !== null) scheduled = push.scheduled;
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
/**
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
*
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
* them onto the new baseline would draw a board that never existed. The history panel is what
* carries what was missed; the animation does not replay it (§ v0.8.0).
*/
if (push.publicReset) {
displayReset = push.publicReset;
displaySteps = [];
}
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
// that happened.
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
changed();
};
const need = (): Frame => {
if (frame === null) throw new Error('RemoteSession.view() called before the first Frame arrived');
return frame;
};
return {
view: need,
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
seat: () => seat,
actor: () => need().actor,
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
async submit(intent: Intent): Promise<boolean> {
/**
* ONE AT A TIME (v0.8.4). The page redraws the SAME menu the instant a submit is sent — the
* new one only arrives with the push — so a second click before the round trip posted a
* second, fresh `seq` for the same option, and the server, which de-duplicates on `seq`
* alone, applied it again when it was still legal: two cards drawn, two Moves spent, two cars
* coupled. A click that lands while one is in flight is dropped; the push is milliseconds away.
*/
if (inFlight) return false;
inFlight = true;
const seq = nextSeq++;
try {
const res = await fetch(`/api/intent?${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seq, intent }),
});
const result = (await res.json()) as { ok?: boolean; code?: string };
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
// not from this response — this only reports whether the rules accepted it.
return result.ok === true;
} catch {
// A network failure or a non-JSON answer used to reject out of a `void`ed promise — an
// unhandled rejection and nothing on screen. False is honest: the move was not confirmed.
return false;
} finally {
inFlight = false;
}
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
capabilities: { undo: false, saveLocal: false, newGame: false },
lines: () => lines,
// Draining, exactly as the local session's are: each of these marks a moment, so it plays once
// and is gone by the next render rather than re-firing on every redraw.
takeCues: () => {
const out = cues;
cues = [];
return out;
},
takeScheduled: () => {
const out = scheduled;
scheduled = null;
return out;
},
takeAnnouncement: () => {
const out = announcement;
announcement = null;
return out;
},
justDrawn: () => justDrawnCard,
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
takeDisplayReset: () => {
const reset = displayReset;
displayReset = null;
return reset;
},
close() {
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
// a game that vanished — `onGone` must not be called and land the page in "that game is no
// longer on this server".
reportedGone = true;
source.close();
listeners.clear();
},
};
}