Files
station-master/src/web/session.ts
T
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.

Three things lined up. start() enters a remembered session WITHOUT checking
it still exists — that is what makes reconnection seamless, and it is why
the lobby was skipped. The v0.5.4 update had refused to resume that game,
its save being recorded under v0.5.3 and the engine-version check being
exact (D7). And createRemoteSession had no onerror at all, so EventSource
retried the resulting 404 forever in silence while frame stayed null and
nothing rendered. The only escape was clearing site data, and nothing on
screen said so.

v0.5.3's Manage Game -> End had just widened the same dead end: it closes
every watcher's stream, so a player whose game an administrator ended would
sit frozen on a stale board indefinitely, for exactly the same reason.

GET /api/session?token= is new: a cheap yes/no on whether a token still
names a live game. EventSource fires error identically for a transient blip
— the expected shape of a game idle for minutes (§9) — and for a 404 it
will retry forever, and exposes no status code either way, so the client
asks rather than guessing. Only a definite 404 closes the stream and
reports the game gone; a flaky network still self-heals.

The page then forgets the stored session, says why (ended by an
administrator, or the service was updated, which does not carry games
across), and drops into the lobby. Forgetting the token is what stops the
next load repeating it. It also stops rendering nothing while it waits —
"… connecting to the game" sits in the presence banner until the first push
arrives, because a page showing nothing is indistinguishable from a broken
one, which is what this looked like.

Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every
game in progress, and v0.5.4's changes were rendering only. The refusal is
right, but the test is exact equality against the PACKAGE version, which
moves for reasons unrelated to the rules. Three options costed; the
recommendation is to replay the save and refuse only if an intent actually
rejects — the real question rather than a proxy for it, and a full replay
measures ~100 ms.

Verified live: /api/session answers 200 for a seated token, 404 once an
administrator ends the game, 404 for a garbage token, and /api/stream 404s
in the same state — which is the response EventSource had been retrying
silently. 673 tests pass.
2026-08-21 17:55:59 -04:00

327 lines
13 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 } from '../sim/view.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,
newGame,
overHandLimit,
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;
/** True when the hand is over §6.2's limit and the turn cannot be ended. */
overHandLimit(): boolean;
/** 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, so the page can tell "never heard from" apart from "was here, then left."
*/
presence(): { seat: PlayerIndex; connected: boolean }[];
};
/**
* 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);
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),
overHandLimit: () => overHandLimit(game),
handPlayable: () => handPlayable(game),
submit: async (intent: Intent) => {
const ok = submit(game, intent);
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: () => [],
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;
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;
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 }[];
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
* 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, boolean>();
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];
if (push.presence) presence.set(push.presence.seat, push.presence.connected);
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,
overHandLimit: () => need().overHandLimit,
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,
takeCues: () => [],
takeScheduled: () => null,
takeAnnouncement: () => null,
justDrawn: () => null,
presence: () => [...presence].map(([s, connected]) => ({ seat: s, connected })),
};
}