/** * Enhancement cards (implications.md §7). * * These are the 18 cards that make up the largest block of the "third of the deck that does * nothing". Several change core loops — Small Yard makes the facing-point switching puzzle * solvable at all, and ABS Signals amends Gap 2's unconditional collisions. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { advance } from '../src/engine/advance.ts'; import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts'; import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts'; const config: GameConfig = { mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, }; const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const at = (row: number, col: number): GridCoord => ({ row, col }); const straight = (): TrackCard => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); function addCard(s: GameState, coord: GridCoord, card: TrackCard): void { areaOf(s, 0).grid.set(coordKey(coord), card); } /** * An Enhancement in hand — minting one if the deck does not deal it. * * Facing Point Locks, the Water Column and the Overpass answer opponent-directed cards, which are * held out of every deck until they are implemented, so those three are no longer dealt either * (v0.4.7). Their RULES are still live and still worth testing: they fire the moment their attacker * returns, and a rule nobody exercises is a rule that rots. So the card is put in hand directly * rather than fished out of a deck that deliberately no longer contains it. */ function handEnhancement(s: GameState, key: string): string { for (const [id, card] of s.cards) { if (card.kind.kind === 'enhancement' && card.kind.key === key) { s.decks.hands.set(0, [id]); return id; } } const id = `mint-${key}-${s.cards.size}`; s.cards.set(id, { id, kind: { kind: 'enhancement', key } } as never); s.decks.hands.set(0, [id]); return id; } /** Puts a specific track card in hand and returns its id. */ function trackInHand(s: GameState, geometry: string, hand: string): string { for (const [id, card] of s.cards) { const k = card.kind as { kind: string; geometry?: string; hand?: string }; if (k.kind === 'track' && k.geometry === geometry && k.hand === hand) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no track card: ${geometry}/${hand}`); } function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string { const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist, direction: 'east', position: { at: 'grid', seat: 0, coord }, movesUsed: 0, }); return id; } // --------------------------------------------------------------------------- describe('enhancement placement', () => { it('defines a rule for every enhancement card', () => { assert.equal(ENHANCEMENT_RULES.length, 10); for (const r of ENHANCEMENT_RULES) assert.ok(enhancementRule(r.key), r.key); }); it('puts Interlocking on a Running Track straight, not a Secondary one', () => { const s = game(); addCard(s, at(0, 2), straight()); addCard(s, at(-1, 0), straight()); const id = handEnhancement(s, 'interlocking'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), null); assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), 'NOT_CONNECTED'); }); it('puts Small Yard on a Secondary Track straight, not the Running Track', () => { const s = game(); addCard(s, at(0, 2), straight()); addCard(s, at(-1, 0), straight()); const id = handEnhancement(s, 'smallYard'); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), null); assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED'); }); it('stacks Telephone on Telegraph and Radio on Telephone, never bare', () => { const s = game(); addCard(s, at(0, 2), straight()); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const phone = handEnhancement(s, 'telephone'); assert.equal(check(s, 0, { type: 'card.play', cardId: phone, placement: at(0, 2) }), 'NOT_CONNECTED'); const tel = handEnhancement(s, 'telegraph'); assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: tel, placement: at(0, 2) }).ok); const phone2 = handEnhancement(s, 'telephone'); assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null); }); it('will not stack two straight-placed enhancements on one card', () => { // A straight-placed Enhancement REPLACES the straight — its printed placement is "any Running // Track Straight", so the card goes down in place of it and what stands there afterwards is an // Interlocking, not a straight carrying one. A second such card has no straight left to take. // This was unchecked, because an enhancement only pushes a string onto `enhancements[]` and // leaves the geometry alone: one straight could hold Interlocking, Telegraph and a Water Column // at once. const s = game(); addCard(s, at(0, 2), straight()); addCard(s, at(0, 3), straight()); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const lock = handEnhancement(s, 'interlocking'); assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) }).ok); for (const key of ['telegraph', 'waterColumn'] as const) { const id = handEnhancement(s, key); assert.equal( check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED', `${key} was stacked on top of an Interlocking`, ); // The bare straight next door is still a legal home for it. assert.equal( check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 3) }), null, `${key} should still be playable on a straight that carries nothing`, ); } // And a turnout cannot upgrade it either — the straight it would replace is no longer there. assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at(0, 2), variant: 0 }), 'UPGRADE_ENHANCED', 'a turnout was laid over a built Interlocking', ); }); it('requires an Interlocking in the district before Facing Point Locks', () => { const s = game(); addCard(s, at(0, 2), straight()); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const fpl = handEnhancement(s, 'facingPointLocks'); assert.equal(check(s, 0, { type: 'card.play', cardId: fpl, placement: at(0, 2) }), 'NOT_CONNECTED'); const lock = handEnhancement(s, 'interlocking'); applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) }); const fpl2 = handEnhancement(s, 'facingPointLocks'); assert.equal(check(s, 0, { type: 'card.play', cardId: fpl2, placement: at(0, 2) }), null); assert.ok(isProtectedFromDerail(areaOf(s, 0)) === false, 'not protected until actually played'); }); }); // --------------------------------------------------------------------------- describe('Small Yard — the card that makes switching solvable', () => { function yardGame() { const s = game(); const yard = at(-1, 0); const card = straight(); card.enhancements.push('smallYard'); addCard(s, yard, card); const tray = placeTray(s, yard, [ { type: 'boxcar', loaded: false }, { type: 'hopper', loaded: true }, { type: 'coach', loaded: false }, ]); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); return { s, tray, yard }; } it('re-orders a consist, including bringing a buried car to the end', () => { // §A.3 forces cars off in seated order, so without this the middle car can never be spotted. const { s, tray } = yardGame(); const r = applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [0, 2, 1] }); assert.ok(r.ok); assert.deepEqual( s.trays.get(tray)!.consist.map((c) => c.type), ['boxcar', 'coach', 'hopper'], 'the hopper is now on the droppable end', ); }); it('costs one Move — "spends one move in the yard"', () => { const { s, tray } = yardGame(); const before = turnOf(s, 0).movesRemaining; applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] }); assert.equal(turnOf(s, 0).movesRemaining, before - 1); }); it('is refused anywhere without a Small Yard', () => { const s = game(); addCard(s, at(-1, 0), straight()); const tray = placeTray(s, at(-1, 0), [ { type: 'boxcar', loaded: false }, { type: 'hopper', loaded: true }, ]); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); assert.equal( check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [1, 0] }), 'NOT_CONNECTED', ); }); it('refuses an order that is not a permutation of the consist', () => { const { s, tray } = yardGame(); for (const bad of [[0, 1], [0, 1, 1], [0, 1, 5]]) { assert.equal( check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: bad }), 'CONSIST_ORDER', `accepted ${JSON.stringify(bad)}`, ); } }); }); // --------------------------------------------------------------------------- describe('Interlocking and Yard Office relieve the Office', () => { function inbound(s: GameState, consist: TrackCard['standing']) { const id = 'inbound'; s.trays.set(id, { id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); const ml = s.division.nodes[1]; if (ml?.kind === 'mainline') { // Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. ml.card = 'plains'; ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); } s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); return id; } it('holds a train at the Limits instead of colliding when the Office is full', () => { // Gap 2d made a full Office an automatic collision. Interlocking is the designed answer. const s = game(); const card = straight(); card.enhancements.push('interlocking'); addCard(s, at(0, 2), card); areaOf(s, 0).adOccupancy = ['blocker']; const id = inbound(s, [{ type: 'hopper', loaded: true }]); advance(s); assert.equal(s.players[0]!.revenue, 0, 'no collision penalty'); assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits'); }); it('still collides without an Interlocking', () => { const s = game(); areaOf(s, 0).adOccupancy = ['blocker']; inbound(s, [{ type: 'hopper', loaded: true }]); advance(s); assert.equal(s.players[0]!.revenue, -5); }); it('diverts a coachless train to the Yard Office', () => { const s = game(); const card = straight(); card.enhancements.push('yardOffice'); addCard(s, at(-1, 0), card); const id = inbound(s, [{ type: 'hopper', loaded: true }]); advance(s); const pos = s.trays.get(id)!.position; assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office'); assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track'); }); it('does not divert a train carrying coaches', () => { // "An inbound train with NO COACHES" — passengers must reach the Train Order Office. const s = game(); const card = straight(); card.enhancements.push('yardOffice'); addCard(s, at(-1, 0), card); const id = inbound(s, [{ type: 'coach', loaded: true }]); advance(s); assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'a coach train must use the Office'); }); }); // --------------------------------------------------------------------------- describe('ABS Signals amend the collision rule', () => { it('turns a following-train judgment into a plain hold', () => { // "Trains on this card will not rear-end each other. They will stop short of a collision." const s = game(); const ml = s.division.nodes[1]; if (ml?.kind === 'mainline') { // Pin the terrain — see the meet tests below. Double Track and Uncontrolled Siding let trains // pass, so the following train would never be held and there would be nothing for ABS to do. ml.card = 'plains'; ml.absSignals = true; ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); } s.trays.set('ahead', { id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); s.trays.set('behind', { id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); const r = advance(s); assert.equal(r.needsInput, false, 'no clearance decision is needed with signals'); assert.equal(s.clock.pendingDecision, null); assert.deepEqual(s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' }); }); }); // --------------------------------------------------------------------------- describe('train revenue per transit — paid to EVERY player when a train completes its run', () => { /** * `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against * a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These * tests are about the rule, so they set the rate; the last one is about the default. */ const paying = (rate = 1, seed = 5): GameState => createGame({ id: 'g', seed, config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } }, playerNames: ['p'], }); /** A train sitting on the A/D track, made up and ready to highball. */ const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => { const id = 'leaving'; s.trays.set(id, { id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [], direction, position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0, }); areaOf(s, 0).adOccupancy.push(id); for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); return id; }; it('pays nothing for merely leaving the Office', () => { /** * THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed, which meant a * five-Office railroad paid five times for one train and paid most to whoever it happened to * pass first. Departing is now worth nothing on its own. */ const s = game(); const before = s.players[0]!.revenue; readyToLeave(s, 'east'); advance(s); assert.equal(s.players[0]!.revenue, before, 'departing the Office still paid'); }); it('pays every player once the train runs off the end of the Division', () => { const s = paying(); const before = s.players.map((p) => p.revenue); readyToLeave(s, 'east'); // Run the Mainline Phase until the train has crossed the Division and left it. const seen: typeof s.players[number]['revenue'][] = []; let completed: { type: string; side?: string } | undefined; for (let i = 0; i < 20 && !completed; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; const r = advance(s); completed = r.events.find((e) => e.type === 'trainCompleted'); if (completed) { assert.equal(completed.side, 'east', 'an eastbound train left by the wrong Division Point'); assert.ok( r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run'), 'no revenue event was emitted for the completed run', ); } seen.push(s.players[0]!.revenue); } assert.ok(completed, 'the train never left the Division'); assert.deepEqual(s.players.map((p) => p.revenue), before.map((r) => r + 1), 'not every player was paid'); // Paid ONCE — the train is gone, so there is nothing left to pay again. const after = s.players.map((p) => p.revenue); for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); } assert.deepEqual(s.players.map((p) => p.revenue), after, 'the same run was paid twice'); }); it('pays nothing for a local crew, which is switching rather than running', () => { const s = paying(); const before = s.players[0]!.revenue; const id = readyToLeave(s, 'east'); s.trays.get(id)!.trainNumber = null; for (let i = 0; i < 20; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); } assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run'); }); it('pays at the rate the game was dealt with, and nothing at the default of zero', () => { // Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not // fill with "+0 Revenue" for work that did not pay. const run = (rate: number): { revenue: number; events: number } => { const s = rate === 0 ? game() : paying(rate); readyToLeave(s, 'east'); let events = 0; let done = false; for (let i = 0; i < 20 && !done; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; const r = advance(s); events += r.events.filter( (e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run', ).length; done = r.events.some((e) => e.type === 'trainCompleted'); } assert.ok(done, `the train never left the Division at rate ${rate}`); return { revenue: s.players[0]!.revenue, events }; }; assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit'); assert.deepEqual(run(1), { revenue: 1, events: 1 }); assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event'); }); }); // --------------------------------------------------------------------------- describe('§8.1 — a train that just arrived from the mainline stops for the Stage', () => { /** * "The train did not just initially arrive from a mainline card at the Office this Stage." * * This holds today, but only as a SIDE EFFECT: the Mainline Phase visits each train once per Stage * in numeric order, so a train that spends its visit arriving has no visit left to depart with. * Nothing states the rule, which makes it exactly the kind of property a later change to the loop * would break in silence. These pin it. * * It is also what gives the printed **Expedite** rule something to do — an Expedited train departs * at the END of the Stage it arrived, precisely because the general rule forbids it departing at * all. It stands through Load/Unload first, which is what makes its coaches workable. */ const arriving = (s: GameState, trainNumber: number, isExtra = false): string => { const id = 'arriving'; s.trays.set(id, { id, trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); const ml = s.division.nodes[1]; if (ml?.kind === 'mainline') { ml.card = 'plains'; // One Stage left to run, so this Stage it rolls off the card and into the Office. ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); } s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); return id; }; it('does not depart the Stage it rolls in', () => { const s = game(); const id = arriving(s, 9); assert.notEqual(trainProfile(9, false)?.rules.expedite, true, 'train 9 must NOT be an Expedite train'); advance(s); assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not arrive at the Office at all'); assert.equal( s.trays.get(id)!.position.at, 'grid', 'the train departed again in the same Stage it arrived from a mainline card', ); }); it('unless its card prints Expedite — but it still stands through Load/Unload first', () => { /** * Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that * Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which * made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable — * measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them. */ const s = game(); assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train'); const id = arriving(s, 3); advance(s); assert.equal( s.trays.get(id)!.position.at, 'grid', 'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it', ); assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage'); assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track'); // Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test // is the departure pass that follows it — not that phase's own input handling. s.clock.phase = 'shiftChange'; advance(s); assert.notEqual( s.trays.get(id)?.position.at, 'grid', 'an Expedite train should be gone by the end of the Stage it arrived', ); }); }); // --------------------------------------------------------------------------- describe('§8.1 asks about the next Subdivision, not the next card', () => { /** * "If there is a train in the next Subdivision moving towards the considered train, the considered * train will not depart." This used to inspect only the card being entered, so a train ran headlong * into a Subdivision an opposing train was two cards deep in and was stopped only when they met. * * At game start every Office is a Whistle Post — not a Control Point — so the whole railroad is ONE * Subdivision: nodes [1, 2, 3] between the two Division Points. That is what makes early traffic so * constrained, and each upgrade to a Control Point splits it and buys capacity back. */ const facingTrainOn = (s: GameState, index: number): void => { // EVERY Mainline card is pinned, not just the one the facing train stands on: Double Track and // Uncontrolled Siding print "trains may pass", so terrain anywhere in the Subdivision can clear // the departure. Terrain is dealt from the same RNG stream as the cards, so leaving any of it to // the seed makes this luck — which is exactly how it broke when the deck composition changed. for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; const ml = s.division.nodes[index]; if (ml?.kind === 'mainline') { ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('facing', { id: 'facing', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'mainline', index }, movesUsed: 0, }); s.trays.set('considered', { id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); }; it('holds a train out of a Subdivision an opposing train is deep inside', () => { const s = game(); assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'the fixture assumes one Subdivision of three nodes'); // The facing train is on node 3 — NOT the card being entered, which is node 1. facingTrainOn(s, 3); const r = advance(s); assert.equal(r.needsInput, false, 'an opposing train is an absolute bar, never a judgment call'); assert.equal(s.clock.pendingDecision, null); assert.deepEqual( s.trays.get('considered')!.position, { at: 'divisionPoint', side: 'west' }, 'the train departed into a Subdivision already occupied by a train coming the other way', ); }); it('ignores a train standing clear on Secondary Track in a Whistle Post district', () => { // The last §8.1 condition: "if the Subdivision contains a Whistle Post yet a train is occupying // Secondary Track there (i.e. clear of the Running Track), the Superintendent MUST Highball the // train." The Office node sits inside this Subdivision, so a scan that counted everything in the // group would have blocked on it. Only Mainline occupancy counts. const s = game(); assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'node 2 is the Office, inside the Subdivision'); addCard(s, at(-1, 0), straight()); placeTray(s, at(-1, 0)); s.trays.set('considered', { id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); const ml = s.division.nodes[1]; if (ml?.kind === 'mainline') ml.card = 'plains'; s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.deepEqual( s.trays.get('considered')!.position, { at: 'mainline', index: 1 }, 'a train standing clear on Secondary Track held up the whole Subdivision', ); }); it('still lets it go once a Control Point splits that Subdivision away', () => { // The same position, with the Office upgraded to a Depot: node 2 becomes a boundary, so the // facing train on node 3 is in a DIFFERENT Subdivision and no longer bars the departure. const s = game(); areaOf(s, 0).tier = 'depot'; assert.deepEqual(subdivisions(s), [[1], [3]], 'a Control Point should split the Division in two'); facingTrainOn(s, 3); advance(s); assert.deepEqual( s.trays.get('considered')!.position, { at: 'mainline', index: 1 }, 'a train in another Subdivision must not hold this one at the Division Point', ); }); }); // --------------------------------------------------------------------------- describe('Telegraph, Telephone and Radio dispatch meets', () => { function meet(s: GameState, device?: string) { if (device) { const card = straight(); card.enhancements.push(device); addCard(s, at(0, 2), card); } // An oncoming senior train — §8.1 makes this an absolute bar without a device. s.trays.set('oncoming', { id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); const ml = s.division.nodes[1]; if (ml?.kind === 'mainline') { // Pin the terrain. Mainline types are drawn from the shuffled deck, so deck composition would // otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar this test is about. ml.card = 'plains'; ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('mine', { id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); } it('holds a facing train with no device — §8.1 stands', () => { const s = game(); meet(s); advance(s); assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' }); }); it('lets a junior train win the meet with a Telegraph', () => { // Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins. const s = game(); meet(s, 'radio'); advance(s); assert.equal(s.trays.get('mine')!.position.at, 'mainline', 'the meet was dispatched'); assert.ok(areaOf(s, 0).dispatchUsedToday.includes('radio')); }); it('spends each device only once a Day', () => { const s = game(); meet(s, 'radio'); advance(s); assert.deepEqual(areaOf(s, 0).dispatchUsedToday, ['radio']); // Reset happens at the Day boundary. s.clock.stage = 12; s.clock.phase = 'shiftChange'; advance(s); assert.deepEqual(areaOf(s, 0).dispatchUsedToday, [], 'devices reset each Day'); }); it('gives Radio the largest bonus and Telegraph the smallest', () => { assert.equal(enhancementRule('telegraph')!.dispatchBonus, 4); assert.equal(enhancementRule('telephone')!.dispatchBonus, 8); assert.equal(enhancementRule('radio')!.dispatchBonus, 12); }); }); // --------------------------------------------------------------------------- describe('defensive enhancements', () => { it('reports district protection only once actually built', () => { // These guard against opponent-directed cards, which a solitaire deck omits entirely (Q6). const s = game(); assert.equal(isProtectedFromDerail(areaOf(s, 0)), false); const card = straight(); card.enhancements.push('facingPointLocks'); addCard(s, at(0, 2), card); assert.equal(isProtectedFromDerail(areaOf(s, 0)), true); assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false); }); });