v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted

Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* 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 type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { actionMenu, currentActor, fromMultiplayerSave, 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 { snapshot } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
export type Push = {
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 }[];
};
/**
* `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;
};
export type IntentResult =
| { accepted: true; pushes: Map<PlayerIndex, Push>; timing: TurnTiming | null }
| { accepted: false; code: string };
export type GameSession = {
readonly playerCount: number;
/** 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;
};
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
function buildSession(game: Game, playerNames: string[], createdAt: number): GameSession {
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());
function frameFor(seat: PlayerIndex): Frame {
return snapshot(game.state, game.log, 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;
}
function pushFor(seat: PlayerIndex): Push {
const frame = frameFor(seat);
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
lastFrame.set(seat, frame);
return { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
}
function pushesForAll(): Map<PlayerIndex, Push> {
const out = new Map<PlayerIndex, Push>();
for (let seat = 0; seat < playerNames.length; seat++) out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex));
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;
}
return {
playerCount: playerNames.length,
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);
return pushFor(seat);
},
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 };
if (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 applied = submit(game, i);
/* 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);
const timing = settleTiming();
return { accepted: true, pushes: pushesForAll(), timing };
},
exportSave() {
return {
seed: game.seed,
config: game.state.config,
playerNames: [...playerNames],
history: [...game.history],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
};
},
};
}
export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.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.
*/
export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
return buildSession(game, saved.playerNames, saved.createdAt);
}