/** * Mainline modifiers and Maneuver cards (implications.md §7). * * The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes, * Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender, * which happens in solitaire too. * * Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so * there is nothing to implement and nothing to test. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { advance } from '../src/engine/advance.ts'; import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; import { legalActions } from '../src/engine/legal.ts'; import { MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS, REALIGNMENTS, crossingStages, mainlineModifierRule, mainlineProfile, } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameEvent } from '../src/engine/events.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import { coordKey, turnOf } from '../src/engine/state.ts'; import { snapshot } from '../src/sim/view.ts'; const config: GameConfig = { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: 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 }); /** Puts a card of `kind`/`key` in hand and returns its id. */ function hand(s: GameState, kind: string, key: string): string { for (const [id, card] of s.cards) { const k = card.kind as { kind: string; key?: string }; if (k.kind === kind && k.key === key) { s.decks.hands.set(0, [id]); return id; } } throw new Error(`no ${kind} card: ${key}`); } /** * Puts a specific TRACK card in hand and returns its id. * * Track is an ordinary deck card, so building a district means holding the right piece — which is * exactly the constraint these tests need to set up deliberately rather than draw for. */ 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}`); } /** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */ function pinned(s: GameState, index: number, card: string) { const node = s.division.nodes[index]; if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`); node.card = card as never; node.transits = []; return node; } /** Lets the player actually play a card this turn. */ function drawTurn(s: GameState): void { s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'draw'; turnOf(s, 0).drawnThisTurn = true; } // --------------------------------------------------------------------------- describe('grade modifiers change crossing time', () => { it('a Heavy Grade takes two Stages bare', () => { assert.equal(crossingStages('heavyGrade', 'fast', false), 2); }); it('Brakeman speeds the descent but not the climb', () => { // Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which // way is UPHILL. With up = east, a westbound train is descending. assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1); assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2); }); it('follows the orientation the player chose, not a fixed compass direction', () => { // The same train on the same card, with the card turned around: Brakeman helps a westbound // train on an east-climbing grade, and an eastbound one when the grade climbs west. assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1); assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2); assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1); assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2); }); it('Helpers speed the climb but not the descent', () => { assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1); assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2); }); it('Airbrakes stack with Brakeman on a slow train', () => { // A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each. assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3); assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2); assert.equal( crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'), 1, ); }); it('never lets a train cross in no time', () => { assert.equal( crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'), 1, ); }); it('leaves non-grade cards alone', () => { // Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless. assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1); assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2); }); }); describe('placing a Mainline modifier', () => { it('accepts Brakeman on a grade', () => { const s = game(); pinned(s, 1, 'heavyGrade'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null); }); it('rejects Brakeman on anything but a grade', () => { const s = game(); pinned(s, 1, 'plains'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE'); }); it('requires Brakeman before Airbrakes', () => { const s = game(); const node = pinned(s, 1, 'heavyGrade'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'airbrakes'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED'); node.modifiers = ['brakeman']; assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null); }); it('will not modify a card with a train on it', () => { const s = game(); const node = pinned(s, 1, 'heavyGrade'); node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD'); }); it('rejects a Mainline node that is not a Mainline card', () => { const s = game(); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); // Node 0 is the western Division Point. assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT'); }); it('lays the modifier and spends the card', () => { const s = game(); const node = pinned(s, 1, 'heavyGrade'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 }); assert.ok(r.ok, 'placement should be accepted'); assert.deepEqual(node.modifiers, ['brakeman']); assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand'); assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard'); }); }); describe('Realignment converts one Mainline type to another', () => { it('converts according to the table', () => { const s = game(); const node = pinned(s, 1, 'curves'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'realignment'); const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 }); assert.ok(r.ok); assert.equal(node.card, 'plains', 'Curves realigns to Plains'); // The point of the card: Curves is a 30 (two Stages), Plains a 60 (one). assert.equal(crossingStages(node.card, 'fast', false), 1); }); it('refuses a card with no conversion listed', () => { const s = game(); // Tunnel is not a `from` in REALIGNMENTS. assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel')); pinned(s, 1, 'tunnel'); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'realignment'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT'); }); it('applies to any Mainline card, not only grades', () => { assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false); }); }); describe('Red Flags protect a stopped train', () => { /** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */ function rearEnder(s: GameState) { const node = pinned(s, 1, 'plains'); node.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, }); const dp = s.division.nodes[0]; if (dp?.kind === 'divisionPoint') dp.holding.push('behind'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); return node; } it('holds the approaching train instead of letting it close', () => { const s = game(); const node = rearEnder(s); node.redFlagged = ['ahead']; advance(s); assert.deepEqual( s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' }, 'the flagged train must not be approached', ); }); it('comes in when the protected train rolls', () => { const s = game(); const node = rearEnder(s); node.redFlagged = ['ahead']; // Bring the protected train to the end of its crossing so it leaves the card. node.transits[0]!.stagesRemaining = 1; for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s); assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off'); }); it('only protects a train out on the Mainline', () => { const s = game(); rearEnder(s); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); assert.equal( check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }), 'NO_PLACEMENT', 'a train sitting at a Division Point cannot be rear-ended', ); assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null); }); it('will not double-flag the same train', () => { const s = game(); const node = rearEnder(s); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); node.redFlagged = ['ahead']; assert.equal( check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), 'OPTION_ALREADY_CHOSEN', ); }); }); describe('Flying Switch rolls a cut into an industry', () => { /** A crew on a siding below the main, with a facility next door along the same track. */ function setup(s: GameState) { const area = areaOf(s, 0); // A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by // rail. Every run is east-west now, and the siding gets down off the main the only way there is: // // row 0: [office] [turnout, leg south] <- Running Track // row -1: [curve ne] [straight, crew] [industry] <- the siding // const plain = (): TrackCard => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); area.grid.set(coordKey(at(0, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' }, baseOperationalRail: false, }); area.grid.set(coordKey(at(-1, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left' } }); area.grid.set(coordKey(at(-1, 2)), plain()); const industry: TrackCard = { ...plain(), 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 }, }, }; area.grid.set(coordKey(at(-1, 3)), industry); s.trays.set('crew', { id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0, consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }], direction: 'east', facing: 'e', position: { at: 'grid', seat: 0, coord: at(-1, 2) }, movesUsed: 0, }); s.clock.phase = 'localOps'; s.clock.currentActor = 0; turnOf(s, 0).option = 'switch'; turnOf(s, 0).movesRemaining = 6; return industry; } it('drops the cut onto the industry track without moving the engine', () => { const s = game(); const industry = setup(s); const cardId = hand(s, 'maneuver', 'flyingSwitch'); const r = applyIntent(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 3), }); assert.ok(r.ok, 'the flying switch should be accepted'); assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track'); assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car'); assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist'); assert.deepEqual( s.trays.get('crew')!.position, { at: 'grid', seat: 0, coord: at(-1, 2) }, 'the engine never enters the industry', ); assert.equal(turnOf(s, 0).movesRemaining, 5, 'the manoeuvre costs a Move'); }); it('only reaches an adjacent card', () => { const s = game(); setup(s); const cardId = hand(s, 'maneuver', 'flyingSwitch'); assert.equal( check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }), 'NOT_CONNECTED', ); }); it('rolls into an industry, not onto plain track', () => { const s = game(); setup(s); const cardId = hand(s, 'maneuver', 'flyingSwitch'); // (0,0) is the Office — track-connected to the crew back up through the turnout, but not a // freight industry. assert.equal( check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }), 'CANNOT_DROP_HERE', ); }); it('will not roll onto a card with no track connection', () => { const s = game(); setup(s); const area = areaOf(s, 0); // An industry sitting directly beneath the crew. Both cards are plain east-west track, so // nothing crosses the edge between them: adjacency is not a rail. const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! }; area.grid.set(coordKey(at(-2, 2)), stranded); const cardId = hand(s, 'maneuver', 'flyingSwitch'); assert.equal( check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 2) }), 'NOT_CONNECTED', ); }); it('cannot cut more cars than it is hauling', () => { const s = game(); setup(s); const cardId = hand(s, 'maneuver', 'flyingSwitch'); assert.equal( check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-1, 3) }), 'CONSIST_EMPTY', ); }); }); describe('card coverage', () => { it('every Mainline modifier is either implemented or a known duplicate', () => { for (const card of MAINLINE_MODIFIER_CARDS) { const known = mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline'; assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`); } }); it('Poling stays unimplemented AND out of the deck, deliberately', () => { // Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the // source says otherwise, a silent no-op would be worse than a rejection. // // It is dealt zero copies rather than deleted: a card that cannot be played is worse in a // player's hand than absent from the deck, but the entry stays so the gap remains visible and // the card returns the moment its rule is known. const poling = MANEUVER_CARDS.find((c) => c.key === 'poling'); assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish'); assert.match(poling!.effect, /TBD/i); assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played'); }); }); // --------------------------------------------------------------------------- describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => { it('refuses to build beyond the Limits sign', () => { // §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point. // Offering the square PAST it produced, from one straight laid at (0,2): // limits · Whistle Post · limits · straight · limits // — track on the far side of the sign, and a second sign planted beyond that. const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; const straight = trackInHand(s, 'straight', 'none'); const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 }; const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 }; for (const placement of [beyondEast, beyondWest]) { assert.notEqual( check(s, 0, { type: 'card.play', cardId: straight, placement, variant: 0 }), null, `(${placement.row},${placement.col}) is outside the Limits and must not be placeable`, ); } // The sign itself IS legal — that is how the Running Track grows. assert.equal( check(s, 0, { type: 'card.play', cardId: straight, placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0, }), null, 'laying on the Limits sign must be how the track extends', ); // A district opens with a TURNOUT on the Running Track — no office or industry card carries a // stub of its own — so extend onto the east sign with one. const turnoutCol = area.limitsEast.col; assert.ok( applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'left'), placement: { row: area.runningRow, col: turnoutCol }, variant: 0, }).ok, 'a turnout must be layable on the Limits sign', ); // The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a // left-hand curve is the `nw` arc — the one on the same diagonal as the turnout's leg. assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'curved', 'left'), placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1, }), null, 'Secondary Track below the turnout must stay legal', ); }); it('keeps the Limits at the ends, never stranded mid-track', () => { // Found by watching a replay: a district grew to // (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) … // with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and // §10 both reason about "the track between the train and the Limits", and running past a // player's Limits is what makes a collision his fault. const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; for (let n = 0; n < 4; n++) { const target = { row: area.runningRow, col: area.limitsWest.col }; const r = applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0, }); assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`); } const onRunning = [...area.grid.entries()] .map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind })) .filter((x) => !Number.isNaN(x.col)); const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b); const cols = onRunning.map((x) => x.col); assert.equal(limits.length, 2, 'there must be exactly two Limits signs'); assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card'); assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card'); }); }); // --------------------------------------------------------------------------- describe('a turnout may be laid on top of a card already down', () => { /** Lays a straight inside the Limits and returns where it went. */ const layStraight = (s: GameState): GridCoord => { const area = areaOf(s, 0); const target = { row: area.runningRow, col: area.limitsEast.col }; const r = applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0, }); assert.ok(r.ok, 'the straight should lay on the Limits sign'); return target; }; it('upgrades a straight, which is how a district opens off track already built', () => { // REPORTED from playtesting: a district can only hang off a turnout, so a player who laid a // straight along the main and then wanted to branch there had no move at all — the piece had to // have been a turnout when it went down. const s = game(); turnOf(s, 0).option = 'draw'; const at = layStraight(s); for (const hand of ['left', 'right'] as const) { for (let variant = 0; variant < 2; variant++) { assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: at, variant }), null, `a ${hand}-hand turnout at rotation ${variant} must be able to upgrade a straight`, ); } } const r = applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0, }); assert.ok(r.ok); const now = areaOf(s, 0).grid.get(`${at.row},${at.col}`)!; assert.equal(now.geometry.kind === 'track' && now.geometry.geometry, 'turnout', 'the square is still a straight'); }); it('upgrades a curve only when the turnout diverges onto the same arc', () => { // The ARC, not merely the diagonal. A `sw` curve and an `ne` one share a slope but leave by // opposite edges, so swapping one for the other would move the leg off whatever it joined. const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; // A turnout on the main, then its matching curve on the row below — the start of every siding. const turnoutAt = { row: area.runningRow, col: area.limitsEast.col }; assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0, }).ok, 'the turnout should lay on the Limits sign'); const curveAt = { row: area.runningRow - 1, col: turnoutAt.col }; assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1, }).ok, 'the matching curve should hang under the turnout'); const laid = area.grid.get(`${curveAt.row},${curveAt.col}`)!; const arc = laid.geometry.kind === 'track' ? laid.geometry.arc : undefined; assert.equal(arc, 'ne', 'the fixture is not the curve this test means to upgrade'); // A turnout whose leg is that same `ne` arc may replace it; every other rotation may not. const matching: string[] = []; for (const hand of ['left', 'right'] as const) { for (let variant = 0; variant < 2; variant++) { const code = check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: curveAt, variant, }); if (code === null) matching.push(`${hand}/${variant}`); else assert.equal(code, 'NOT_UPGRADEABLE_TRACK', `${hand}/${variant} should be refused as a mismatch`); } } assert.deepEqual(matching, ['right/1'], 'exactly the turnout diverging onto `ne` should be accepted'); }); it('refuses to swap the track out from under a car, or out from under an Interlocking', () => { const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; const at = layStraight(s); const square = area.grid.get(`${at.row},${at.col}`)!; square.standing = [{ type: 'boxcar', loaded: false }]; assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }), 'UPGRADE_OCCUPIED', 'track was swapped out from under a standing car', ); square.standing = []; square.enhancements = ['interlocking']; assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }), 'UPGRADE_ENHANCED', 'the upgrade lifted a built Interlocking with the card', ); }); it('refuses to upgrade anything that is not a straight or a matching curve', () => { // The Office is east-west track like a straight, but it is not track to build over. const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: area.officeCoord, variant: 0, }), 'NOT_UPGRADEABLE_TRACK', 'a turnout was laid over the Office', ); }); it('does not let a straight or a curve upgrade anything — only a turnout may', () => { const s = game(); turnOf(s, 0).option = 'draw'; const at = layStraight(s); assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: at, variant: 0 }), 'NOT_UPGRADEABLE_TRACK', 'a straight was laid on top of a straight', ); assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: at, variant: 0 }), 'NOT_UPGRADEABLE_TRACK', 'a curve was laid on top of a straight', ); }); it('offers the upgrade in the legal-move list, or it may as well not exist', () => { // The 18 Enhancement cards were permanently dead for weeks for exactly this reason: the engine // allowed the play and `legalIntents` never once offered a square it could go on. const s = game(); turnOf(s, 0).option = 'draw'; const at = layStraight(s); const cardId = trackInHand(s, 'turnout', 'right'); const offered = legalActions(s, 0).some( (i) => i.type === 'card.play' && i.cardId === cardId && i.placement?.row === at.row && i.placement?.col === at.col, ); assert.ok(offered, 'the occupied square is never offered, so the upgrade cannot be played'); }); }); describe("a train is made up to its card's consist (§8.2)", () => { it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => { // Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and // no caboose, because placeCar checked only MAX_CONSIST and yard availability. const s = game(); s.clock.phase = 'newTrain'; s.trays.set('t', { id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0, }); for (let n = 0; n < 3; n++) { const r = applyIntent(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }); assert.ok(r.ok, `hopper ${n + 1} of 3 should be accepted`); } assert.equal( check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }), 'NO_SUITABLE_CAR', 'a fourth freight car exceeds the card', ); assert.equal( check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'coach', loaded: true }), 'NO_SUITABLE_CAR', 'this card carries no coaches', ); assert.equal( check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'caboose', loaded: true }), null, 'the card calls for a caboose', ); }); it('couples nothing behind the caboose', () => { // §A.3 — the caboose rides last. const s = game(); s.clock.phase = 'newTrain'; s.trays.set('t', { id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [{ type: 'caboose', loaded: true }], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0, }); assert.equal( check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }), 'NO_SUITABLE_CAR', ); }); }); describe('regions on a Mainline card (§2.1, §8.2)', () => { /** Put one train mid-crossing and ask the view where the map should draw it. */ const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => { const s = createGame({ id: 'reg', seed: 4, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['Solitaire'], }); const node = s.division.nodes.find((n) => n.kind === 'mainline'); assert.ok(node && node.kind === 'mainline'); const tray = [...s.trays.keys()][0]!; node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' }); const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml'); assert.ok(ml, 'no Mainline node in the view'); const t = ml!.trains.flat()[0]!; return { region: t.region as number, regions: ml!.regions as number }; }; it('divides every Mainline card into two regions', () => { // §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves // trains still on the road when the next Day begins, locking Crew Trays permanently. assert.equal(regionFor(2, 2).regions, 2); }); it('moves one region per Stage for an ordinary train on a 30 card', () => { // §8.2 exactly. This is the case the printed rule was written for. assert.equal(regionFor(2, 2).region, 0); assert.equal(regionFor(2, 1).region, 1); }); it('starts a fast train further along the card rather than moving it twice', () => { // The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map // shows as entering at the second region. That is the same fact as "Brakemen moves your entry // point further along", which is how the modifier cards are drawn. assert.equal(regionFor(1, 1).region, 1); }); it('holds a slow train at the START, not at the exit', () => { // Three Stages will not fit three steps into two regions. The card's distance is fixed, so the // extra Stage is spent getting going — clamping the entry instead would park it at the exit and // read as a train that raced across and then waited. assert.equal(regionFor(3, 3).region, 0); assert.equal(regionFor(3, 2).region, 0); assert.equal(regionFor(3, 1).region, 1); }); it('never leaves the card it is on', () => { for (let total = 1; total <= 4; total++) { for (let left = total; left >= 1; left--) { const r = regionFor(total, left).region; assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`); } } }); }); describe('Q13 — a train that catches the one ahead runs into it', () => { /** * Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the * leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and * then region 1 — so it catches up whichever order the phase happens to process them in. */ const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => { const s = createGame({ id: 'rear', seed: 3, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['bot'], }); const index = s.division.nodes.findIndex( (n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass, ); assert.ok(index >= 0, 'no single-track Mainline card in this Division'); const node = s.division.nodes[index]!; assert.ok(node.kind === 'mainline'); if (node.kind !== 'mainline') throw new Error('unreachable'); if (opts.absSignals) node.absSignals = true; const make = (n: number, total: number, left: number): string => { const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [{ type: 'boxcar', loaded: false }], direction: 'east', position: { at: 'mainline', index }, } as never); node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' }); return id; }; make(9, 3, 2); // the leader make(11, 2, 2); // the follower, closing s.clock.phase = 'mainline'; const events: GameEvent[] = []; for (let i = 0; i < 4; i++) events.push(...advance(s).events); return { s, events }; }; it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => { // REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the // Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders // — but §8.3's trigger list never named one, so granting clearance was FREE: both trains // survived, no penalty, and ABS Signals protected against nothing. const { events } = twoTrains(); const smash = events.find((e) => e.type === 'trainsDestroyed'); assert.ok(smash, 'the follower ran into the leader and nothing happened'); if (smash.type !== 'trainsDestroyed') throw new Error('unreachable'); assert.match(smash.reason, /ran into the train ahead/); assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains'); assert.ok( events.some((e) => e.type === 'revenueChanged' && e.delta < 0), 'the collision cost nothing', ); }); it('ABS Signals holds the follower short instead', () => { // "Trains on this card will not rear-end each other; they stop short of a collision." const { events } = twoTrains({ absSignals: true }); assert.ok( !events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent the collision', ); assert.ok( events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)), 'nothing was held short of the train ahead', ); }); it('leaves trains alone on a card that prints "trains may pass"', () => { // Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up // there means going past, which is what the card is for. Without this the mechanic fired 0.41 // times a game while the bot never once granted clearance — the tell that they were all // passing cards. const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind); assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']); }); });