/** * 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, movesFor, reduce } from '../src/engine/apply.ts'; import { pump } from '../src/engine/advance.ts'; import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } 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 { carsOn, coordKey, railFacingOf, spaceOn, 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, standingWest: 0, 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; const dealt = s.decks.hands.get(0)!.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); // Measured against the deal rather than against a constant: the opening hand is a house rule // now (three random, six random, or three-and-three), and none of the three changes what a draw // does — it adds one card, and §6.2's limit then has to be played down to three. assert.equal(s.decks.hands.get(0)!.length, dealt + 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(); const dealt = s.decks.hands.get(0)!.length; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'draw.fromHomeOffice' }); assert.equal(s.decks.hands.get(0)!.length, dealt + 1); assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything'); 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 // a 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'); }); describe('which train cards may be discarded (Gitea#9, superseding Gitea#6)', () => { /** * Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows * it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either * play or discard. If someone else wants to pick it up, they are more than able to. The reason: * I don't want, if you decide to play a game longer than five days, to decide that maybe there * are too many trains, the stations are jammed, and the railroad doesn't need any more." * * So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so * it cannot be what jams it — and whether the Timetabled half applies is a New Game setting, * because the reasoning is about long games and a five-Day game may want Gitea#6's pressure. * * Note there is still no FORCING mechanism, and deliberately so: the corner is what the two * existing rules produce together whenever the setting is off. */ const handOf = (s: GameState, kinds: string[]): string[] => { // Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not // depend on what the shuffle happened to deal. const picked: string[] = []; for (const want of kinds) { for (const [id, card] of s.cards) { if (card.kind.kind !== want || picked.includes(id)) continue; picked.push(id); break; } } assert.equal(picked.length, kinds.length, 'the catalogue is missing a card this test needs'); s.decks.hands.set(0, picked); return picked; }; /** The same game with the setting turned off — Gitea#6's rule, still reachable. */ const strictGame = (): GameState => createGame({ id: 'g', seed: 77, config: { ...config, houseRules: { ...(config.houseRules ?? {}), discardTimetabled: false } }, playerNames: ['Jesse'], }); it('lets a Timetabled train be discarded, and still refuses an Extra', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']); assert.equal( check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }), null, 'Gitea#9 allows this and it was refused', ); assert.equal( check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED', 'an Extra never joins the timetable, so Gitea#9 does not reach it', ); assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null); }); it('puts the discarded train where a rival can pick it up', () => { // The other half of the ruling — "if someone else wants to pick it up, they are more than able // to" — needed no machinery, because a discard already goes face-up onto a Department pile. const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const [timetabled] = handOf(s, ['timetabledTrain', 'track']); assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok); const pile = s.decks.departments[1]!; assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile'); }); it('offers the discard as a legal action, so the bot can take it', () => { const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const [timetabled, extra] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']); const offered = legalActions(s, 0).filter((i) => i.type === 'card.discard'); assert.ok( offered.some((i) => i.type === 'card.discard' && i.cardId === timetabled), 'a Timetabled train was not offered as a discard', ); assert.ok( !offered.some((i) => i.type === 'card.discard' && i.cardId === extra), 'an Extra was offered as a discard', ); }); it('keeps Gitea#6 reachable when the setting is off', () => { const s = strictGame(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']); for (const id of [timetabled!, extra!]) { assert.equal( check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED', ); } assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null); }); it('leaves PLAYING a train as the only way out of a hand of four, setting off', () => { const s = strictGame(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']); assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit'); // Over the limit, so the turn cannot be ended... assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT'); // ...and not one of them may be discarded... for (const id of four) { assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED'); } // ...but playing one is always legal, so the player is never actually stuck. assert.equal(check(s, 0, { type: 'card.play', cardId: four[0]! }), null); assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok); assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT); assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn'); }); it('a hand of four Extras is the corner that survives Gitea#9 with the setting ON', () => { // Gitea#9 does not reach an Extra, so the deadlock-that-is-not-a-deadlock is still real in a // default game — worth pinning, since it is now the ONLY way to reach it. const s = game(); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const four = handOf(s, ['extraTrain', 'extraTrain', 'extraTrain', 'extraTrain']); assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT'); for (const id of four) { assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED'); } assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok); assert.equal(check(s, 0, { type: 'draw.end' }), null); }); it('lets a train be held across Stages and into the next Day', () => { // "They may keep the card in their hand for multiple stages and even multiple days." Nothing // sweeps a hand at a Stage or Day boundary, and this is what says so out loud. An Extra is // used, because it is the card that still cannot be got rid of any other way. const s = game(); const [extra] = handOf(s, ['extraTrain', 'track']); const startDay = s.clock.day; // Play out Stages by taking whatever ends the current turn, until the Day turns over. for (let guard = 0; guard < 400 && s.clock.day === startDay; guard++) { pump(s); const actor = s.clock.currentActor; if (actor === null) break; const options = legalActions(s, actor); const end = options.find((i) => i.type.endsWith('.end')) ?? options[0]; if (!end) break; applyIntent(s, actor, end); } assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`); assert.ok( (s.decks.hands.get(0) ?? []).includes(extra!), 'the train did not survive being held into the next Day', ); assert.equal( check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED', 'a Day boundary made an Extra discardable', ); }); it('tells the player on the card itself which of the two rules applies', () => { // The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no // reason given. Since Gitea#9 there are TWO reasons, so the card has to say which. const s = game(); handOf(s, ['timetabledTrain', 'extraTrain', 'track']); const f = snapshot(s, [], null); // `hand` is reversed for display, so compare as a set rather than by position. assert.deepEqual([...f.handDiscardable].sort(), [false, true, true]); const said = f.handKeepWhy.filter((w): w is string => w !== null); assert.equal(said.length, 1, 'exactly one card in this hand may not be discarded'); assert.match(said[0]!, /An Extra is never discarded/); const strict = strictGame(); handOf(strict, ['timetabledTrain', 'extraTrain', 'track']); const sf = snapshot(strict, [], null); assert.deepEqual([...sf.handDiscardable].sort(), [false, false, true]); assert.ok( sf.handKeepWhy.some((w) => w !== null && /never discarded in this game/.test(w)), 'the setting being off is not explained on the card', ); }); }); 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: [], standingWest: 0, facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 3, inbound: 0 }, menAtWork: [null, null, null], // An empty hopper is SPOTTED, which is what the LABORERS need (§9.3). The Freight Agent // does not: stocking works over a bare track too, and the tests below cover that. industryTrack: { cars: [{ type: 'hopper', loaded: false }] }, 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('stocks the green box before the car is spotted (§6.3)', () => { /** * REPORTED FROM PLAY: "A freight agent should be able to load an outbound green box prior to * having the car there that matches the load he's putting in there." * * §6.3 asks for nothing but a car in the Division Yard and room in the box. The empty-car * requirement is §9.3's, on the Laborer who moves the load out of the box — see the test below. * The engine used to hoist it forward onto stocking and so refused the ordinary play of having * the cargo waiting while the car is still being switched in. */ const s = game(); const coord = withFacility(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.industryTrack.cars = []; applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }); assert.ok(r.ok, 'staging cargo against a bare industry track is legal'); assert.equal(f.outboundBox.length, 1); }); it('still refuses to stock with no matching loaded car in the Division Yard', () => { // The requirement §6.3 DOES make: the load has to come from somewhere. const s = game(); const coord = withFacility(s); s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'hopper' && c.loaded)); applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }); assert.equal( check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }), 'NO_SUITABLE_CAR', ); }); 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: [], standingWest: 0, 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: { 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('holds a staged load in the green box until a car is spotted (§9.3)', () => { /** * THE OTHER HALF OF THE STOCKING RULE. The Freight Agent may stage a load with nothing on the * industry track (§6.3), but "Load the car" requires "*a load in the Green Loading Box AND an * empty car of the required type on the industry's track*" — so the load simply waits in the * box, which is a wait and not a jam: only a load on MEN | AT | WORK locks the track. */ const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.menAtWork = [null, null, null]; f.outboundBox = [{ type: 'hopper', loaded: true }]; f.industryTrack.cars = []; assert.equal( check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED', 'no car on the track — the Laborers cannot start it', ); // A crew sets an empty hopper out, and the same staged load may now start down the sign. f.industryTrack.cars = [{ type: 'hopper', loaded: false }]; assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), null); const r = applyIntent(s, 0, { type: 'laborer.startLoad', at: coord }); assert.ok(r.ok); assert.equal(f.outboundBox.length, 0, 'the load left the green box'); assert.notEqual(f.menAtWork![0], null, 'and is on MEN'); }); it('will not start a load onto a spotted car of the wrong type', () => { // Strict matching: a hopper load cannot be swapped onto a tank (§9.3). const s = game(); const coord = facilityWithLoad(s); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.menAtWork = [null, null, null]; f.outboundBox = [{ type: 'hopper', loaded: true }]; f.industryTrack.cars = [{ type: 'tank', loaded: false }]; assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED'); }); it('unloads the car the player actually picked, not always the westmost one', () => { // REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST // (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked. // The reducer for `unloadBegan` used to re-derive the target with // `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in // track order no matter which `carIndex` the intent actually named. const s = game(); const coord = at(-1, 0); addCard(s, coord, { geometry: { kind: 'facility', facility: 'freightHouse' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: { kind: 'freight', subtype: 'freightHouse', allows: { outbound: true, inbound: true }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 }, menAtWork: [null, null, null], industryTrack: { cars: [ { type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'tank', loaded: true }, ], }, laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], }); s.clock.phase = 'loadUnload'; // An empty replacement of every type standing on the track, so `check` never refuses for a // reason unrelated to what this test is about. s.yards.divisionYard.push( { type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }, { type: 'tank', loaded: false }, ); const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 }); assert.ok(r.ok, 'unloading the east (index 2) car should be legal'); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched'); assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded'); assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched'); assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded'); assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there'); assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied'); }); 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: [], standingWest: 0, facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 3, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { 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: { 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: { 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('the Limits bound the district, and the nine spots reach round a Facility', () => { /** * A district whose Running Track has been extended one square east, so the sign stands at col 2 * and there is a siding hanging below the main to build on: * * row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2 * row -1: [curve ne] * * Returns the industry-ready square under the sign, at (-1, 2). */ function district(s: GameState): GridCoord { const area = areaOf(s, 0); const plain = (geometry: object): TrackCard => ({ geometry: geometry as TrackCard['geometry'], baseOperationalRail: true, standing: [], standingWest: 0, 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', })); area.limitsEast = at(area.runningRow, col + 1); area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' })); s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'draw'; return at(area.runningRow - 1, col + 1); } const trackInHand = (s: GameState, geometry: string): string => { for (const [id, card] of s.cards) { if (card.kind.kind === 'track' && card.kind.geometry === geometry) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no ${geometry} card`); }; const industryOf = (s: GameState, facility: string): string => { for (const [id, card] of s.cards) { if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no industry card: ${facility}`); }; const modifierOf = (s: GameState, modifier: string): string => { for (const [id, card] of s.cards) { if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no modifier card: ${modifier}`); }; /** An industry standing on the board, so a Modifier has a host to hang off. */ const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => { areaOf(s, 0).grid.set(coordKey(coord), { geometry: { kind: 'facility', facility: kind, axis: 'ew' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], facility: { kind: 'freight', subtype: kind, allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); }; it('accepts a siding in the sign\'s own column and refuses one past it', () => { /** * REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines * Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound * belongs to the district rather than to the one row the sign stands in — it used to guard the * Running Track alone, and a siding could run east past a player's own sign, taking industries * with it, onto track that §8.1 and §10 do not consider his territory at all. * * INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening * district is signs at ±1 around the Office, so the strict reading would leave one buildable * column and break §11.3's promise that both Secondary rows are usable from the first Stage. */ const s = game(); const under = district(s); const cardId = trackInHand(s, 'straight'); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: under }), null, 'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it', ); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }), 'OUTSIDE_LIMITS', 'a siding was built outside the district it belongs to', ); }); it('refuses an industry outside the Limits — a Facility carries track', () => { // §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound // is the same one, and said with the same code rather than left to read as NOT_CONNECTED. const s = game(); const under = district(s); const cardId = industryOf(s, 'mineTipple'); assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }), 'OUTSIDE_LIMITS', ); }); it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => { /** * REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places * one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted * all eight neighbours — it was `placementCandidates` that walked north, south, east and west * only, so a diagonal square with no orthogonal neighbour was legal and never offered. */ const s = game(); const under = district(s); buildFacility(s, 'packingSheds', under); const cardId = modifierOf(s, 'iceHouse'); const offered = legalActions(s, 0) .filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined) .map((i) => coordKey((i as { placement: GridCoord }).placement)); // South-east of the host, and orthogonally adjacent to nothing at all. const southEast = at(under.row - 1, under.col + 1); assert.ok( offered.includes(coordKey(southEast)), `the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`, ); assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null); }); it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => { /** * Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps * all nine of its spots — refusing the outer three would make the card unplayable exactly where * the district ends. The Running Track ROW is the exception: inside the Limits that row is * always full, so this bites only beyond the sign, and that is the ground the main grows onto — * a Modifier parked there would block the player's own sign from moving outward (§2.1). */ const s = game(); const under = district(s); buildFacility(s, 'packingSheds', under); const cardId = modifierOf(s, 'iceHouse'); const area = areaOf(s, 0); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }), null, 'a Modifier beside a host at the limit was refused the spot outside it', ); assert.equal( check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }), 'ON_RUNNING_TRACK', 'a Modifier was allowed to stand in the row the Running Track grows along', ); }); }); // --------------------------------------------------------------------------- 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: { cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, }); /** Plays `modifier` next to the facility at `at`, and returns the facility and its card. */ const modifierBeside = (modifier: string, 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 card = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === modifier, ); assert.ok(card, `no ${modifier} in the deck`); s.decks.hands.set(0, [card![0]]); const r = applyIntent(s, 0, { type: 'card.play', cardId: card![0], placement: { row: at.row, col: at.col + 1 }, }); assert.ok(r.ok, `the ${modifier} should be playable beside a ${kind}`); const host = area.grid.get(coordKey(at))!; return { f: host.facility!, card: host }; }; const iceHouseBeside = (kind: string, out: boolean, into: boolean) => modifierBeside('iceHouse', kind, out, into).f; 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'); }); 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'); }); it('gives a Truck Dock a second red box and not one more car length', () => { /** * The case as it was described: "if I have a Grocer's Warehouse that receives, it has one red * box. If it has a Truck Dock next to it, that should give it a second red box. There is no * change to the number of cars that are allowed to be dropped here." * * Both halves matter, and the second half is the one that used to be wrong: the Truck Dock's * inbound grant lengthened the industry track along with the box, because the track's length WAS * the box count. The boxes are how much work the industry can hold; the card holds four cars * either way. */ const { f, card } = modifierBeside('truckDock', 'grocersWarehouse', true, true); assert.equal(f.capacity.inbound, 2, 'the Truck Dock did not add the red box it prints'); assert.equal(f.capacity.outbound, 1, 'the Truck Dock added a green box, which it does not print'); assert.equal( spaceOn(card as never), MAX_CONSIST, 'a Modifier changed how many cars may be set out at the industry it stands beside', ); }); it('does not give a Passenger Facility an industry track 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 square to spot a car on. // A Waiting Area's "+1" is a passenger slot; passengers board off the platform. // // The length field is gone now — an industry track holds four cars like any other card, so there // was never a number for a Modifier to raise. What is still worth pinning down is the property // that bug was really about: an Office keeps its cars in `standing` and has no industry track for // a Modifier to hand it, no matter what the Modifier prints. 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 officeCard = area.grid.get(coordKey(area.officeCoord))!; const office = officeCard.facility!; office.allows = { outbound: true, inbound: true }; 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( carsOn(officeCard), officeCard.standing, 'a Modifier gave a Passenger Facility an industry track to spot cars on', ); }); it('suppresses a grant the host cannot use — and gives it back when it can', () => { /** * REPORTED from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot only * shows one green / one red box. I expected two, because Restaurant increases outbound by one." * * `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1 * outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right, * and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied * only the difference between two tiers and knew nothing about what had been discarded. * * This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again * now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the * suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across * that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently * and tests nothing about the upgrade path. */ const s = game(); const area = areaOf(s, 0); const office = area.grid.get(coordKey(area.officeCoord))!; assert.equal(office.facility!.allows.outbound, false, 'a Whistle Post is not a Passenger Facility'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const restaurant = [...s.cards.entries()].find( ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant', )!; s.decks.hands.set(0, [restaurant[0]]); assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: restaurant[0], placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col }, }).ok, 'the Restaurant could not be played beside the Office'); const f = office.facility!; assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have'); assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed'); const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Whistle')); assert.ok(fv, 'the Office is missing from the panel'); assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported'); assert.match(fv!.suppressed[0]!, /Restaurant/); assert.deepEqual(fv!.modifiers, ['Restaurant'], 'the modifier is not listed against its host'); // Upgrading makes it a Passenger Facility, and the slot the Restaurant always printed arrives. reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' }); assert.equal(f.allows.outbound, true); assert.equal( f.capacity.outbound, officeProfile('depot').passengerOut + 1, "the Restaurant's slot did not come back when the Office could finally use it", ); // And it is paid ONCE: a second upgrade must not grant it again. const afterDepot = f.capacity.outbound; reduce(s, { type: 'officeUpgraded', player: 0, from: 'depot', to: 'station' }); assert.equal( f.capacity.outbound, afterDepot + (officeProfile('station').passengerOut - officeProfile('depot').passengerOut), 'the Restaurant was paid a second time on the next upgrade', ); }); }); // --------------------------------------------------------------------------- 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] [lim] <- Running Track * row -1: [curve ne] [siding square] * * The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign * MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played * rather than written straight into the grid. Without that the siding square would be outside the * district's own Limits, which is no longer a place track may go. */ function withSiding(s: GameState): GridCoord { const area = areaOf(s, 0); const plain = (geometry: object): TrackCard => ({ geometry: geometry as TrackCard['geometry'], baseOperationalRail: true, standing: [], standingWest: 0, 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', })); area.limitsEast = at(area.runningRow, col + 1); area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' })); 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: [], standingWest: 0, 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'); }); }); // --------------------------------------------------------------------------- describe('the engine is drawn pointing east or west, whatever track it is standing on', () => { /** * Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept * that ▲ after it departed onto the Division — where there is no north or south at all — because * nothing resets `facing` when a train leaves a district. A Division runs east and west, and a * player reads a train the way a railroader does: which END the engine is on. * * So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws. */ const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') => ({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const; it('carries the east-west sense across north-south track', () => { const s = game(); const id = placeTray(s, at(0, 0)); const tray = s.trays.get(id)!; reduce(s, moved(id, 'w')); assert.equal(railFacingOf(tray), 'w'); // Onto a curve, and away up a spur. The port is north; the engine is still on the west end. reduce(s, moved(id, 'n')); assert.equal(tray.facing, 'n', 'the movement port must still be the real one'); assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end'); }); it('follows the engine around 180° of curves, because that really is a turn', () => { // Forward through two curves — west port to north, north to east — and the engine genuinely // does come out pointing the other way. Held values are held, not frozen. const s = game(); const id = placeTray(s, at(0, 0)); const tray = s.trays.get(id)!; for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f)); assert.equal(railFacingOf(tray), 'e'); }); it('falls back to the direction of the run for a tray that has not moved yet', () => { // A tray placed straight onto the board has no history to carry, and `direction` is the only // east-west fact about it. const s = game(); const tray = s.trays.get(placeTray(s, at(0, 0)))!; assert.equal(tray.railFacing, undefined); assert.equal(railFacingOf(tray), 'e', 'placed running east'); tray.direction = 'west'; assert.equal(railFacingOf(tray), 'w'); }); it('never reports north or south to the board', () => { // The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a // north-south spur used to break. const s = game(); const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never); addCard(s, at(0, 0), straight()); reduce(s, moved(id, 's')); const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0); const train = cell?.trains[0]; assert.ok(train, 'the train should be on the card'); assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`); }); }); // --------------------------------------------------------------------------- describe('a train that rounds a curve points where the curve took it', () => { /** * REPORTED from play: "a train reversed into a siding and the display of the cars was reversed." * * `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` — * which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a * north-west curve through its west port and the far end is north, not east. So a train that * rounded a curve was left facing a port the card does not have. * * Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the * card lacks yields nothing, so the crew could only ever back out the way it came. Display: the * east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine * west could leave the board still drawing it east — the consist mirrored, which is what was seen. */ const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({ geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); /** * Runs a crew one Move and reports where the engine ended up pointing. * * The card it starts ON matters as much as the one it moves to: a crew facing north or south has * to be standing on something with that port, and only a curve has one. */ const roundIt = ( from: GridCoord, to: GridCoord, origin: TrackCard, dest: TrackCard, start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' }, reverse = false, ): { facing?: string; drawn: string } => { const s = game(); addCard(s, from, origin); addCard(s, to, dest); const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never); const tray = s.trays.get(id)!; tray.facing = start.facing; tray.railFacing = start.railFacing; s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse }); assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`); return { facing: tray.facing, drawn: railFacingOf(tray) }; }; it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => { // East along the running track into a curve that turns north. The far end is NORTH. const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' }); assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have'); }); it('does not strand the crew on the curve it just rounded', () => { // The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without // that port yields nothing at all — so the only move left was backing out the way it came. const s = game(); addCard(s, at(0, 0), straight()); addCard(s, at(0, 1), curve('nw')); // joins north and west addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues addCard(s, at(1, 2), straight()); const id = placeTray(s, at(0, 0), []); s.trays.get(id)!.facing = 'e'; s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok); const { to } = movesFor(s, 0, id); assert.ok( to.some((c) => c.row === 1 && c.col === 1), 'the crew cannot carry on round the curve — it can only back out the way it came', ); }); it('can still back out of the curve it just rounded', () => { /** * THE OTHER HALF OF THE SAME MISTAKE, and the one that bites hardest. * * Forward exits by `facing`; reverse used to exit by `opposite(facing)` — which, again, is the * other end of a STRAIGHT and of nothing else. A crew standing on a north-west curve facing * north reverses out through WEST, the way it came; `opposite('n')` is south, a port the curve * does not have, so backing up found nothing. * * Fixing only the forward case moved the problem rather than solving it: before, a crew that * rounded a curve could only back out; after, it could only carry on. A crew must be able to do * both, which is what makes a siding reachable and therefore what makes setting out a cut * possible at all. */ const s = game(); addCard(s, at(0, 0), straight()); addCard(s, at(0, 1), curve('nw')); // joins north and west addCard(s, at(1, 1), curve('se')); // the spur carries on north const id = placeTray(s, at(0, 0), []); s.trays.get(id)!.facing = 'e'; s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok); const { to } = movesFor(s, 0, id); assert.ok( to.some((c) => c.row === 0 && c.col === 0), 'the crew cannot back off the curve onto the track it came from', ); assert.ok( to.some((c) => c.row === 1 && c.col === 1), 'the crew cannot carry on round the curve either', ); // And backing up really does go there, rather than merely being offered. assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok); }); it('turns the DRAWN direction when the curve really does turn the engine round', () => { // South down a spur into a curve that turns west. The engine genuinely now points west, and the // board has to say so — this is the mirrored consist that was reported. const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' }); assert.equal(out.facing, 'w', 'the curve turned the engine west'); assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east'); }); it('still points back the way it came when it BACKS round a curve', () => { // Backing up does not turn a train around, whatever the track does underneath it: the engine // trails, pointing out through the port the train came in by. That is geometry-independent and // was already right — this is here so the fix above cannot quietly change it. const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true); assert.equal(out.facing, 'n', 'backing up turned the train around'); assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on'); }); }); // --------------------------------------------------------------------------- describe('the switching job a player actually does: put a car in a siding, take an empty to an industry', () => { /** * REPORTED from play: "I'm trying to switch to get an empty car to an industry, but I can't drop * any cars on the siding first." * * Setting out a cut needs no Move and is refused almost nowhere — but you can only set out where * the train IS, and the Office square itself is barred (§A.4). So "I cannot drop" is nearly always * "I cannot GET there", and getting there means leaving the Running Track through a turnout and a * curve. That is exactly where `opposite(facing)` was wrong in both directions. * * This walks the whole errand rather than any one rule, because each individual rule passed its * own test while the errand was impossible. */ const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({ geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({ geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); it('takes a cut off the Running Track into a siding and sets it out', () => { const s = game(); // Running Track east from the Office, with a turnout diverging north onto a short siding. addCard(s, at(0, 0), straight()); addCard(s, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' })); addCard(s, at(0, 2), straight()); addCard(s, at(1, 1), curve('se')); // up off the turnout, then east along the siding addCard(s, at(1, 2), straight()); const id = placeTray(s, at(0, 0), [ { type: 'boxcar', loaded: false }, { type: 'tank', loaded: false }, ] as never); s.trays.get(id)!.facing = 'e'; s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; // Out of the Office and up onto the siding. A turnout is not somewhere a train may stop, so the // first square it can finish on is the curve beyond it. const reachable = movesFor(s, 0, id).to; assert.ok( reachable.some((c) => c.row === 1 && c.col === 1), 'the siding is not reachable from the Running Track at all', ); assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 1), reverse: false }).ok); // Set the cut out. This is the step that was reported as impossible. const drop = applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }); assert.ok(drop.ok, `setting out on the siding was refused: ${drop.ok ? '' : drop.code}`); assert.deepEqual( areaOf(s, 0).grid.get(coordKey(at(1, 1)))!.standing.map((c) => c.type), ['tank'], 'the car did not end up on the siding', ); assert.equal(s.trays.get(id)!.consist.length, 1, 'the train still has the car it set out'); // And it can get back off the siding again, carrying the empty on toward an industry. const after = movesFor(s, 0, id).to; assert.ok( after.some((c) => c.row === 0 && c.col === 0) || after.some((c) => c.row === 0 && c.col === 2), 'the crew is stranded on the siding — it cannot return to the Running Track', ); }); }); // --------------------------------------------------------------------------- /** * The v0.4.9d playtest, three reports with two causes. * * "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers * just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two * trains in a station: the select button does not work, regardless of which you pick it is always * one train, not the other" — the porter intents carrying no tray. */ describe('a load may not be broken in the district that made it (v0.4.9e)', () => { const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!; /** An Office that can work passengers, with someone waiting and Porters to hand. */ function platform(s: GameState): void { const f = office(s); f.allows = { outbound: true, inbound: true }; f.porters = 4; f.capacity = { outbound: 2, inbound: 2 }; f.outboundBox = [{ type: 'coach', loaded: true }]; s.yards.divisionYard.push({ type: 'coach', loaded: false }); s.clock.phase = 'loadUnload'; s.clock.currentActor = 0; } /** A tray standing on an A/D track at the Office. */ function atOffice(s: GameState, 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: areaOf(s, 0).officeCoord }, movesUsed: 0, }); areaOf(s, 0).adOccupancy.push(id); return id; } /** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */ function freightHouse(s: GameState, coord: GridCoord): void { areaOf(s, 0).grid.set(coordKey(coord), { geometry: { kind: 'facility', facility: 'freightHouse' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: { kind: 'freight', subtype: 'freightHouse', allows: { outbound: true, inbound: true }, outboundBox: [{ type: 'boxcar', loaded: true }], inboundBox: [], capacity: { outbound: 1, inbound: 1 }, menAtWork: [null, null, null], industryTrack: { cars: [{ type: 'boxcar', loaded: false }] }, laborers: 9, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], } as TrackCard); s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false }); s.clock.phase = 'loadUnload'; s.clock.currentActor = 0; } /** Walk a staged load all the way onto the spotted car. */ function finishLoad(s: GameState, coord: GridCoord): void { applyIntent(s, 0, { type: 'laborer.startLoad', at: coord }); for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box }); } it('refuses to unload the boxcar the Freight House just loaded', () => { const s = game(); const coord = at(-1, 0); freightHouse(s, coord); finishLoad(s, coord); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car'); assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it'); assert.equal( check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), 'LOADED_IN_THIS_DISTRICT', ); // And it is not merely absent from the menu by accident — the menu agrees with `check`. assert.ok( !legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'), 'the unload was still offered', ); }); it('unloads a load that came from somewhere else', () => { // The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up // at a Division Point out of the common supply carries no origin, and is exactly the inbound // traffic a district lives on. const s = game(); const coord = at(-1, 0); freightHouse(s, coord); const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; f.industryTrack.cars = [{ type: 'boxcar', loaded: true }]; assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null); }); it('refuses to detrain the passengers this Office just put aboard', () => { const s = game(); platform(s); const tray = atOffice(s, [{ type: 'coach', loaded: false }]); assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok); const coach = s.trays.get(tray)!.consist[0]!; assert.equal(coach.loaded, true, 'nobody boarded'); assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it'); assert.equal( check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), 'LOADED_IN_THIS_DISTRICT', ); }); it('detrains passengers who boarded somewhere else', () => { const s = game(); platform(s); const tray = atOffice(s, [{ type: 'coach', loaded: true }]); assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null); }); it('takes the origin stamp off a coach that reaches the red box', () => { // The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and // heads back to a yard from there; carrying the stamp on would poison the common supply. const s = game(); platform(s); const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]); assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok); assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box'); }); }); describe('two trains in one station are told apart (v0.4.9e)', () => { function twoAtOffice(s: GameState): [string, string] { const area = areaOf(s, 0); const f = area.grid.get(coordKey(area.officeCoord))!.facility!; f.allows = { outbound: true, inbound: true }; f.porters = 4; f.capacity = { outbound: 2, inbound: 2 }; f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }]; s.clock.phase = 'loadUnload'; s.clock.currentActor = 0; const ids: string[] = []; for (let n = 0; n < 2; n++) { const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [{ type: 'coach', loaded: false }], direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, }); area.adOccupancy.push(id); ids.push(id); } return [ids[0]!, ids[1]!]; } it('offers boarding on each train, not once for the platform', () => { // REPORTED: "operating two trains in a station, the select button does not work — regardless of // which you pick, it is always one train, not the other." There was one button, because the // intent carried no train at all. const s = game(); const [a, b] = twoAtOffice(s); const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board'); assert.deepEqual( boards.map((i) => (i as { trayId?: string }).trayId).sort(), [a, b].sort(), 'both trains at the platform must be offered', ); }); it('boards the train the player named, not the first on the A/D tracks', () => { const s = game(); const [a, b] = twoAtOffice(s); assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok); assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers'); assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead'); }); it('still works for an intent that names no train, so old saves replay', () => { // `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical // record every save and every undo replays against. const s = game(); const [a] = twoAtOffice(s); assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok); assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them'); }); }); /** * Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars * and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later * drive right through them." * * Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the * engine can see always couple — which means cars a train can drive through are cars the engine does * not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and * the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried, * so if the case is found later it is somewhere none of them cover. */ describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => { const boxcar = () => ({ type: 'boxcar' as const, loaded: false }); const caboose = () => ({ type: 'caboose' as const, loaded: false }); /** A Freight House card, with cars spotted on its industry track. */ function industry(cars: TrackCard['standing']): TrackCard { return { geometry: { kind: 'facility', facility: 'freightHouse' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: { kind: 'freight', subtype: 'freightHouse', allows: { outbound: true, inbound: true }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 }, menAtWork: [null, null, null], industryTrack: { cars: [...cars] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, modifiers: [], enhancements: [], } as TrackCard; } const empty = (s: GameState, ...coords: GridCoord[]): void => { for (const c of coords) { assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`); } }; it('takes a cut standing on plain track on the way to the caboose', () => { const s = game(); addCard(s, at(0, 2), straight()); addCard(s, at(0, 1), straight([boxcar(), boxcar()])); addCard(s, at(0, 0), straight([caboose()])); const id = placeTray(s, at(0, 2)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok); assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']); empty(s, at(0, 1), at(0, 0)); }); it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => { // Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a // facility, and `carsOn` reads its industry track rather than the card — so this is the case // where the two could have come apart. const s = game(); addCard(s, at(0, 2), straight()); addCard(s, at(0, 1), industry([boxcar(), boxcar()])); addCard(s, at(0, 0), straight([caboose()])); const id = placeTray(s, at(0, 2)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok); assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']); empty(s, at(0, 1), at(0, 0)); }); it("takes the train's own cut off the square it is standing on as well", () => { const s = game(); addCard(s, at(0, 1), straight()); addCard(s, at(0, 0), straight([caboose()])); const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); // Set the pair out behind the engine, pull forward, then back up past them to the caboose. assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok); assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2); assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok); assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']); empty(s, at(0, 1), at(0, 0)); }); });