Files
station-master/src/web/session.ts
T
Jesse.MarkowitzandClaude Opus 5 02289e94b8 v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.

Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.

Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.

Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 15:31:46 -04:00

458 lines
20 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;
};
/**
* 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;
let nextSeq = 1;
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>) => {
const push = JSON.parse(ev.data) as Push;
// 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> {
const seq = nextSeq++;
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;
},
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();
},
};
}