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;
}
+10 -11
View File
@@ -697,25 +697,24 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const dropRules = rulesOf(tray);
const dropArea = areaOf(s, player);
const atOffice = here.row === dropArea.officeCoord.row && here.col === dropArea.officeCoord.col;
/**
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
* never set out during switching at all.
*
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
* district, and may not abandon its coach at an industry or on a siding while it does.
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
* Trains 7/8 Local — "coach must remain on station track if switching" — the coach may never
* be set out anywhere ELSE while switching. §A.4 now carries an exception for the Office
* square (v0.5.0, Jesse's call): any train may cut a coach loose there, which is exactly what
* "station track" meant on the card all along. So the coach is refused everywhere except the
* Office, rather than everywhere.
*/
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach') && !atOffice) {
return 'COACH_MUST_STAY';
}
const droppedFreight = cut.filter(isFreight).length;
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
return 'FREIGHT_WORKED_HERE';
}
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
const coachesOnly = cut.every((c) => c.type === 'coach');
return canDropCarsAt(dropArea, here, i.count, coachesOnly) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
+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 {
+16 -13
View File
@@ -53,13 +53,16 @@ export type SetupOptions = {
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] {
/**
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK FOR NOW, not just the solitaire one.
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
*
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
* `pvpCardsAllowed` (`GameConfig`, wired 2026-08-20) is the player-facing toggle, but ANDing it
* with `cardsImplemented` keeps it inert until Phase 5 actually builds the cards — flipping
* `pvpCardsAllowed` on today must not turn a working game into one where ~9% of draws reject.
*
* AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant,
* which is the same dead draw by another name. Recorded in TODO.md as multiplayer work.
@@ -68,7 +71,8 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
* for these two categories today.
*/
void mode;
const opponentCardsInDeck = false;
const cardsImplemented = false;
const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented;
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
@@ -226,17 +230,15 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
const mainline = (): DivisionNode => {
const card = kinds[rng.nextInt(kinds.length)]!;
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
// least deterministic and varies between games; it should become a real player choice when
// setup gains an interactive phase. See implications.md §10 Q11.
if (mainlineProfile(card).speed.kind === 'grade') {
/**
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
* setup has no decision point at all. Rolled from the seed until it gains one, because the
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
* Helpers card is worth.
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
* players (§4.2), or beyond an end Division Point next to one — never inside one player's own
* district — so whichever direction climbs advantages one neighbour over the other, and there
* is no single player who owns that call fairly. Orientation is rolled from the seed instead,
* identically for solitaire and multiplayer, and this is not expected to change when setup
* eventually gains an interactive phase for other decisions. See implications.md §10 Q11.
*/
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
@@ -317,7 +319,7 @@ export function createGame(opts: SetupOptions): GameState {
*/
const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand];
const deck = buildDeck(config.mode);
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
@@ -409,6 +411,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
collisionsTotal: 0,
status: 'active',
outcome: null,
};
+43 -10
View File
@@ -12,7 +12,6 @@ import type {
Hand,
MainlineKind,
FreightKind,
GameLength,
HouseRuleOverrides,
ModifierKind,
OfficeTier,
@@ -326,6 +325,12 @@ export type CrewTray = {
*
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
*
* This only records POSITION, not a supply — and that is not a gap. `rules-v0.2.md`:339 (Gap 4b)
* ties Crew Trays and engine pieces together as one combined resource, `player count + 3`
* (`crewTrayCount` in `content.ts`), so engine scarcity IS tray scarcity: `freeTrays` running out
* already blocks a new train exactly when the engine supply would. There is no state for "a tray
* with no engine" because the rules never separate the two.
*/
engineAt: number;
/**
@@ -540,12 +545,42 @@ export type Player = {
};
export type GameMode = 'solitaire' | 'competitive' | 'coop';
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
/**
* VICTORY CONDITIONS — designed 2026-08-20, unified across all three modes.
*
* Replaces the old `length`-preset lookup (`LENGTH_PROFILES.target`) and the dead
* `VictoryCondition: 'firstToTarget'` (grepped: never selected anywhere in the codebase). Winner is
* whoever has the most Revenue when `days` run out — solitaire's own player counts as "everyone" —
* unless `minCombinedRevenue` was missed, in which case everyone loses. Coop keeps summing every
* player's Revenue into one table score, now against a configurable floor instead of
* `profile.target * players.length`.
*
* `0` means "off" for every numeric field below. `minCombinedRevenue`'s natural default is
* `collectiveRevenueFloor(players, days)` (`content.ts`), computed by whoever authors the config —
* the engine only ever reads a concrete number here, never resolves one lazily, because player count
* is not always known at config-authoring time (a lobby, a CLI flag, a dialog).
*
* `maxCollisionsPerDay`/`maxCollisionsTotal` are deliberately FLAT, not scaled by player count: more
* players means more independent chances to collide, not a bigger shared budget, so a multiplayer
* table is genuinely riskier than solitaire at the same default (Jesse's call).
*/
export type GameConfig = {
mode: GameMode;
victory: VictoryCondition;
length: GameLength;
/** How many Days the game runs. */
days: number;
/** Everyone loses if the table's total Revenue is below this when `days` run out. 0 = off. */
minCombinedRevenue: number;
/** Everyone loses immediately, mid-game, once collisions in one Day reach this. 0 = off. */
maxCollisionsPerDay: number;
/** Same, summed across the whole game, never reset. 0 = off. */
maxCollisionsTotal: number;
/**
* Whether the 22 opponent-directed cards are in the deck (`setup.ts`'s `buildDeck`). Forced off in
* `solitaire` and `coop` — neither has a valid target for them — on by default in `competitive`.
* Has no effect until those cards are built (Phase 5); see `TODO.md`.
*/
pvpCardsAllowed: boolean;
optionalRules: {
reducedVisibility: boolean;
sisterTrains: boolean;
@@ -562,11 +597,7 @@ export type GameConfig = {
houseRules?: HouseRuleOverrides;
};
export type OutcomeReason =
| 'targetReached'
| 'daysElapsed'
| 'collisionFloor'
| 'revenueFloor';
export type OutcomeReason = 'daysElapsed' | 'collisionFloor' | 'revenueFloor';
export type Outcome = {
result: 'win' | 'loss';
@@ -742,8 +773,10 @@ export type GameState = {
turns: Map<PlayerIndex, TurnState>;
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day. */
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number;
status: 'setup' | 'active' | 'finished';
outcome: Outcome | null;
};
+10 -3
View File
@@ -667,12 +667,19 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
}
/**
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there — WITH ONE
* EXCEPTION (Jesse's call, v0.5.0): any train may set out one or more coaches at the Office. It is
* the one car type a passenger platform is meant to hold, and it is what makes the ENGINE-boxcar-
* coach Local arrangement workable — the coach drops here while the engine and freight car switch
* freely. Freight and cabooses stay banned at the Office for every train, no exception.
*
* An industry track also has a finite length (§9.3), so it can be full.
*/
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
if (sameCoord(coord, area.officeCoord)) return false;
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1, coachesOnly = false): boolean {
const card = cardAt(area, coord);
if (sameCoord(coord, area.officeCoord)) {
return coachesOnly && !!card && isOperationalRail(card) && spaceOn(card) >= count;
}
if (!card || !isOperationalRail(card)) return false;
return spaceOn(card) >= count;
}