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
+26 -7
View File
@@ -876,7 +876,13 @@ export function mainlineCardCount(players: number): number {
return players + 1;
}
/** Provisional; not in the recovered files. */
/**
* Sourced, not provisional: `rules-v0.2.md`:339, "There are only a limited number of Crew Trays
* and engine pieces [Gap 4b: player count + 3]." The rules tie Crew Trays and engine pieces
* together as ONE combined resource, not two independently-tracked supplies — an engine is never
* conjured separately from the tray it rides in, and the game has no state for "an engine with no
* tray" or "a tray with no engine". `state.ts`'s `freeTrays` pool already IS the engine supply.
*/
export function crewTrayCount(players: number): number {
return players + 3;
}
@@ -1003,7 +1009,11 @@ export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
export const LABORER_ACTIONS_PER_LOAD = 4;
export const COLLISION_PENALTY = 5;
export const COLLISION_FLOOR_PER_DAY = 3;
/** Suggested defaults for `GameConfig`'s collision floors — see `state.ts`'s `GameConfig` doc. */
export const DEFAULT_MAX_COLLISIONS_PER_DAY = 3;
export const DEFAULT_MAX_COLLISIONS_TOTAL = 5;
/** Suggested default for `GameConfig.days`, all modes. */
export const DEFAULT_DAYS = 5;
/**
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
@@ -1011,18 +1021,27 @@ export const COLLISION_FLOOR_PER_DAY = 3;
*/
export const EXPEDITE_FAULT_PENALTY = 1;
/**
* The suggested default for `GameConfig.minCombinedRevenue` — a caller computes this before
* submitting a config; the engine itself never calls it (`state.ts`'s `GameConfig` doc explains why).
*/
export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days;
}
/**
* `GameLength` IS NOT PART OF `GameConfig` ANY MORE (2026-08-20) — `days` is a free variable there
* now, replacing the old `target`-bearing preset. This survives only as a convenience for sim/CLI
* tooling (`harness.ts`, `compare.ts`, `replay.ts`) that still wants a `short`/`standard`/`campaign`
* argument instead of a raw day count; `lengthProfile()` just resolves that argument to `days`.
*/
export type GameLength = 'short' | 'standard' | 'campaign';
export type LengthProfile = { length: GameLength; target: number; days: number };
export type LengthProfile = { length: GameLength; days: number };
/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */
export const LENGTH_PROFILES: readonly LengthProfile[] = [
{ length: 'short', target: 10, days: 3 },
{ length: 'standard', target: 20, days: 5 },
{ length: 'campaign', target: 45, days: 10 },
{ length: 'short', days: 3 },
{ length: 'standard', days: 5 },
{ length: 'campaign', days: 10 },
];
export function lengthProfile(length: GameLength): LengthProfile {