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
+50 -8
View File
@@ -24,6 +24,7 @@ import {
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
HAND_LIMIT,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
@@ -37,7 +38,6 @@ import {
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
houseRules,
@@ -348,6 +348,18 @@ export type Frame = {
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
*/
houseRules: HouseRules;
/**
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
* and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on
* any `max*`/`minCombinedRevenue` field means that check is off.
*/
days: number;
minCombinedRevenue: number;
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
collisionsToday: number;
collisionsTotal: number;
status: GameState['status'];
outcome: GameState['outcome'];
/**
@@ -360,6 +372,13 @@ export type Frame = {
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
/**
* True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played
* down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than
* importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this
* from, only a `Frame`, so it has to already be resolved on the wire.
*/
overHandLimit: boolean;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
@@ -1207,6 +1226,12 @@ export function snapshot(
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
days: s.config.days,
minCombinedRevenue: s.config.minCombinedRevenue,
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday,
collisionsTotal: s.collisionsTotal,
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
@@ -1217,6 +1242,8 @@ export function snapshot(
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
@@ -1633,21 +1660,36 @@ const SIMPLE_CARDS = [
...ACTION_CARDS,
];
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
/**
* The goal, and whether the VIEWER's score is keeping up with the clock.
*
* `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses,
* not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the
* VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an
* approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress
* view of its own. `0` means no floor is configured — nothing to pace against.
*/
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const { days, minCombinedRevenue: target } = s.config;
const revenue = s.players[viewer]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
const daysLeft = Math.max(0, days - s.clock.day + 1);
const elapsed = days - daysLeft + 1;
if (target <= 0) {
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`;
return { target: 0, days, daysLeft, onPace: true, note };
}
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const expected = (target * elapsed) / days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
: `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
return { target, days, daysLeft, onPace, note };
}
/**