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
+22 -19
View File
@@ -22,8 +22,11 @@ import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -155,7 +158,7 @@ describe('the revenue chain works end to end (regression)', () => {
// more cars in total (22 vs 19 over forty games). Widened so it fails for the reason it names.
let spotted = 0;
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 5 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
@@ -172,7 +175,7 @@ describe('the revenue chain works end to end (regression)', () => {
// the old 52-card placeholder it was 10 in 52.
let placed = 0;
for (const seed of [11, 23, 47, 91, 137, 211]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 10 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
@@ -206,7 +209,7 @@ describe('the revenue chain works end to end (regression)', () => {
// "downward only" is gone, and with it the rule that rejected every square above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 10 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
@@ -272,7 +275,7 @@ describe('the deck is closed — no card is created or destroyed', () => {
// Every card is in exactly one place: the Home Office deck, a Department pile, the Salvage Yard,
// a hand, or on the board — grid cells, enhancements laid on them, and Mainline modifiers.
for (const seed of [3, 17, 91]) {
const s = createGame({ id: `cc-${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: `cc-${seed}`, seed, config: { ...config, days: 5 }, playerNames: ['bot'] });
const total = s.cards.size;
playGame(s, developerBot, pump);
@@ -294,7 +297,7 @@ describe('the deck is closed — no card is created or destroyed', () => {
// The sharper form of the same guard. A discard moves a card from a hand to a Department pile
// and a draw moves one back, so the count across deck + Departments + Salvage Yard + hands can
// only fall by cards actually placed on the board — never by one being overwritten.
const s = createGame({ id: 'cc', seed: 5, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'cc', seed: 5, config: { ...config, days: 5 }, playerNames: ['bot'] });
const before = s.cards.size;
playGame(s, developerBot, pump);
const loose =
@@ -541,7 +544,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
const s = createGame({
id: `sd-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -639,7 +642,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
const s = createGame({
id: `cs-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
@@ -723,7 +726,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
const s = createGame({
id: `uj-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
@@ -752,7 +755,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
// The other half of the regression above. Gutting the fallback entirely would satisfy "never
// unjams a green box" while leaving a real jam to block the industry forever — a load stranded
// on WORK keeps the track locked, which now stops trains passing as well as stopping (§9.3).
const s = createGame({ id: 'jam', seed: 5, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'jam', seed: 5, config: { ...config, days: 5 }, playerNames: ['bot'] });
const area = s.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
@@ -797,7 +800,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
const s = createGame({
id: `st-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -834,7 +837,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
const s = createGame({
id: `il-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -872,7 +875,7 @@ describe('rolling stock returns to service (regression)', () => {
const s = createGame({
id: 'refill',
seed: 4,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
@@ -911,7 +914,7 @@ describe('measurement discipline', () => {
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
// reason to hand-roll the drive loop again.
const seen: string[] = [];
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, days: 5 }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
assert.ok(out.events.length > 0, 'a finished game must produce events');
@@ -923,7 +926,7 @@ describe('measurement discipline', () => {
// Asserted across several seeds because a single game may never draw a Depot card.
let upgrades = 0;
for (let seed = 0; seed < 25; seed++) {
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const s = createGame({ id: `u${seed}`, seed, config: { ...config, days: 5 }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump);
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
}
@@ -943,7 +946,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
const s = createGame({
id: `lay-${seed}`,
seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['Solitaire'],
});
const laid: Lay[] = [];
@@ -996,7 +999,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
for (const seed of SEEDS) {
const s = createGame({
id: `dp-${seed}`, seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['Solitaire'],
});
const spy = {
@@ -1085,7 +1088,7 @@ describe('the freight figures count both halves (regression)', () => {
const seed = 1000 + i * 7919;
const s = createGame({
id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);