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:
+168
-20
@@ -9,17 +9,20 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { railFacingOf } from '../src/engine/state.ts';
|
||||
import { coordKey, railFacingOf } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'short',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
@@ -327,6 +330,75 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
assert.ok(!s.trays.has(id), 'the train is removed');
|
||||
});
|
||||
|
||||
it('does not collide when a coach is legally parked at the Office (v0.5.0 §A.4 exception)', () => {
|
||||
// A coach may now be set out at the Office (§A.4's carve-out). It must not become a hazard to
|
||||
// the next train in — that would punish exactly the thing the rule was written to allow.
|
||||
const s = game();
|
||||
const area = s.officeAreas.get(0)!;
|
||||
area.adOccupancy = []; // an A/D track is free — only the fouling check is under test
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||||
officeCard.standing = [{ type: 'coach', loaded: false }];
|
||||
|
||||
const id = 'inbound';
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: true }],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.collisionsToday, 0, 'a legally parked coach is not a hazard to the next arrival');
|
||||
assert.ok(s.trays.has(id), 'the train is not written off');
|
||||
});
|
||||
|
||||
it('still collides when a non-coach car is left fouling the Office track', () => {
|
||||
// Reachable only by poking state directly — `canDropCarsAt` already refuses every non-coach
|
||||
// drop at the Office, so this backstops the collision check itself, not a state a legal game
|
||||
// can reach.
|
||||
const s = game();
|
||||
const area = s.officeAreas.get(0)!;
|
||||
area.adOccupancy = [];
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||||
officeCard.standing = [{ type: 'boxcar', loaded: false }];
|
||||
|
||||
const id = 'inbound';
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: true }],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.collisionsToday, 1);
|
||||
assert.ok(!s.trays.has(id), 'the train is removed');
|
||||
});
|
||||
|
||||
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
|
||||
const s = game();
|
||||
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
|
||||
@@ -464,11 +536,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('victory conditions (§3, Gap 10e)', () => {
|
||||
it('loses a timed Solitaire game that misses the target', () => {
|
||||
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
||||
const s = game(1);
|
||||
s.clock.day = lengthProfile('short').days + 1;
|
||||
describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
it('loses a timed Solitaire game that misses minCombinedRevenue', () => {
|
||||
// minCombinedRevenue doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
||||
const s = game(1, { minCombinedRevenue: 10 });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
@@ -478,25 +550,101 @@ describe('victory conditions (§3, Gap 10e)', () => {
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('wins a timed Solitaire game that clears the target', () => {
|
||||
const s = game(1);
|
||||
s.clock.day = lengthProfile('short').days + 1;
|
||||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||||
const s = game(1, { minCombinedRevenue: 10 });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = lengthProfile('short').target;
|
||||
s.players[0]!.revenue = 10;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
it('ends a first-to-target game the moment the target is reached', () => {
|
||||
const s = game(1, { victory: 'firstToTarget' });
|
||||
s.players[0]!.revenue = lengthProfile('short').target;
|
||||
it('minCombinedRevenue = 0 disables the floor — any score wins once days run out', () => {
|
||||
const s = game(1, { minCombinedRevenue: 0 });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
it('everyone loses in Competitive when the TABLE misses minCombinedRevenue, whoever scored best', () => {
|
||||
const s = createGame({
|
||||
id: 'g', seed: 2, config: baseConfig({ mode: 'competitive', minCombinedRevenue: 20 }),
|
||||
playerNames: ['A', 'B'],
|
||||
});
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 5; // best individual score...
|
||||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('Co-op wins as one table score (summed Revenue), winner stays null', () => {
|
||||
const s = createGame({
|
||||
id: 'g', seed: 3, config: baseConfig({ mode: 'coop', minCombinedRevenue: 10 }),
|
||||
playerNames: ['A', 'B'],
|
||||
});
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 4;
|
||||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
||||
});
|
||||
|
||||
it('ends immediately, mid-Stage, when collisions reach maxCollisionsPerDay', () => {
|
||||
// Competitive/coop only, and immediate — not gated on the day boundary the way the revenue
|
||||
// floor is. Stage 1, well short of STAGES_PER_DAY, proves it fires mid-day.
|
||||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'targetReached');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('ends immediately when the running total reaches maxCollisionsTotal, even under the per-day cap', () => {
|
||||
const s = game(1, { mode: 'coop', maxCollisionsPerDay: 0, maxCollisionsTotal: 3 });
|
||||
s.collisionsTotal = 3;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('maxCollisionsPerDay = 0 and maxCollisionsTotal = 0 disable the collision floor entirely', () => {
|
||||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
|
||||
it('solitaire never checks the collision floor, whatever the counts', () => {
|
||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -708,7 +856,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
* which deadlocks the moment Local Operations wants an option chosen before it can be ended.
|
||||
*/
|
||||
const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => {
|
||||
const s = game(7, { length: 'standard' });
|
||||
const s = game(7, { days: 5 });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
/**
|
||||
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
|
||||
@@ -799,7 +947,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
|
||||
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
|
||||
// switching, say — it must not still be there when the next Mainline Phase begins.
|
||||
const s = game(7, { length: 'standard' });
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
|
||||
@@ -827,7 +975,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
it('does not fault an Expedited train legitimately held at the station itself', () => {
|
||||
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
|
||||
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
|
||||
const s = game(7, { length: 'standard' });
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
s.trays.set(id, {
|
||||
|
||||
Reference in New Issue
Block a user