/** * The game session host — Phases 2 and 3 of `docs/architecture/multiplayer.md` (§8, §12 steps 9, * 14-16). * * Pure logic, no sockets, no filesystem — `src/server/http.ts` is the thin wiring that calls into * this, and `src/server/persistence.ts` is what actually reads/writes disk. One `GameSession` per * in-memory game (Phase 2 is one game per process; Phase 4 generalizes to many). * * REUSES `src/web/game.ts`'s `Game`/`submit`/`drain`/`currentActor`/`actionMenu` wholesale — all pure * logic already, with no DOM dependency, exactly as `LocalSession` uses them client-side. Building a * second copy of "apply an intent, narrate it, compute the Menu" here would drift from what solitaire * already does and is tested against. * * ONE THING `game.ts`'s `submit` DOES NOT DO: verify who is calling it. `submit(game, intent)` reads * `currentActor(game)` itself and applies the intent AS that player, regardless of who asked — safe * for `LocalSession` (there is only ever one possible caller: the one browser). A server has more than * one seat submitting, so THIS file is where "is `seat` actually allowed to act right now?" has to be * checked, before `submit` is ever called — see `intent()` below. */ import { check } from '../engine/apply.ts'; import { legalActions } from '../engine/legal.ts'; import type { Intent } from '../engine/intents.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultiplayerGame, submit } from '../web/game.ts'; import type { Game, Menu } from '../web/game.ts'; import { deltaFrame } from '../sim/frame-delta.ts'; import type { FrameDelta } from '../sim/frame-delta.ts'; import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts'; import type { Frame, PublicFrame } from '../sim/view.ts'; import { takeSteps } from '../sim/display-step.ts'; import type { DisplayStep } from '../sim/display-step.ts'; import { developerBot } from '../sim/bot.ts'; export type Push = { /** * Absent on a presence-only push (below) — there is no board delta to send when nothing about the * GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame * for a seat that may not even be in this game's connection table (a lobby-stage notice has none). */ frame?: FrameDelta; /** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */ menu: Menu | null; /** Narration since the LAST push to this specific seat, not the whole game's log. */ lines: { text: string; tone: string }[]; /** * Connection news about OTHER seats — never this push's own recipient. `lobby-and-sessions.md` * §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go * through the engine or the shared narration log (which must stay replayable from a seed). Built * and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never * sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no * `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`. * * A LIST, since 2026-08-23: a connecting client is told about every other seat at once. It used to * learn of a seat only when that seat disconnected AFTER it connected, so a player arriving at a * table where two people had not shown up yet was told nothing at all. `seen` distinguishes "was * here and dropped" from "has never opened the game". */ presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[]; /** * THE FOUR TRANSIENT SIGNALS (2026-08-23) — what solitaire has always drawn and multiplayer never * did: sound cues, the timetable slot a D12 just filled, a one-line announcement, and the card * that just came into this seat's hand. * * The first three are SHARED — a collision anywhere on the Division, the Stage bell, a train * running off the end pays everyone — so they are identical in every seat's push and drained once * per broadcast. `justDrawn` is not: it goes ONLY to the seat that drew it (`game.justDrawn` is * one field for the whole game and does not say whose). `test/redaction.test.ts` is the guard. */ cues?: string[]; scheduled?: number | null; announcement?: string | null; justDrawn?: string | null; /** * ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. * * A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for * its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore * needs no change at all — `broadcastGame` forwards whatever this file builds. * * IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A * player's own hand, menu and objective are not animated: they arrive on the same push, already * coalesced, exactly as they always have. That is what keeps the redaction surface at zero new * area — `test/redaction.test.ts` guards the projection these are built from. */ steps?: DisplayStep[]; /** * The public board to start a step queue from — sent on a CONNECT, never on an update. * * Steps carry deltas against one chain shared by the whole table, so a client that has just * arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would * rightly throw. This is that baseline: the exact frame the chain has reached, so the next step * lands on it. A reconnecting client resets rather than replaying what it missed — the history * panel is what carries the words, and it is already sent whole on connect (#97). */ publicReset?: PublicFrame; }; /** * `lobby-and-sessions.md` §5 — "wall-clock at the start of a turn, wall-clock at the intent that * ends it, per player per phase." Computed entirely here, never touching the engine (which has no * clock and must stay deterministic) and never stored inside `history` (a replay must reproduce a * game from decisions alone). */ export type TurnTiming = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number; endedAt: number; }; /** Everything `persistence.ts` needs to write `game.json` and rebuild a session from it later. */ export type SavedGame = { seed: number; config: GameConfig; playerNames: string[]; history: Intent[]; status: 'active' | 'finished'; createdAt: number; /** * Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby * time only, never mid-game). Persisted so a bot seat is still a bot after a server restart — * `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean * guessing from a display name rather than reading a fact. */ botSeats: PlayerIndex[]; /** * Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart. * * Optional because it postdates the format, and defaulted to `createdAt` when absent — a game * whose last move is unrecorded reads as untouched since it began, which is the honest answer * rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same * reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision. */ lastMoveAt?: number; }; /** What an administrator needs to see about a game without replaying it themselves. */ export type GameSummary = { playerCount: number; playerNames: string[]; botSeats: PlayerIndex[]; status: 'active' | 'finished'; createdAt: number; lastMoveAt: number; day: number; stage: number; phase: string; /** * Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and * a finished game waits on nobody. */ waitingOn: { seat: PlayerIndex; name: string } | null; }; export type IntentResult = | { accepted: true; pushes: Map; timing: TurnTiming | null } | { accepted: false; code: string }; export type GameSession = { readonly playerCount: number; isBot(seat: PlayerIndex): boolean; /** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */ connect(seat: PlayerIndex): Push; intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; /** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */ exportSave(): SavedGame; /** * A cheap description of where this game has got to. Deliberately does not copy `history` the * way `exportSave` must — the health check polls this on a timer, and an administrator listing * games wants the state of each, not a copy of every intent in all of them. */ summary(): GameSummary; }; type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number }; function buildSession( game: Game, playerNames: string[], createdAt: number, botSeats: Set, lastMoveAtInit: number, ): GameSession { let lastMoveAt = lastMoveAtInit; /** Who drew the card `game.justDrawn` names. The engine records WHICH card came into hand but not * whose hand it went into, and that is the whole difference between a badge and a leak. */ let lastDraw: { seat: PlayerIndex; cardId: string } | null = null; const lastSeq = new Map(); const lastFrame = new Map(); const sentLines = new Map(); const openSpanFor = (now: number): OpenSpan | null => { const actor = currentActor(game); if (actor === null) return null; return { player: actor, phase: game.state.clock.phase, day: game.state.clock.day, stage: game.state.clock.stage, startedAt: now }; }; let open: OpenSpan | null = openSpanFor(Date.now()); /** * A seat's full Frame, with `lines` deliberately EMPTY (#97). * * Narration reaches a client by ONE path — `Push.lines` — because `RemoteSession` * (`web/session.ts`) accumulates from that field alone and its `lines()` returns the accumulator. * Passing `game.log` here serialised the entire log into every frame for every seat, where it grew * all game and was thrown away on arrival, while `linesSince` correctly sent the same text beside * it. The duplicate was not merely waste: it masked the reconnect bug `connect` fixes below. */ function frameFor(seat: PlayerIndex): Frame { return snapshot(game.state, [], null, null, null, false, seat); } function linesSince(seat: PlayerIndex): { text: string; tone: string }[] { const already = sentLines.get(seat) ?? 0; sentLines.set(seat, game.log.length); return game.log.slice(already); } function menuFor(seat: PlayerIndex): Menu | null { return seat === currentActor(game) ? actionMenu(game, seat) : null; } /** What the last batch of events earned, drained from the game exactly once and then handed to * every seat. `null` on a (re)connect: a fresh connection is drawing a STATE, and replaying the * sounds of everything it missed would be a burst of noise about the past. */ type Moment = { cues: string[]; scheduled: number | null; announcement: string | null }; function takeMoment(): Moment { const cues = game.cues.splice(0, game.cues.length); const scheduled = game.scheduled; game.scheduled = null; const announcement = game.announced; game.announced = null; return { cues, scheduled, announcement }; } function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push { const frame = frameFor(seat); const delta = deltaFrame(lastFrame.get(seat) ?? null, frame); lastFrame.set(seat, frame); const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) }; if (steps.length > 0) push.steps = steps; if (moment) { if (moment.cues.length > 0) push.cues = moment.cues; if (moment.scheduled !== null) push.scheduled = moment.scheduled; if (moment.announcement !== null) push.announcement = moment.announcement; } // The drawer's own card, and nobody else's — on a reconnect too, so the badge survives a refresh. if (lastDraw !== null && lastDraw.seat === seat) push.justDrawn = lastDraw.cardId; return push; } function pushesForAll(): Map { const moment = takeMoment(); // Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and // `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and // an empty list to everybody else. const steps = takeSteps(game.display); const out = new Map(); for (let seat = 0; seat < playerNames.length; seat++) { out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps)); } return out; } /** * Closes the open span if the acting player, phase, Day or Stage moved since it opened, and opens * the next one (or none, in the automatic Mainline Phase). Returns the just-closed span, if any, * for the caller to persist — a "turn" here is exactly the contiguous stretch where none of those * four things changed, which can span several intents (draw, then play, then end) as one span. */ function settleTiming(): TurnTiming | null { const now = Date.now(); const next = openSpanFor(now); const same = open !== null && next !== null && open.player === next.player && open.phase === next.phase && open.day === next.day && open.stage === next.stage; if (same) return null; const closed: TurnTiming | null = open === null ? null : { ...open, endedAt: now }; open = next; return closed; } /** * PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends. * * `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never * takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at * `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a * client with a `seq` to dedupe against, and its choice is by construction always legal (`check` * would only ever confirm what `legalActions` already promised). * * Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded — * `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides * in zero wall-clock time by definition. Called once at construction (a resume could land exactly * on a bot's turn) and once after every accepted human intent. */ /** * §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote. * * "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then * bots vote yes too. If a human votes no, it's not unanimous, it ends right then. If only bots are * playing, they never vote to extend" (Jesse, 2026-08-28). * * Which makes a bot's vote a formality rather than a policy decision, performed once the humans * have already settled it, so that the unanimity the engine checks is a real unanimity rather than * a special case carved into the rules for absent players. * * THE ALL-BOT TABLE IS THE CASE TO GET RIGHT, and getting it wrong hung the game. `driveBots` * cannot reach the vote — it loops on `currentActor`, which is null the moment the game stops — * so if this returns early with no humans to follow, nobody votes at all and a bot-only game sits * on the question for ever. It happened: an all-bot session never reached `finished`. With nobody * to follow, the bots' own answer stands, and it is no. */ function driveBotVotes(): void { if (game.state.status !== 'awaitingExtension') return; const humans = [...Array(playerNames.length).keys()].filter((p) => !botSeats.has(p)); // A human who has voted `false` has already ended the game, so reaching here with humans still // outstanding means the table is genuinely waiting on a person. Bots wait with it. if (humans.length > 0 && !humans.every((p) => game.state.extensionVotes[p] === true)) return; const agree = humans.length > 0; for (const seat of botSeats) { if (game.state.extensionVotes[seat] === null) { submit(game, { type: 'game.extend', player: seat, agree }, seat); } } } /** * Bots play, bots vote, and an agreed extension puts them back to playing — so the two drivers * alternate rather than running once each. Bounded because every pass must consume something: a * turn, or a vote that cannot be cast twice. */ function driveBotTurns(): void { for (let pass = 0; pass < 1_000; pass++) { const before = game.history.length; driveBots(); driveBotVotes(); if (game.history.length === before) return; } throw new Error('driveBotTurns: probable infinite loop'); } function driveBots(): void { let guard = 0; for (;;) { const actor = currentActor(game); if (actor === null || !botSeats.has(actor)) return; if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`); const options = legalActions(game.state, actor); if (options.length === 0) return; const choice = developerBot.choose(game.state, actor, options); const drawnBefore = game.justDrawn; const ok = submit(game, choice); if (game.justDrawn !== drawnBefore && game.justDrawn !== null) { lastDraw = { seat: actor, cardId: game.justDrawn }; } /* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */ if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`); settleTiming(); } } driveBotTurns(); // Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped // here rather than fired at the first client to arrive. (It also stops `game.cues` growing without // bound on a server, which nothing was draining before this.) takeMoment(); /** * THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0). * * Left in the collector they would be delivered on the FIRST broadcast after somebody connects — * but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying * them onto it would draw positions the game had already left. The plan says as much: opening bot * moves need no replay, and a later display simply receives the final reset. * * This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains * it synchronously at the end of every `intent()`, so between moves it is always empty — which is * what makes dropping here safe rather than a race with a seat that has not been sent them yet. */ takeSteps(game.display); return { playerCount: playerNames.length, isBot: (seat) => botSeats.has(seat), connect(seat) { // A (re)connect always starts from a clean slate — no cache to trust across a lost connection // (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta. lastFrame.delete(seat); // ...and the NARRATION watermark with it (#97). The browser this answers has just reloaded // from an empty accumulator, so a seat told "nothing new since your last push" came back to a // blank history panel mid-game, with the server holding the whole log. `Push.lines` on a // connect IS the history, which is what lets the Frame stop carrying a second copy. sentLines.delete(seat); const push = pushFor(seat, null); /** * The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame * the shared delta chain has reached, so the next step merges onto it; before any step has * been collected there is no chain yet and a fresh projection is the same thing. */ push.publicReset = game.display.last ?? publicSnapshot(game.state); return push; }, intent(seat, seq, i) { // Idempotent resend (protocol.md §5): a repeat of an ALREADY-APPLIED seq is a no-op success, // with no pushes to re-broadcast. A repeat after a REJECTION is not remembered here — it was // never applied, so it is worth trying again (see the `lastSeq.set` below: only on success). if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null }; /** * §3.3, EXTENDED PLAY (Gitea#11) — the vote is the one intent with no actor to be. * * `currentActor` is null once the timetable has run out, so this guard would refuse every * vote with NOT_YOUR_TURN. Every seat may vote, and `check` is still the authority on whether * this particular seat may vote right now (it has voted already; the game is not waiting on a * vote at all), so skipping the turn test here gives nothing away. */ if (!isOutOfTurn(i) && seat !== currentActor(game)) { return { accepted: false, code: 'NOT_YOUR_TURN' }; } // Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a // "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise // broadcast one seat's illegal attempt to the whole table on their next push (rejection // feedback is private, to the submitter only); and calling `check` first means an intent // already known to be illegal never touches the engine at all. const code = check(game.state, seat, i); if (code) return { accepted: false, code }; const drawnBefore = game.justDrawn; const applied = submit(game, i, isOutOfTurn(i) ? seat : null); if (game.justDrawn !== drawnBefore && game.justDrawn !== null) { lastDraw = { seat, cardId: game.justDrawn }; } /* c8 ignore next -- `check` above already proved this intent is legal; `submit` cannot then refuse it. */ if (!applied) return { accepted: false, code: 'REJECTED' }; lastSeq.set(seat, seq); lastMoveAt = Date.now(); const timing = settleTiming(); // Any bot due to act now plays out entirely before this push goes back — the delta mechanism // diffs against whatever was last sent, so it captures the bots' moves along with the human's // in one push regardless of how many turns that took. Votes included, since Gitea#11: a human // agreeing to another Day is exactly the move the bots are waiting on to agree themselves. driveBotTurns(); return { accepted: true, pushes: pushesForAll(), timing }; }, exportSave() { return { seed: game.seed, config: game.state.config, playerNames: [...playerNames], history: [...game.history], // `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps // to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11). status: game.state.status === 'finished' ? 'finished' : 'active', createdAt, botSeats: [...botSeats], lastMoveAt, }; }, summary() { const actor = currentActor(game); return { playerCount: playerNames.length, playerNames: [...playerNames], botSeats: [...botSeats], // `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps // to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11). status: game.state.status === 'finished' ? 'finished' : 'active', createdAt, lastMoveAt, day: game.state.clock.day, stage: game.state.clock.stage, phase: game.state.clock.phase, waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${seatLabel(actor)}` }, }; }, }; } export function createSession( seed: number, config: GameConfig, playerNames: string[], botSeats: PlayerIndex[] = [], ): GameSession { const now = Date.now(); return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now); } /** * Phase 3 — rebuild a session from a persisted `SavedGame` (`persistence.ts`). The engine-version * check happens before this is ever called; by the time `saved.history` reaches here it is already * known to have been recorded under the currently-running rules. */ /** * A resume that could not complete, and exactly where it gave up. `index.ts` turns this into the * refusal it logs, so the operator is told which move the current rules will not accept rather * than only that some version string differs. */ export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string }; export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } { const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); if (stopped) { return { ok: false, failure: { stoppedAt: stopped.index, of: saved.history.length, intent: stopped.intent.type, code: stopped.code }, }; } return { ok: true, session: build(game, saved) }; } /** * Throws on a save the current rules will not replay. Kept for callers that have already * established the save is good — the server boots through `tryResumeSession`, which answers * instead of throwing. */ export function resumeSession(saved: SavedGame): GameSession { const r = tryResumeSession(saved); if (!r.ok) { throw new Error( `save does not replay under the current rules: intent ${r.failure.stoppedAt + 1} of ` + `${r.failure.of} (${r.failure.intent}) was rejected with ${r.failure.code}`, ); } return r.session; } function build(game: Game, saved: SavedGame): GameSession { return buildSession( game, saved.playerNames, saved.createdAt, new Set(saved.botSeats), saved.lastMoveAt ?? saved.createdAt, ); }