/** * Build order step 3 — "Individual actions apply correctly; illegal ones rejected." * See architecture/components.md §4. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { applyIntent, check, areaOf } from '../src/engine/apply.ts'; import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts'; import type { Intent } from '../src/engine/intents.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import { coordKey } from '../src/engine/state.ts'; const config: GameConfig = { mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, }; const game = (seed = 77): GameState => createGame({ id: 'g', seed, config, playerNames: ['Jesse'] }); const at = (row: number, col: number): GridCoord => ({ row, col }); /** Puts a tray on the player's grid so switching intents have something to act on. */ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string { const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: null, trainIsExtra: false, engineFront: true, consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0, }); return id; } function addCard(s: GameState, coord: GridCoord, card: TrackCard): void { areaOf(s, 0).grid.set(coordKey(coord), card); } const straight = (standing: TrackCard['standing'] = []): TrackCard => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing, facility: null, modifiers: [], enhancements: [], }); // --------------------------------------------------------------------------- describe('turn and phase gating', () => { it('rejects an intent from a player who is not the actor', () => { const s = game(); s.clock.currentActor = 1; assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN'); }); it('rejects an intent belonging to another phase', () => { const s = game(); s.clock.phase = 'loadUnload'; assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE'); }); it('rejects everything once the game is finished', () => { const s = game(); s.status = 'finished'; assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE'); }); }); // --------------------------------------------------------------------------- describe('Local Operations: the three-way exclusive choice (§6)', () => { it('accepts a first choice and records it', () => { const s = game(); const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.ok(r.ok); assert.equal(s.turn.option, 'draw'); }); it('forecloses the other two options for the Stage', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal( check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'OPTION_ALREADY_CHOSEN', ); }); it('refuses sub-intents of an option that was not chosen', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN'); }); }); // --------------------------------------------------------------------------- describe('Local Operations: drawing (§6.2)', () => { it('draws from the Home Office deck into the hand', () => { const s = game(); const before = s.decks.homeOffice.length; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.ok(r.ok); assert.equal(s.decks.homeOffice.length, before - 1); assert.equal(s.decks.hands.get(0)!.length, 4); }); it('takes the face-up card from a Department slot', () => { const s = game(); const target = s.decks.departments[1]!; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 }); assert.ok(r.ok); assert.ok(s.decks.hands.get(0)!.includes(target)); // §6.2 — an emptied Department slot is refilled from the Home Office deck immediately, so the // face-up market never disappears. assert.notEqual(s.decks.departments[1], null, 'Department slot was not refilled'); assert.notEqual(s.decks.departments[1], target, 'refilled with the same card'); }); it('allows only one draw per Stage', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN'); }); it('will not end the phase over the hand limit', () => { // §6.2 — "must reduce his hand to no more than three cards". const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1); assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT'); }); it('discards face up onto a Department slot, restoring the limit', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 }); const spare = s.decks.hands.get(0)![0]!; const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 }); assert.ok(r.ok); assert.equal(s.decks.departments[0], spare, 'discards go face up onto a Department'); assert.equal(check(s, 0, { type: 'draw.end' }), null); }); it('refuses to play a card that is not in hand', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal( check(s, 0, { type: 'card.play', cardId: 'nonsense' }), 'CARD_NOT_IN_HAND', ); }); }); // --------------------------------------------------------------------------- describe('Office upgrades (Gap 3b, Gap 8)', () => { function handCardOfTier(s: GameState, tier: 'depot' | 'station') { for (const [id, card] of s.cards) { if (card.kind.kind === 'office' && card.kind.tier === tier) { s.decks.hands.set(0, [id]); return id; } } throw new Error('no such office card'); } it('upgrades a Whistle Post to a Depot', () => { const s = game(); const id = handCardOfTier(s, 'depot'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'card.play', cardId: id }); assert.ok(r.ok); assert.equal(areaOf(s, 0).tier, 'depot'); }); it('refuses to skip a tier', () => { const s = game(); const id = handCardOfTier(s, 'station'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE'); }); it('preserves every grid connection through an upgrade', () => { // Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan // Secondary Track hanging off the Office. const s = game(); addCard(s, at(-1, 0), straight()); const gridBefore = new Map(areaOf(s, 0).grid); const id = handCardOfTier(s, 'depot'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'card.play', cardId: id }); const gridAfter = areaOf(s, 0).grid; assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost'); for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`); }); it('grants the Depot its Porters and slots on upgrade', () => { const s = game(); const id = handCardOfTier(s, 'depot'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'card.play', cardId: id }); const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!; assert.equal(office.facility!.porters, 1); // The design gives slots equal to porters, not one more. assert.equal(office.facility!.capacity.inbound, 1); }); }); // --------------------------------------------------------------------------- describe('Local Operations: switching (§6.1, Appendix A)', () => { it('moves a tray and spends a Move', () => { const s = game(); addCard(s, at(0, 2), straight()); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false }); assert.ok(r.ok); assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1); }); it('couples standing cars automatically and mandatorily', () => { // §A.4 — "you MUST pick them up". const s = game(); addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }])); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false }); assert.ok(r.ok); assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event'); assert.equal(s.trays.get(tray)!.consist.length, 1); }); it('refuses a move to an unreachable card', () => { const s = game(); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); assert.equal( check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }), 'ILLEGAL_MOVE', ); }); it('runs out of Moves after six', () => { const s = game(); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); s.turn.movesRemaining = 0; assert.equal( check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }), 'NO_MOVES_REMAINING', ); }); it('drops cars from the seated end, in order', () => { // §A.3 — cars must come off in the order they are seated in the Crew Tray. const s = game(); addCard(s, at(0, 2), straight()); const tray = placeTray(s, at(0, 2), [ { type: 'boxcar', loaded: false }, { type: 'hopper', loaded: true }, ]); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }); assert.ok(r.ok); const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing; assert.equal(left.length, 1); assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first'); assert.equal(s.trays.get(tray)!.consist.length, 1); }); it('refuses to drop cars at the Office', () => { // §A.4 — a passenger platform is no place to switch Rolling Stock. const s = game(); const office = areaOf(s, 0).officeCoord; const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); assert.equal( check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }), 'CANNOT_DROP_HERE', ); }); }); // --------------------------------------------------------------------------- describe('Freight Agent operations (§6.3)', () => { function withFacility(s: GameState): GridCoord { const coord = at(-1, 0); addCard(s, coord, { geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 3, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { length: 4, cars: [] }, laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], }); return coord; } it('stocks the green Outbound box from the Division Yard', () => { const s = game(); const coord = withFacility(s); const before = s.yards.divisionYard.length; applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }); assert.ok(r.ok); assert.equal(s.yards.divisionYard.length, before - 1); assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1); }); it('allows only one Freight Agent operation per Stage', () => { // This is the bottleneck the whole economy rests on (card-reference.md §7). const s = game(); const coord = withFacility(s); applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }); assert.equal( check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }), 'OPTION_ALREADY_CHOSEN', ); }); it('refuses to overfill the Outbound box', () => { const s = game(); const coord = withFacility(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.outboundBox = [ { type: 'hopper', loaded: true }, { type: 'hopper', loaded: true }, { type: 'hopper', loaded: true }, ]; applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); assert.equal( check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }), 'BOX_FULL', ); }); it('refuses to load a facility that only unloads', () => { const s = game(); const coord = withFacility(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.allows = { outbound: false, inbound: true }; // Give it something to clear, so the Freight Agent option itself remains available. f.inboundBox = [{ type: 'hopper', loaded: true }]; const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); assert.ok(chose.ok); assert.equal( check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }), 'NO_SUCH_FACILITY', ); }); it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => { // A player with no Facility cannot choose an option that has no possible follow-up. const s = game(); assert.equal( check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }), 'NO_SUCH_FACILITY', ); }); it('makes the switch option unavailable with no train to switch', () => { const s = game(); assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY'); }); }); // --------------------------------------------------------------------------- describe('Load/Unload: the four-action freight pipeline (§9.3)', () => { function facilityWithLoad(s: GameState): GridCoord { const coord = at(-1, 0); addCard(s, coord, { geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 3, inbound: 0 }, menAtWork: [{ type: 'hopper', dir: 'out' }, null, null], industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] }, laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], }); s.clock.phase = 'loadUnload'; return coord; } it('advances a load one box per Laborer action', () => { const s = game(); const coord = facilityWithLoad(s); const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }); assert.ok(r.ok); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; assert.equal(f.menAtWork[0], null); assert.notEqual(f.menAtWork[1], null); assert.equal(f.usedThisStage.laborers, 1); }); it('spends each Laborer only once per Stage', () => { // §9.1 — Laborers and Porters may be used once each in a Stage. const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.usedThisStage.laborers = f.laborers; assert.equal( check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }), 'RESOURCE_SPENT', ); }); it('scores exactly one Revenue when a load comes off WORK', () => { const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }]; assert.equal(s.players[0]!.revenue, 0); const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }); assert.ok(r.ok); assert.equal(s.players[0]!.revenue, 1, 'one point per completed load'); assert.ok(r.events.some((e) => e.type === 'loadCompleted')); assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded'); }); it('will not complete a load with no empty car spotted', () => { const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }]; f.industryTrack.cars = []; assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY'); }); it('takes four Laborer actions in total for one point', () => { // The asymmetry the whole economy is calibrated against (card-reference.md §2). const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.laborers = 99; // isolate the pipeline from the per-Stage cap let actions = 0; for (let box = 0; box < 3; box++) { const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box }); assert.ok(r.ok, `advance from box ${box}`); actions++; } assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK'); assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored'); }); }); // --------------------------------------------------------------------------- describe('the Superintendent clearance ruling (§8.1)', () => { it('is refused when no decision is pending', () => { const s = game(); assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION'); }); it('is accepted out of turn order, because it interrupts an automatic phase', () => { const s = game(); s.clock.phase = 'mainline'; s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' }; const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false }); assert.ok(r.ok); assert.equal(s.clock.pendingDecision, null); }); it('is refused to a player who is not the Superintendent', () => { const s = game(); s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' }; s.clock.superintendent = 1; assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT'); }); }); // --------------------------------------------------------------------------- describe('legalActions shares its rules with apply (component 6)', () => { it('offers only intents that apply would accept', () => { // The whole discipline of legal.ts in one assertion. const s = game(); for (const i of legalActions(s, 0)) { assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`); } }); const chooseOptions = (s: GameState) => legalActions(s, 0) .filter((i): i is Extract => i.type === 'localOps.choose') .map((i) => i.option) .sort(); it('offers only "draw" on the opening Stage', () => { // §6 — the other two options have no possible follow-up yet: a player opens with no Facility // and no train. This is why the very first Stages are forced development. assert.deepEqual(chooseOptions(game()), ['draw']); }); it('offers all three once the player has a facility and a train', () => { const s = game(); addCard(s, at(0, 2), straight()); placeTray(s, at(0, 2)); addCard(s, at(1, 0), { geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 3, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { length: 4, cars: [] }, laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], }); assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']); }); it('narrows to one option once a choice is made', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const kinds = new Set(legalActions(s, 0).map((i) => i.type)); assert.ok(!kinds.has('localOps.choose'), 'the choice is spent'); assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available'); assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not'); }); it('offers reachable destinations for a placed tray', () => { const s = game(); addCard(s, at(0, 2), straight()); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move'); assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go'); for (const m of moves) assert.equal(check(s, 0, m), null); assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray)); }); it('offers nothing illegal after the game ends', () => { const s = game(); s.status = 'finished'; assert.equal(legalActions(s, 0).length, 0); }); });