Files
station-master/src/server/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

549 lines
26 KiB
TypeScript

/**
* 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<PlayerIndex, Push>; 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<PlayerIndex>,
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<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
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<PlayerIndex, Push> {
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<PlayerIndex, Push>();
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,
);
}