/** * 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, facilityCarTypes, reduce } from '../src/engine/apply.ts'; import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS, OPENING_OTHER, OPENING_TRACK } 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, turnOf } from '../src/engine/state.ts'; import { cardDescription, snapshot } 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 = 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, engineAt: 0, consist, direction: 'east', position: { at: 'grid', seat: 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(turnOf(s, 0).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); // The opening deal is 3 track + 3 other, so a draw takes the hand to seven — and §6.2's limit // then has to be played down to three before the turn can end. assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1); }); it('takes the TOP card from a Department pile, never one buried under it', () => { const s = game(); const buried = s.decks.departments[1]![0]!; // Bury it: a card discarded onto this Department goes on top and puts the other out of reach. const spare = s.decks.hands.get(0)![0]!; s.decks.departments[1]!.push(spare); s.decks.hands.set(0, s.decks.hands.get(0)!.filter((c) => c !== spare)); 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(spare), 'the top card should be the one taken'); assert.ok(!s.decks.hands.get(0)!.includes(buried), 'a buried card must not be reachable'); assert.deepEqual(s.decks.departments[1], [buried], 'the pile should be one shorter, not refilled'); }); it('refills a Department only when taking its last card empties it', () => { const s = game(); const target = s.decks.departments[1]![0]!; 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 is refilled from the Home Office deck immediately, so the // face-up market never disappears. assert.equal(s.decks.departments[1]!.length, 1, 'an emptied Department was not refilled'); assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card'); }); it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => { // §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards // from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office // deck and Department slots." // // This had a `deckReshuffled` event declared in `events.ts` and a line of narration written for // it, and was never emitted or reduced anywhere. The game looked like it had a reshuffle. const s = game(); const all = [...s.decks.homeOffice]; s.decks.salvageYard = all.slice(0, 40); s.decks.homeOffice = all.slice(40, 41); const loose = (): number => s.decks.homeOffice.length + s.decks.departments.reduce((n, p) => n + p.length, 0) + s.decks.salvageYard.length + [...s.decks.hands.values()].reduce((n, h) => n + h.length, 0); const before = loose(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.ok(r.ok); assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted'); assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept'); assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established'); assert.ok( s.decks.departments.every((p) => p.length === 1), 'the Departments must be turned face up again, one card each', ); assert.equal(loose(), before, 'the reshuffle created or destroyed cards'); const ids = [ ...s.decks.homeOffice, ...s.decks.departments.flat(), ...s.decks.salvageYard, ...[...s.decks.hands.values()].flat(), ]; assert.equal(new Set(ids).size, ids.length, 'a card ended up in two places'); }); it('does not reshuffle while the deck still has cards', () => { const s = game(); s.decks.salvageYard = [...s.decks.homeOffice].slice(0, 10); s.decks.homeOffice = [...s.decks.homeOffice].slice(10); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.ok(r.ok); assert.ok(!r.events.some((e) => e.type === 'deckReshuffled'), 'reshuffled with cards still in the deck'); }); it('leaves a genuinely exhausted game exhausted', () => { // Cards played onto the board are on the table, not in the Salvage Yard, so a game CAN run out // for real. Reshuffling an empty sweep would loop forever pretending otherwise. const s = game(); s.decks.homeOffice = []; s.decks.salvageYard = []; for (const pile of s.decks.departments) pile.length = 0; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'DECK_EMPTY'); }); it('replays the reshuffle identically from the same seed', () => { // The shuffled order rides the event, and `rngState` with it, so a save — a seed plus intents — // reconstructs the same deck. A reshuffle that re-rolled would fork every replay after it. const build = () => { const s = game(); const all = [...s.decks.homeOffice]; s.decks.salvageYard = all.slice(0, 40); s.decks.homeOffice = all.slice(40, 41); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); return s; }; assert.deepEqual(build().decks.homeOffice, build().decks.homeOffice); assert.deepEqual(build().decks.departments, build().decks.departments); }); 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, OPENING_TRACK + OPENING_OTHER + 1); assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT'); // And the way down is play or discard — there is no per-turn cap on either, which is what makes // the six-card opening hand playable in one turn rather than a limit that cannot be met. const hand = () => s.decks.hands.get(0)!; while (hand().length > HAND_LIMIT) { const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 }); assert.ok(r.ok, 'a discard was refused while over the hand limit'); } assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit'); }); it('discards face up ON TOP of a chosen Department, burying what was there', () => { // The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a // card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what // the old single-slot model did — destroyed the buried card outright and threw the choice away. const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 }); const under = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!; 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.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost'); // The opening hand is six, so one discard does not yet clear §6.2's limit — shed the rest. while (s.decks.hands.get(0)!.length > HAND_LIMIT) { applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 1 }); } assert.equal(check(s, 0, { type: 'draw.end' }), null); }); it('lets the discarding player pick which Department to use', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); const spare = s.decks.hands.get(0)![0]!; for (const slot of [0, 1, 2]) { assert.equal(check(s, 0, { type: 'card.discard', cardId: spare, toSlot: slot }), null, `Department ${slot}`); } const depths = s.decks.departments.map((p) => p.length); assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 2 }).ok); assert.deepEqual( s.decks.departments.map((p) => p.length), [depths[0]!, depths[1]!, depths[2]! + 1], 'only the chosen Department should grow', ); }); 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(turnOf(s, 0).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' }); turnOf(s, 0).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); }); }); describe('an industry handles every commodity its profile names (regression)', () => { it('derives car types from the sheet rather than a hand-written map', () => { // REGRESSION, and the reason tank cars were dropped ZERO times in 100 games. // // Two maps of industry -> car type existed. The engine's returned `carTypes[0]`, so the second // commodity of a two-commodity industry was unreachable — a Power Plant burns coal OR oil and // a Grocer's Warehouse receives dry goods OR perishables. The bot kept its own switch beside // it, which had drifted further: it named `produceShed` and `oilRefinery`, neither of which is // an industry, and omitted `freightHouse`, `refinery` and `packingSheds`, which are. Half the // industries in the deck therefore never asked for a car at all. // // Asserting equality with the profile is the point: it fails if anyone writes a third map. for (const p of INDUSTRY_PROFILES) { const f = { kind: 'freight', subtype: p.kind, allows: { outbound: p.baseOut > 0, inbound: p.baseIn > 0 }, outboundBox: [], inboundBox: [], capacity: { outbound: p.baseOut, inbound: p.baseIn }, menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] }, laborers: p.baseLoaders, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, } as never; assert.deepEqual( [...facilityCarTypes(f)], [...p.carTypes], `${p.kind} handles ${p.carTypes.join('/')} on the sheet`, ); } }); it('names both commodities of the two-commodity industries', () => { // Guards the specific collapse: `carTypes[0]` alone passes the test above only if someone also // rewrites the profiles, but silently fails this one. const twoCommodity = INDUSTRY_PROFILES.filter((p) => p.carTypes.length > 1).map((p) => p.kind); assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']); }); }); describe('a Modifier only goes beside a host that can use it (regression)', () => { it('offers a Waiting Area no square that is not beside a Passenger Facility', () => { // REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a // Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement // check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area // was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to // whichever facility the scan happened to reach first. The player was shown three legal spots // for a card that has one. const s = game(); const area = areaOf(s, 0); // A freight industry away from the Office, and nothing else nearby. area.grid.set(coordKey({ row: -1, col: 4 }), { geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); const waiting = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea', ); assert.ok(waiting, 'no Waiting Area card in the deck'); const [cardId] = waiting!; s.decks.hands.set(0, [cardId]); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const spots = legalActions(s, 0).filter( (i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined, ); assert.ok(spots.length > 0, 'a Waiting Area has nowhere legal at all — the Office is a host'); // Every offered square must touch the Office and none may touch only the Mine Tipple. for (const i of spots) { const p = (i as { placement: { row: number; col: number } }).placement; const touchesOffice = Math.abs(p.row - area.officeCoord.row) <= 1 && Math.abs(p.col - area.officeCoord.col) <= 1; assert.ok( touchesOffice, `a Waiting Area was offered (${p.row}, ${p.col}), which is not beside a Passenger Facility`, ); } }); it('refuses a second Modifier of the same kind in one Office Area', () => { // REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from // doubling up since Q4, but a Modifier is a different card kind and had no such check at all. const s = game(); const area = areaOf(s, 0); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const copies = [...s.cards.entries()] .filter(([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea') .map(([id]) => id); assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area'); s.decks.hands.set(0, [copies[0]!]); const first = legalActions(s, 0).find( (i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined, ) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } }; assert.ok(first, 'no legal square for the first Waiting Area'); assert.ok(applyIntent(s, 0, first).ok, 'the first Waiting Area should be playable'); // A different copy, on a different square, is still a second Waiting Area in this district. s.decks.hands.set(0, [copies[1]!]); const elsewhere = { row: first.placement.row, col: first.placement.col + 1 }; assert.equal( check(s, 0, { type: 'card.play', cardId: copies[1]!, placement: elsewhere }), 'FACILITY_LOCKED', 'a district accepted two Waiting Areas', ); assert.equal( legalActions(s, 0).filter((i) => i.type === 'card.play' && i.cardId === copies[1] && i.placement).length, 0, 'the second copy is still being offered squares it cannot legally take', ); }); }); // --------------------------------------------------------------------------- describe("a Modifier grants only what its host's flow can use", () => { const withFacility = (kind: string, out: boolean, into: boolean) => ({ geometry: { kind: 'facility', facility: kind, axis: 'ew' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], facility: { kind: 'freight', subtype: kind, allows: { outbound: out, inbound: into }, outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 }, menAtWork: [null, null, null], industryTrack: { length: 1, cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, }); /** Plays an Ice House next to the facility at `at`, and returns that facility. */ const iceHouseBeside = (kind: string, out: boolean, into: boolean) => { const s = game(); const area = areaOf(s, 0); const at = { row: -1, col: 4 }; area.grid.set(coordKey(at), withFacility(kind, out, into) as never); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const ice = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse', ); assert.ok(ice, 'no Ice House in the deck'); s.decks.hands.set(0, [ice![0]]); const r = applyIntent(s, 0, { type: 'card.play', cardId: ice![0], placement: { row: at.row, col: at.col + 1 }, }); assert.ok(r.ok, `the Ice House should be playable beside a ${kind}`); return area.grid.get(coordKey(at))!.facility!; }; it('gives a Packing Sheds the outbound slot its card prints', () => { // The host ships, so every printed grant lands. This is the control for the case below. const f = iceHouseBeside('packingSheds', true, false); assert.equal(f.capacity.outbound, 2, 'the outbound slot did not arrive'); assert.equal(f.laborers, 2, 'the laborer did not arrive'); assert.equal(f.industryTrack.length, 2, 'the siding should lengthen with the slot it serves'); }); it("drops the outbound slot on a Grocer's Warehouse, which only receives", () => { // REPORTED as "the Ice House added the laborer but not the outbound slot" — and it is not a bug. // A Grocer's is flow:'inbound', so `allows.outbound` is false and the capacity had nowhere to // go: it was raised anyway, on a direction that can never be drawn or stocked. const f = iceHouseBeside('grocersWarehouse', false, true); assert.equal(f.laborers, 2, 'the laborer should still arrive — Laborers have no direction'); assert.equal(f.capacity.outbound, 0, 'outbound capacity was credited to a facility that cannot ship'); assert.equal(f.capacity.inbound, 1, 'the modifier must not silently move the grant to the other direction'); assert.equal(f.industryTrack.length, 1, 'the siding lengthened for a slot that does not exist'); }); it('does not give a Passenger Facility a siding by way of a Modifier', () => { // FOUND by rendering 12,000 frames of real games rather than a fixture: a Waiting Area beside the // Office lengthened `industryTrack`, and the board then drew the Depot a siding square to spot a // car on. The same phantom siding the renderer had just been fixed for, arriving by another door. // A Waiting Area's "+1" is a passenger slot; passengers board off the platform. const s = game(); const area = areaOf(s, 0); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); // Forced open so this tests the freight/passenger distinction rather than the `allows` gating — // the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing. const office = area.grid.get(coordKey(area.officeCoord))!.facility!; office.allows = { outbound: true, inbound: true }; const before = office.industryTrack.length; const waiting = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea', )!; s.decks.hands.set(0, [waiting[0]]); const spot = legalActions(s, 0).find( (i) => i.type === 'card.play' && i.cardId === waiting[0] && i.placement !== undefined, ); assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office'); assert.ok(applyIntent(s, 0, spot!).ok); assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted'); assert.equal(office.porters, 1, 'the porter should still be granted'); assert.equal( office.industryTrack.length, before, 'a Modifier gave a Passenger Facility an industry track to spot cars on', ); }); it('says which bonus was dropped, and why', () => { // The number genuinely does not move; what was missing was the reason. const s = game(); const area = areaOf(s, 0); const at = { row: -1, col: 4 }; area.grid.set(coordKey(at), withFacility('grocersWarehouse', false, true) as never); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const ice = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse', )!; // Before it is played, the card in hand warns which of its two printed hosts cannot use it. const what = cardDescription(s, ice[0]); assert.match(what, /only receives/, 'the hand card does not warn about the dead half'); s.decks.hands.set(0, [ice[0]]); assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: ice[0], placement: { row: at.row, col: at.col + 1 }, }).ok); const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Grocer')); assert.ok(fv, "the Grocer's Warehouse is missing from the panel"); assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported'); assert.match(fv!.suppressed[0]!, /Ice House/); // And the modifier is now recorded against the host it served, which never used to happen. assert.deepEqual(fv!.modifiers, ['Ice House'], 'the modifier is not listed against its host'); }); }); // --------------------------------------------------------------------------- describe('Industry cards go on a stub, and lock each other out', () => { /** * A district with a siding hanging off the Running Track, which is the only place an industry may * go. Returns the siding square east of the curve. * * row 0: [lim] [office] [turnout, leg south] … <- Running Track * row -1: [curve ne] [siding square] */ function withSiding(s: GameState): GridCoord { const area = areaOf(s, 0); const plain = (geometry: object): TrackCard => ({ geometry: geometry as TrackCard['geometry'], baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }); const col = area.limitsEast.col; area.grid.set(coordKey(at(area.runningRow, col)), plain({ kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left', })); area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({ kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left', })); s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'draw'; return at(area.runningRow - 1, col + 1); } /** Puts an industry card of `kind` in hand and returns its id. */ function industryInHand(s: GameState, kind: string): string { for (const [id, card] of s.cards) { if (card.kind.kind === 'freightFacility' && card.kind.facility === kind) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no industry card: ${kind}`); } /** Puts an industry straight onto the board, bypassing the hand. */ function build(s: GameState, kind: string, coord: GridCoord): void { areaOf(s, 0).grid.set(coordKey(coord), { geometry: { kind: 'facility', facility: kind as never }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }); } it('refuses an industry on the Running Track', () => { // The sheet's "Placed" column, identical for all six: "Straight, Stub (not on Running Track)". // An industry has to hang off a siding — which is what makes building one worth the cards. const s = game(); withSiding(s); const area = areaOf(s, 0); const cardId = industryInHand(s, 'mineTipple'); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, area.limitsEast.col) }), 'ON_RUNNING_TRACK', ); }); it('accepts the same industry on a stub off the Running Track', () => { const s = game(); const siding = withSiding(s); const cardId = industryInHand(s, 'mineTipple'); assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null); }); it('refuses a second industry of the same kind', () => { // The sheet states this in the Freight House row, which lists Freight House among its own // lockouts. It is a general rule and applies to every kind. const s = game(); const siding = withSiding(s); build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1)); const cardId = industryInHand(s, 'mineTipple'); assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), 'FACILITY_LOCKED'); }); it('refuses a producer once its consumer is built, and the reverse', () => { // Every locked pair is a producer and the consumer of the same commodity: Mine Tipple makes the // coal a Power Plant burns, the Refinery makes the oil it also burns, Packing Sheds fill the // reefers a Grocer's Warehouse empties. One end of a chain or the other, never both. const pairs: [string, string][] = [ ['mineTipple', 'powerPlant'], ['refinery', 'powerPlant'], ['packingSheds', 'grocersWarehouse'], ['freightHouse', 'grocersWarehouse'], ]; for (const [a, b] of pairs) { for (const [built, wanted] of [[a, b], [b, a]] as [string, string][]) { const s = game(); const siding = withSiding(s); build(s, built, at(areaOf(s, 0).runningRow - 1, siding.col + 1)); const cardId = industryInHand(s, wanted); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: siding }), 'FACILITY_LOCKED', `${wanted} should be locked out by ${built}`, ); } } }); it('allows industries that share no commodity', () => { // Mine Tipple ships coal and Packing Sheds ship produce; neither consumes what the other makes, // so both may stand in one district. The lockouts must not be a blanket ban. const s = game(); const siding = withSiding(s); build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1)); const cardId = industryInHand(s, 'packingSheds'); assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null); }); it('matches the lockouts on the sheet exactly', () => { // Transcribed from the "Lockouts" column, so a change to the catalogue has to be deliberate. const expected: Record = { freightHouse: ['grocersWarehouse'], mineTipple: ['powerPlant'], refinery: ['powerPlant'], powerPlant: ['mineTipple', 'refinery'], packingSheds: ['grocersWarehouse'], grocersWarehouse: ['packingSheds', 'freightHouse'], }; for (const p of INDUSTRY_PROFILES) { assert.deepEqual([...p.lockouts].sort(), [...expected[p.kind]!].sort(), p.kind); } }); }); // --------------------------------------------------------------------------- describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendix A)', () => { const car = (type: string, loaded = false): { type: string; loaded: boolean } => ({ type, loaded }); it('holds an engine and up to four cars', () => { // "Up to five tokens: an engine, and up to four cars, one of which could be a caboose." The // engine is not one of the consist entries — §8.2 counts the consist as Rolling Stock and the // four-car limit is a limit on cars, not on the locomotive hauling them. const s = game(); const id = placeTray(s, at(0, 0), [car('boxcar'), car('hopper'), car('tank'), car('caboose')] as never); assert.equal(s.trays.get(id)!.consist.length, 4); }); it('couples to the nose going forward and to the tail backing up', () => { // Appendix A: "Engines also have couplers on the front end... a train can pick up two cars and // add them to the Crew Tray in order that they were in, pushing them into the Facility." Which // end they land on is the whole point of a run-around: it decides which car comes off next. const build = (toNose: boolean): string[] => { const s = game(); const id = placeTray(s, at(0, 0), [car('boxcar')] as never); reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose }); return s.trays.get(id)!.consist.map((c) => c.type); }; assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose'); assert.deepEqual(build(false), ['boxcar', 'hopper'], 'backing up couples them behind'); }); it('moves the engine back when cars are taken onto the nose, and forward when they are set out', () => { // Cars taken on the nose go AHEAD of the engine, so it is no longer leading. That is what §8.2 // then refuses to let out of the Office, and setting the nose cars out is the way back. const s = game(); const id = placeTray(s, at(0, 0), [car('boxcar')] as never); const tray = s.trays.get(id)!; tray.engineAt = 0; reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true }); assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it'); assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']); reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true }); assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front'); assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']); }); it('will not cut a train in the middle', () => { // A drop takes a cut off an OUTER end. Lifting cars from beside the engine would leave the far // end of the train coupled to nothing — a cut no coupler could make. const s = game(); // On a Limits card, not the Office — Rolling Stock may never be left at the Office (§A.4). const id = placeTray(s, at(0, 1), [car('boxcar'), car('hopper')] as never); s.trays.get(id)!.engineAt = 1; s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; // One car ahead of the engine and one behind: at most one may come off either end. assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2, fromNose: true }), 'CONSIST_EMPTY'); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY'); }); }); // --------------------------------------------------------------------------- describe('only the train being made up may take cars (§7)', () => { /** A tray with a short consist, placed wherever the test needs it. */ function trayAt(s: GameState, position: CrewTray['position']): void { s.clock.phase = 'newTrain'; s.trays.set('t', { id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position, movesUsed: 0, }); } const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }; it('accepts a car for a train standing at a Division Point', () => { const s = game(); trayAt(s, { at: 'divisionPoint', side: 'east' }); assert.equal(check(s, 0, addHopper), null); }); it('refuses to load a train standing in the district', () => { // The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division // Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding // in your own district: cars appeared on a train nobody was making up. const s = game(); trayAt(s, { at: 'grid', seat: 0, coord: at(0, 1) }); assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); }); it('refuses to load a train out on the Mainline', () => { const s = game(); trayAt(s, { at: 'mainline', index: 1 }); assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); }); it('refuses to load a train whose consist is already what its card calls for', () => { // Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing // at a Division Point — otherwise the phase and `check` disagree about who is being filled. const s = game(); trayAt(s, { at: 'divisionPoint', side: 'east' }); const tray = s.trays.get('t')!; tray.consist = [ { type: 'hopper', loaded: true }, { type: 'hopper', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }, ]; assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); }); }); // --------------------------------------------------------------------------- describe('an Office upgrade keeps what Modifiers added (§9)', () => { it('does not wipe a Restaurant when the Depot becomes a Station', () => { // The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant // played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next // upgrade. The card had been spent and the effect vanished with no message. const s = game(); const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!; const play = (tier: 'depot' | 'station'): void => { for (const [id, card] of s.cards) { if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue; s.decks.hands.set(0, [id]); turnOf(s, 0).option = null; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'card.play', cardId: id }); assert.ok(r.ok, `${tier} upgrade should apply`); return; } throw new Error(`no ${tier} card`); }; play('depot'); const base = { out: office().capacity.outbound, porters: office().porters }; // A Restaurant beside the Office: +1 passenger out, +1 porter. office().capacity.outbound += 1; office().porters += 1; play('station'); const station = { out: 2, porters: 2 }; // Station's printed numbers assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade'); assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade'); assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier'); }); });