/** * §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', victory: 'highestAfterDays', length: 'standard', 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. It may NOT stand on the Office // itself: §A.4 forbids leaving Rolling Stock on the Office track, so every drop there is refused // whatever the train's own card says. 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 }); /** As `ROLLING_STOCK_SUPPLY` mints them: there is no empty caboose in the game. */ const caboose = (): RollingStock => ({ type: 'caboose', loaded: true }); 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', ); }); it('X22 Pee-Dee may still couple a caboose, which is not a load (Gitea#8)', () => { // Every caboose in the game is minted `loaded: true` because the supply table's loaded/empty // split doubles as a piece count. Taken literally that left the per-diem train unable to pick // up ANY caboose, its own included: set it out at the end of a sweep and it was stranded there. const s = game(); switching(s, 22, true, [], [caboose()]); assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null); // The restriction itself is untouched — a loaded car alongside the caboose still refuses. const withLoad = game(); switching(withLoad, 22, true, [], [caboose(), boxcar(true)]); assert.equal( check(withLoad, 0, { type: 'switch.move', trayId: 't', to: west(withLoad), 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); }); }); 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).copiesNextScheduled, undefined, `${t.name} carries a flag that should not exist`, ); } assert.ok(trainProfile(1, false), 'the catalogue should still load'); }); });