/** * 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; /** 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(); 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) => { 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 { 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 })), }; }