Files
station-master/test/train-rules.test.ts
T
Jesse c3c5cbfeec 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.
2026-08-20 23:50:38 -04:00

354 lines
15 KiB
TypeScript

/**
* §7 — the operating rules printed on each train's card.
*
* Nine of these were declared on `TrainRules` and read by nothing, so a Military train could be
* worked by Porters and a Crack Limited could shunt an industry. Each is enforced at the point the
* act happens, and a LOCAL CREW (`trainNumber: null`) is exempt from all of them — it has no card.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { trainRules } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
});
/** A district of plain straight track west of the Office, and a train sitting on the Office square. */
function switching(
s: GameState,
trainNumber: number | null,
isExtra: boolean,
consist: RollingStock[] = [],
westCars: RollingStock[] = [],
): string {
const area = areaOf(s, 0);
const office = area.officeCoord;
// The crew stands one square west of the Office and works westward, away from the Office square's
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
// that exception exercise it directly instead.
const here = { row: office.row, col: office.col - 1 };
area.grid.set(coordKey(here), straight());
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight());
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist,
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: here }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return 't';
}
/** One square further west than the crew starts, i.e. the square carrying `westCars`. */
const west = (s: GameState, n = 2): GridCoord => {
const o = areaOf(s, 0).officeCoord;
return { row: o.row, col: o.col - n };
};
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
// ---------------------------------------------------------------------------
describe('§7 — no switching', () => {
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
// A train held at the Office may still need to clear onto Secondary Track ahead of other
// traffic, so a move that couples nothing is legal.
const s = game();
switching(s, 1, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
const s = game();
switching(s, 1, false, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
});
it('leaves a local crew alone — it has no card and therefore no printed rules', () => {
const s = game();
switching(s, null, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('leaves an ordinary freight train alone', () => {
// Train 9 Heavy Freight prints no rules at all, so nothing here may touch it.
const s = game();
switching(s, 9, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
});
describe('§7 — what a train may couple', () => {
it('X13 Appleseed may drop but not pick up', () => {
// Coupling is mandatory (§A.4), so the restriction has to bite on the MOVE that would pick the
// cars up — there is no moving over them and leaving them behind.
const s = game();
switching(s, 13, true, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
// Setting out is exactly what it IS allowed to do.
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('X22 Pee-Dee may pick up empties and refuses a loaded car', () => {
const empty = game();
switching(empty, 22, true, [], [boxcar(false)]);
assert.equal(check(empty, 0, { type: 'switch.move', trayId: 't', to: west(empty), reverse: false }), null);
const loaded = game();
switching(loaded, 22, true, [], [boxcar(true)]);
assert.equal(
check(loaded, 0, { type: 'switch.move', trayId: 't', to: west(loaded), reverse: false }),
'EMPTIES_ONLY',
);
});
});
describe('§7 — one freight car per location (trains 3/4)', () => {
it('spends the budget per SQUARE, not per turn', () => {
// "May drop or pick up one freight car at every location" — so a second car on the same square
// is refused, and the same train may still work another car one square along.
const s = game();
switching(s, 4, false, [boxcar(), boxcar()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'FREIGHT_WORKED_HERE');
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
'FREIGHT_WORKED_HERE',
'a second car came off the same square',
);
// One square west, the budget is fresh.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }).ok);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('shares one budget between dropping and picking up, as the card says "drop OR pick up"', () => {
const s = game();
switching(s, 4, false, [boxcar()], [boxcar()]);
// Drop here first, then try to take the car standing one square west… which is a new square, so
// it is allowed; the point is that the drop was charged where it happened.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'CONSIST_EMPTY');
});
it('does not count a caboose or a coach against it — the card says FREIGHT', () => {
const s = game();
switching(s, 4, false, [coach(), { type: 'caboose', loaded: false }]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), null);
});
});
describe('§7 — the Local keeps its coach (trains 7/8)', () => {
it('never sets the coach out while switching, but shunts its freight freely', () => {
/**
* "Coach must remain on station track if switching." The intended reading was "set out only at
* the Office" — but §A.4 refuses the Office square to every drop, so "only there" and "nowhere"
* are the same rule. What is enforced is the effect that matters: the Local may work its freight
* car around the district and may not abandon its coach doing it.
*/
// Consist reads nose-first with the engine at index 0, so both cars trail it and a cut comes off
// the tail: one car is the coach, two cars is the boxcar and the coach together.
const s = game();
switching(s, 7, false, [boxcar(), coach()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'COACH_MUST_STAY');
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'COACH_MUST_STAY');
// The same train with only freight behind it shunts exactly as any other train would.
const freight = game();
switching(freight, 7, false, [boxcar()]);
assert.equal(check(freight, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('leaves an ordinary train free to set a coach out', () => {
const s = game();
switching(s, 9, false, [boxcar(), coach()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('may set the coach out at the Office — the one place §A.4 now allows it (v0.5.0)', () => {
const s = game();
const area = areaOf(s, 0);
const office = area.officeCoord;
s.trays.set('t', {
id: 't',
trainNumber: 7,
trainIsExtra: false,
engineAt: 0,
consist: [boxcar(), coach()],
direction: 'west',
facing: 'w',
position: { at: 'grid', seat: 0, coord: office },
movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
null,
'the coach may be cut loose at the Office',
);
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
const officeCard = area.grid.get(coordKey(office))!;
assert.deepEqual(
officeCard.standing.map((c) => c.type),
['coach'],
'the coach is parked on the Office card',
);
assert.equal(s.trays.get('t')!.consist.length, 1, 'the coach left the tray');
});
});
describe('§7 — trains Porters may not work', () => {
/** An Office holding a train, with passengers waiting and an empty coach to put them in. */
function platform(s: GameState, trainNumber: number, isExtra: boolean, tier: 'depot' | 'terminal') {
const area = areaOf(s, 0);
area.tier = tier;
const office = area.grid.get(coordKey(area.officeCoord))!;
office.facility = {
kind: 'passenger', subtype: 'office',
allows: { outbound: true, inbound: true },
outboundBox: [coach(true)], inboundBox: [],
capacity: { outbound: 2, inbound: 2 },
menAtWork: null,
industryTrack: { cars: [] },
laborers: 0, porters: 2, usedThisStage: { laborers: 0, porters: 0 },
};
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('t');
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return area.officeCoord;
}
it('refuses the Military train and the Director\'s car outright', () => {
for (const n of [19, 20]) {
const s = game();
const where = platform(s, n, true, 'terminal');
assert.equal(
check(s, 0, { type: 'porter.board', at: where }), 'NO_PASSENGER_WORK',
`train X${n} was worked by a Porter`,
);
}
});
it('works the Crack Limited at a Terminal and nowhere else', () => {
// "Stop at Terminals only" — it still runs into every Office and takes an A/D track, but the
// passengers it is carrying can only be worked where it is booked to stop.
const depot = game();
const atDepot = platform(depot, 2, false, 'depot');
assert.equal(check(depot, 0, { type: 'porter.board', at: atDepot }), 'NOT_A_TERMINAL');
const terminal = game();
const atTerminal = platform(terminal, 2, false, 'terminal');
assert.equal(check(terminal, 0, { type: 'porter.board', at: atTerminal }), null);
});
it('still works an ordinary train at a Depot', () => {
const s = game();
const where = platform(s, 7, false, 'depot');
assert.equal(check(s, 0, { type: 'porter.board', at: where }), null);
});
});
describe('§7 — the Campaign Train stops once, then runs expedited', () => {
it('makes its speech at the first Office and expedites afterwards', () => {
// X17 prints "one turn at station (speeches) then expedite". The first arrival is an ordinary
// stop; from then on it departs in the Stage it arrives, like any expedited train.
const s = game();
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
ml.card = 'plains';
ml.transits.push({ tray: 'x17', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.trays.set('x17', {
id: 'x17', trainNumber: 17, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
const tray = s.trays.get('x17');
assert.ok(tray, 'the Campaign Train vanished');
assert.equal(tray!.position.at, 'grid', 'it should stop at the first Office for the speeches');
assert.equal(tray!.speechMade, true, 'the speech was not recorded');
});
});
describe('§7 — the catalogue and what it claims', () => {
it('no longer tells a player a rule is unenforced', () => {
for (const t of ALL_TRAINS) {
const text = trainRules({ trainNumber: t.number, trainIsExtra: t.isExtra });
assert.doesNotMatch(text, /NOT YET ENFORCED/, `${t.name} still apologises for its own card`);
}
});
it('describes every rule a train actually carries', () => {
// Train 1 carries three at once, so the tooltip has to say all three.
const limited = trainRules({ trainNumber: 1, trainIsExtra: false });
assert.match(limited, /NO SWITCHING/);
assert.match(limited, /TERMINALS ONLY/);
assert.match(limited, /EXPEDITED/);
});
it('has dropped copiesNextScheduled, which no train ever carried', () => {
// A Second Section is a Maneuver card with its own intent (`newTrain.secondSection`), not a
// property of a train card. The flag was an unreachable second description of a working mechanic.
for (const t of ALL_TRAINS) {
assert.equal(
(t.rules as Record<string, unknown>).copiesNextScheduled, undefined,
`${t.name} carries a flag that should not exist`,
);
}
assert.ok(trainProfile(1, false), 'the catalogue should still load');
});
});