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
458 lines
20 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|