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.
354 lines
15 KiB
TypeScript
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');
|
|
});
|
|
});
|