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
+61 -49
View File
@@ -27,9 +27,7 @@ import {
MOVES_PER_LOCAL_OPS_NIGHT,
STAGES_PER_DAY,
STAGES_PER_SHIFT,
collectiveRevenueFloor,
houseRules,
lengthProfile,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
mainlineProfile,
@@ -273,10 +271,23 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
return { events, needsInput: true };
}
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
/**
* Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains,
* cycling Superintendent-then-left one car at a time (§7).
*
* `tray.consist.length` IS the round position: a freshly made-up tray always starts with
* `consist: []`, and each `newTrain.placeCar` appends exactly one car to it (`apply.ts`'s
* `carPlacedOnTrain` reducer), so it counts placements toward THIS tray without any new state —
* and resets to 0 naturally for the next train made up, which a phase-wide `actorOffset` cannot
* do. Reproduces the rulebook's worked example exactly: 2 players, 4-coach Limited → seats
* 0, 1, 0, 1.
*/
const filling = trainNeedingCars(s);
if (filling) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
const tray = s.trays.get(filling)!;
const nextActor = actorAt(s, tray.consist.length % s.players.length);
if (s.clock.currentActor !== nextActor) events.push({ type: 'actorChanged', player: nextActor });
s.clock.currentActor = nextActor;
return { events, needsInput: true };
}
@@ -893,8 +904,13 @@ function arriveAtOffice(
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
// is not expecting them (§A.4), so this is a collision too, not a coupling.
//
// A coach is the one exception (v0.5.0, §A.4's Office carve-out): it may be legally, deliberately
// parked at the Office while its engine switches, so it must not become a hazard to the next
// arrival. Anything else standing there is still illegal to have dropped in the first place —
// `canDropCarsAt` already refuses it — so this filter only ever excludes a coach in practice.
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) {
if (officeCard && officeCard.standing.some((c) => c.type !== 'coach')) {
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
return 'moved';
}
@@ -956,6 +972,7 @@ function collide(
}
s.collisionsToday += 1;
s.collisionsTotal += 1;
const player = s.players[faultPlayer];
if (player) {
player.revenue -= COLLISION_PENALTY;
@@ -1067,63 +1084,58 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
}
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
s.status = 'finished';
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
return { events, needsInput: false };
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
// collide, not a bigger shared budget.
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
const perDayBreach =
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
const totalBreach =
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
if (perDayBreach || totalBreach) {
s.status = 'finished';
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
return { events, needsInput: false };
}
}
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
}
/** §3.3 — evaluated at the end of a Day. */
/**
* §3.3 — evaluated at the end of a Day.
*
* Unified 2026-08-20 across all three modes: play `config.days`, then whoever has the most Revenue
* wins — unless the table's combined Revenue missed `config.minCombinedRevenue`, in which case
* everyone loses. Solitaire is "everyone" with one player, so this is the same win/lose shape it
* always had, just against a configured floor instead of a `length`-preset `target`. Coop keeps its
* "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is
* shared — now against the same configurable floor.
*/
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
const profile = lengthProfile(s.config.length);
const daysElapsed = s.clock.day - 1;
if (s.config.victory === 'firstToTarget') {
const target =
s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
if (score >= target) {
const winner =
s.config.mode === 'coop'
? null
: s.players.findIndex((p) => p.revenue === score);
s.status = 'finished';
s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
return true;
}
return false;
}
if (daysElapsed < profile.days) return false;
if (daysElapsed < s.config.days) return false;
s.status = 'finished';
if (s.config.mode === 'competitive') {
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
const best = Math.max(...s.players.map((p) => p.revenue));
s.outcome = {
result: 'win',
winner: s.players.findIndex((p) => p.revenue === best),
reason: 'daysElapsed',
};
const combined = totalRevenue(s);
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
s.outcome =
score >= target
? { result: 'win', winner: null, reason: 'daysElapsed' }
: { result: 'loss', winner: null, reason: 'revenueFloor' };
if (s.config.mode === 'coop') {
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
return true;
}
const best = Math.max(...s.players.map((p) => p.revenue));
s.outcome = {
result: 'win',
winner: s.players.findIndex((p) => p.revenue === best),
reason: 'daysElapsed',
};
return true;
}