/** * 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 type { MainlineKind } from '../src/engine/content.ts'; 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, decisionActor, 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, // These fixtures were written against a Whistle Post opening — one A/D track and no // Control Point — and several of them test exactly that. Named explicitly since the // default became a Depot. houseRules: { startingOffice: 'whistlePost' }, 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. */ /** * Puts a specific rules card in hand and returns its id, MINTING ONE IF THE DECK NO LONGER DEALS IT. * * A card at `copies: 0` is still a card: the catalogue keeps its row and the engine keeps its rule, * so the design stays visible and the mechanic works the moment it is dealt again. Poling and the * sharp curves have been treated that way for a while, and Gitea#14 put the dispatching ladder, * Facing Point Locks, Flying Switch, Section House and Vandalism there too — none of them are in * `docs/Deck cards5.xlsx`. * * This used to throw when it could not find one, which made "dealt zero copies" and "deleted" * indistinguishable from a test's point of view: zeroing Flying Switch took five passing tests of a * rule that had not changed at all down with it. Minting keeps the rule under test independently of * whether the deck currently deals the card, which is the whole reason for keeping the row. */ 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; } } const id = `zero-copy-${kind}-${key}`; s.cards.set(id, { id, kind: { kind, key } } as never); s.decks.hands.set(0, [id]); return id; } /** * 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; } // --------------------------------------------------------------------------- /** * Crossing time on the region model (Gitea#3). `cross` fills in the parts each test does not care * about, so the numbers below read as the table RAR gave rather than as argument lists. */ const cross = ( kind: Parameters[0], over: Partial[1]> = {}, ): number => crossingStages(kind, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [], ...over, }); describe('a card costs one Stage per printed region (Gitea#3)', () => { /** * RAR, 2026-08-26, and this REPLACES the two rules that were here before — Q1, "the printed 60/30 * are mph expressed as crossing time", and Q2, "a Slow train adds one Stage to every card". * * "Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it * takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3 * unless you have help." * * The report that opened the issue was a Slow train taking two Stages to clear Double Track. Q2 is * what did that, and it is gone. */ it('crosses in the number of regions the card prints, whatever the train', () => { for (const speed of ['fast', 'slow'] as const) { assert.equal(cross('plains', { trainSpeed: speed }), 1, `plains, ${speed}`); assert.equal(cross('doubleTrack', { trainSpeed: speed }), 1, `double track, ${speed}`); assert.equal(cross('trestle', { trainSpeed: speed }), 1, `trestle, ${speed}`); assert.equal(cross('curves', { trainSpeed: speed }), 2, `curves, ${speed}`); assert.equal(cross('tunnel', { trainSpeed: speed }), 2, `tunnel, ${speed}`); assert.equal(cross('heavyGrade', { trainSpeed: speed }), 3, `heavy grade, ${speed}`); } }); it('reads Fast/Slow on Hilly and on nothing else', () => { // "Some cards say fast / slow… Fast / Slow does not apply to every card — just those that say // fast / slow on them. Currently this is only hilly." A fast train starts in the second region. assert.equal(cross('hilly', { trainSpeed: 'fast' }), 1); assert.equal(cross('hilly', { trainSpeed: 'slow' }), 2); }); it('does not read the consist any more', () => { // Hilly used to take its split off the printed P60/F30 and decide by whether the train carried a // coach, so a fast freight crossed slower than a slow passenger train. RAR: "I notice that you // are basing stages in mainline cards off coach/non-coach. Actually, all trains are rated as // FAST and SLOW." `crossingStages` no longer takes a consist at all — this test is here so the // deletion is deliberate rather than incidental. assert.equal(cross('hilly', { trainSpeed: 'fast' }), cross('hilly', { trainSpeed: 'fast' })); }); it('runs a train through a siding or an Interchange in one Stage', () => { // Both print a back region that is not part of the road, so a train passing through starts past // it. What that region is FOR is tested below and in the collision tests. assert.equal(cross('uncontrolledSiding'), 1); assert.equal(cross('interchange'), 1); }); it('costs the extra Stage to anything starting in that back region', () => { // The Uncontrolled Siding with a train already on it, and an Extra beginning its run at an // Interchange. Both start at the back and have the whole card to run. assert.equal(cross('uncontrolledSiding', { startsAtBack: true }), 2); assert.equal(cross('interchange', { startsAtBack: true }), 2); }); }); describe('grade modifiers move the start, not the clock', () => { it('a Heavy Grade takes three Stages bare', () => { // Three regions, up from the two the old 30mph reading gave it. assert.equal(cross('heavyGrade'), 3); }); it('Brakeman speeds the descent but not the climb', () => { // Q11 — the card prints "(Up)" and "Player sets orientation", so `gradeUp` is which way is // UPHILL. With up = east, a westbound train is descending. assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2); assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east' }), 3); }); it('follows the orientation the card was dealt, not a fixed compass direction', () => { // The same train on the same card, with the card turned around. assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east', gradeUp: 'west' }), 2); assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west', gradeUp: 'west' }), 3); assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west', gradeUp: 'west' }), 2); assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east', gradeUp: 'west' }), 3); }); it('Helpers speed the climb but not the descent', () => { // RAR: "helpers… helps all trains going up hill by starting 1 region easier — so 2 to traverse, // not 3." assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east' }), 2); assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west' }), 3); }); it('Airbrakes stack on top of Brakeman', () => { // "Airbrakes is an upgrade from brakemen (which must be played first)", so a fully-equipped // grade is one Stage downhill — and `check` refuses Airbrakes without Brakeman already there. assert.equal(cross('heavyGrade', { direction: 'west' }), 3); assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2); assert.equal(cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes'], direction: 'west' }), 1); }); it('never lets a train cross in no time', () => { // Three modifiers on a three-region card would otherwise put the start past the far edge. assert.equal( cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes', 'helpers'], direction: 'west' }), 1, ); }); it('leaves non-grade cards alone', () => { // Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless. assert.equal(cross('plains', { modifiers: ['brakeman'], direction: 'west' }), 1); assert.equal(cross('curves', { modifiers: ['helpers'] }), 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 prints two regions, Plains one, so realigning halves the time. assert.equal(cross(node.card), 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 hold a train out of your Limits (Gitea#19)', () => { /** * REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped * train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4 * plays across 600 games, a mechanic nobody used. ABS Signals already does that job better. * * Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits * from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops — * one card, one train. */ /** A westbound train one Stage from entering seat 0's district from the east. */ function approaching(s: GameState) { const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); const node = pinned(s, officeIndex + 1, 'plains'); node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' }); s.trays.set('inbound', { id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [{ type: 'hopper', loaded: true }], direction: 'west', position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0, }); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); return s.division.nodes[officeIndex] as Extract; } it('FLAG EAST holds a westbound train short of the Limits', () => { const s = game(); const office = approaching(s); office.redFlag = 'east'; advance(s); const pos = s.trays.get('inbound')!.position; assert.equal(pos.at, 'mainline', 'the flagged train came in anyway'); assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track'); }); it('is spent on the train it stops — one card, one train', () => { const s = game(); const office = approaching(s); office.redFlag = 'east'; advance(s); assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train'); }); it('lets the train in on the next Mainline Phase', () => { // "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection. const s = game(); const office = approaching(s); office.redFlag = 'east'; advance(s); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in'); }); it('does not hold a train coming from the OTHER side', () => { // "Flag East holds westbound trains" — an eastbound train arrives from the west. const s = game(); const office = approaching(s); office.redFlag = 'west'; advance(s); assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east'); assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent'); }); it('is played on a side, not on a train', () => { const s = game(); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null); assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null); }); it('will not double-flag the same side', () => { const s = game(); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); const office = s.division.nodes[officeIndex] as { redFlag?: string }; office.redFlag = 'east'; assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED'); assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null, 'the other side should still be free'); }); }); describe('Red Flags offered at the moment of danger (Gitea#19)', () => { /** * "In actual cases of danger… if there is a train or cars on the track and there will be a * collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way, * you can play the card normally or out of phase, but only if you need it." * * The engine establishes the danger, so the player is never asked to judge it — which is also why * the bot can now use this card at all. It is offered ONLY to somebody holding one. */ function dangerous(s: GameState, giveCard: boolean) { const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); const node = pinned(s, officeIndex + 1, 'plains'); node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' }); s.trays.set('inbound', { id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [{ type: 'hopper', loaded: true }], direction: 'west', position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0, }); // A hopper fouling the Running Track: §8.3 makes this arrival a collision. const area = areaOf(s, 0); area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }]; if (giveCard) hand(s, 'maneuver', 'redFlags'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); } it('breaks in to offer the flag when an arrival would collide', () => { const s = game(); dangerous(s, true); advance(s); assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision'); assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player'); }); it('flagging holds the train and costs the card', () => { const s = game(); dangerous(s, true); advance(s); const before = (s.decks.hands.get(0) ?? []).length; const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true }); assert.ok(r.ok, 'the flag was refused'); advance(s); assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway'); assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless'); assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent'); }); it('declining lets the collision happen', () => { const s = game(); dangerous(s, true); advance(s); assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok); advance(s); assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide'); }); it('does not offer a flag to a player holding none', () => { // A prompt with one button is not a choice, and it leaks that a collision is coming. const s = game(); dangerous(s, false); advance(s); assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card'); assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened'); }); it('stays quiet when the arrival is safe', () => { const s = game(); dangerous(s, true); // Clear the hazard: nothing fouling the Running Track, and room at the Office. const area = areaOf(s, 0); area.grid.get(coordKey(area.officeCoord))!.standing = []; advance(s); assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival'); }); }); 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. */ /** Force an industry card into hand and return its id, the way `trackInHand` does for track. */ const facilityInHand = (s: GameState): string => { for (const [id, card] of s.cards) { if ((card.kind as { kind: string }).kind !== 'freightFacility') continue; const hand = s.decks.hands.get(0) ?? []; if (!hand.includes(id)) hand.push(id); s.decks.hands.set(0, hand); return id; } throw new Error('no freight facility card in the deck'); }; 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('builds an industry over a straight already laid, off the Running Track', () => { /** * REPORTED FROM A TABLE: "just like you could play a turnout over a straight or a curve, the * game should allow placing an industry over a straight on a non-running track." * * A player who lays the rail first and draws the industry afterwards otherwise has no move, * which punishes building a district in the sensible order. The swap is safe for the same * reason the turnout upgrade is: `protoCard` builds every Facility as plain east-west track, so * replacing a straight is port-for-port and no neighbour loses a join. */ const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; // A stub below the main: a turnout on the Running Track, a straight hanging under it. 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'); // Laying on the Limits sign moved it outward, so the square east of the curve is now inside. const stub = { row: curveAt.row, col: curveAt.col + 1 }; assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: stub, variant: 0, }).ok, 'the straight should run east off the curve'); const industry = facilityInHand(s); assert.equal( check(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }), null, 'an industry could not be built over a straight on a stub', ); // And the menu offers it, or the rule exists and is never presented. const offered = legalActions(s, 0).some( (i) => i.type === 'card.play' && i.cardId === industry && i.placement?.row === stub.row && i.placement.col === stub.col, ); assert.ok(offered, 'the square is legal but never enumerated, so it cannot be chosen'); // It really replaces the track, rather than being refused after the fact. assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }).ok); assert.equal(area.grid.get(`${stub.row},${stub.col}`)?.geometry.kind, 'facility'); }); it('will not build an industry over the Running Track, a curve or a turnout', () => { // The Running Track is §11.2's own rule. Curves and turnouts carry ports a Facility does not, // so building over one could sever a neighbour's join — which is why only a straight is allowed. const s = game(); const area = areaOf(s, 0); turnOf(s, 0).option = 'draw'; 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'); assert.equal( check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: turnoutAt, variant: 0 }), 'ON_RUNNING_TRACK', 'an industry was allowed onto the Running Track', ); 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 curve should hang under the main'); assert.equal( check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: curveAt, variant: 0 }), 'NOT_UPGRADEABLE_TRACK', 'an industry was allowed over a curve, whose ports it does not carry', ); }); 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('extras that must run loaded, and one that must not (Gitea#13)', () => { /** * "I've redefined some of the extra trains that they have to run full boxcars — military trains, * circus trains, etc. If not loaded, then empty, and if none available, run without." * * A PREFERENCE ORDER, so every test here is about what the DIVISION YARD still holds. The rule * has nothing to say about a train once it is running; it decides which car may be taken next. */ const madeUp = (trainNumber: number) => { const s = game(); s.clock.phase = 'newTrain'; s.trays.set('t', { id: 't', trainNumber, trainIsExtra: true, engineAt: 0, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); return s; }; const place = (carType: string, loaded: boolean) => ({ type: 'newTrain.placeCar', trayId: 't', carType, loaded }) as never; /** Leaves the Division Yard holding exactly the cars described. */ const stockYard = (s: ReturnType, cars: { type: string; loaded: boolean }[]) => { s.yards.divisionYard.length = 0; s.yards.divisionYard.push(...(cars as never[])); }; it('refuses an empty while the yard can still supply a loaded one (X18 Circus)', () => { const s = madeUp(18); stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]); assert.equal(check(s, 0, place('boxcar', false)), 'NO_SUITABLE_CAR', 'an empty was accepted while a loaded boxcar was still in the yard'); assert.equal(check(s, 0, place('boxcar', true)), null, 'the loaded boxcar was refused'); }); it('accepts an empty once the yard has no loaded car of that kind left', () => { // "If not loaded, then empty." The rule releases as soon as the yard cannot supply. const s = madeUp(18); stockYard(s, [{ type: 'boxcar', loaded: false }]); assert.equal(check(s, 0, place('boxcar', false)), null, 'an empty was refused when the yard held no loaded car at all'); }); it('does not let a loaded car of the WRONG category unlock the rule', () => { // A loaded coach is no reason to refuse an empty boxcar: the preference is per category, since // that is the slot the car is competing for. const s = madeUp(18); stockYard(s, [{ type: 'coach', loaded: true }, { type: 'boxcar', loaded: false }]); assert.equal(check(s, 0, place('boxcar', false)), null, 'a loaded coach blocked an empty boxcar'); }); it('exempts the caboose, which is never empty in the supply', () => { const s = madeUp(18); stockYard(s, [{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }]); assert.equal(check(s, 0, place('caboose', true)), null, 'the caboose its card calls for was refused'); }); it('applies to the Military train too', () => { const s = madeUp(19); stockYard(s, [{ type: 'coach', loaded: true }, { type: 'coach', loaded: false }]); assert.equal(check(s, 0, place('coach', false)), 'NO_SUITABLE_CAR', 'the Military train took an empty coach over a loaded one'); }); it('leaves trains without the rule alone', () => { // X21 Freight Extra has no loading rule: an empty is as good as a loaded one. const s = madeUp(21); stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]); assert.equal(check(s, 0, place('boxcar', false)), null, 'a train with no loading rule was made to prefer loaded cars'); }); it('REGRESSION: X13 Appleseed is empties-only, and now the rules say so too', () => { /** * `ConsistSpec.emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" * by `web/game.ts` and `sim/view.ts`, and enforced by NOTHING — `acceptsCar` never read it. So * the Appleseed could be made up with loaded cars while its own card said it could not. Found * while building Gitea#13, which is the same rule pointing the other way. */ const s = madeUp(13); stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]); assert.equal(check(s, 0, place('boxcar', true)), 'NO_SUITABLE_CAR', 'the Appleseed took a loaded car despite printing "empties only"'); assert.equal(check(s, 0, place('boxcar', false)), null, 'the Appleseed refused an empty'); }); it('still lets the Appleseed take the caboose its consist calls for', () => { // Every caboose in ROLLING_STOCK_SUPPLY is minted loaded, so an unexempted empties-only rule // would bar the one car the card explicitly lists. const s = madeUp(13); stockYard(s, [{ type: 'caboose', loaded: true }]); assert.equal(check(s, 0, place('caboose', true)), null, 'the empties-only rule ate the caboose'); }); }); 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, direction: 'east' | 'west' = 'east', ): { 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 }); 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}`); } } }); it('never leaves the card it is on, whichever way it runs', () => { for (const direction of ['east', 'west'] as const) { for (let total = 1; total <= 4; total++) { for (let left = total; left >= 1; left--) { const r = regionFor(total, left, direction).region; assert.ok(r >= 0 && r <= 1, `${direction}, total ${total}, ${left} left put the train in region ${r}`); } } } }); /** * GITEA#22 — A WESTBOUND TRAIN WAS DRAWN IN THE WRONG HALF OF THE CARD. * * `regionOfTransit` answers "how far along its crossing is this train", counted from the end it * ENTERED: a train with everything still to run is in region 0. That is the right question for the * collision rules, which is what the engine asks it, and both directions share the one index space. * * The map asks a different question — WHICH PRINTED BOX, left to right — and used the same number * for it. East is right on this map and always has been, so for an eastbound train the two agree by * luck: it enters at the west end, so "just entered" and "leftmost box" are the same box. A * westbound train enters at the EAST end, so its region 0 is the card's RIGHT-hand box, and drawing * it at index 0 put it at the left — the whole card mirrored. * * Reported from seed 550943578, undo 187, and it cost a collision. Three westbound trains: TX17 had * just entered (2 Stages still to run, so travel index 0) and T5 was nearly across (1 Stage left, * index 1). Physically TX17 was BEHIND T5 — further east, the direction they had both come from. * The map drew TX17 at the left and so put it further WEST, which reads as further ahead. Asked * whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3 * entered behind — into TX17, exactly where the rules had it and nowhere near where the map did. * * The engine was right throughout. Only the picture lied, so the fix is one mirror in the view and * the collision rules are untouched. This is the same class of bug as the consist row at the * Whistle Post (`board-svg.ts`, seed 270861860), which came out mirrored for the same reason. */ describe('Gitea#22 — the map draws a westbound train where it actually is', () => { it('mirrors a westbound train, because it entered from the east end', () => { // Two-region card. Just entered, 2 Stages still to run: an eastbound train is in the WEST box // and a westbound one is in the EAST box, because they came in at opposite ends. assert.equal(regionFor(2, 2, 'east').region, 0); assert.equal(regionFor(2, 2, 'west').region, 1); // One Stage left, nearly across: the two swap. assert.equal(regionFor(2, 1, 'east').region, 1); assert.equal(regionFor(2, 1, 'west').region, 0); }); it('puts the follower behind the leader, not in front of it — the seed 550943578 collision', () => { // TX17 had just entered; T5 was a Stage from the far end. Both westbound, so BEHIND means to // the east, which is to the right, which is the higher index. const tx17 = regionFor(2, 2, 'west').region; const t5 = regionFor(2, 1, 'west').region; assert.ok( tx17 > t5, `a westbound train that has just entered must be drawn east of one that is nearly across, ` + `but TX17 was drawn at ${tx17} and T5 at ${t5}`, ); }); it('leaves an eastbound train where it has always been drawn', () => { // The mirror must not disturb the direction that was right, which is every existing region test // above — those all run east — and the case the printed rule was written for. assert.equal(regionFor(2, 2, 'east').region, 0); assert.equal(regionFor(2, 1, 'east').region, 1); assert.equal(regionFor(1, 1, 'east').region, 1); }); }); }); 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[] } => { /** * THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN. * * This asked for seed 3 and asserted that its Division held a single-track Mainline card. It * does not any more: the Division is laid out from the same RNG stream the card deck is * shuffled from, so changing the SIZE of that deck re-deals the Division too. Gitea#14's deck * counts moved it, and the test failed on its own precondition — "no single-track Mainline card * in this Division" — which says nothing about the rule under test. * * The fixture needs A Division with a card trains may not pass on, not one particular one, so * it now takes the first seed that provides one. That is stable across any future retune, and * it fails loudly if such a Division stops being reachable at all. */ let s!: GameState; let index = -1; for (let seed = 3; seed < 200 && index < 0; seed++) { s = createGame({ id: 'rear', seed, config: { mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }, playerNames: ['bot'], }); index = s.division.nodes.findIndex( (n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass, ); } assert.ok(index >= 0, 'no seed under 200 deals a Division holding a single-track Mainline card'); 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', ); }); /** * ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3). * * A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train * granted clearance is in the same place as the train ahead the moment it arrives. Nothing tested * that: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage crossing never * reaches, so entering behind another train on a Plains was silently free. */ const enteringBehind = (card: MainlineKind, opts: { absSignals?: boolean } = {}) => { const s = game(); const index = s.division.nodes.findIndex((n) => n.kind === 'mainline'); const node = s.division.nodes[index]!; assert.ok(node.kind === 'mainline'); if (node.kind !== 'mainline') throw new Error('unreachable'); node.card = card; node.transits = []; if (opts.absSignals) node.absSignals = true; /** * THE TRAIN ALREADY THERE IS THE JUNIOR ONE, and that is what makes the situation reachable. * * Trains move lowest number first, so a card's occupant normally clears before anything behind * it is even considered — put train 9 on the card and train 11 at the Division Point and 9 has * gone by the time 11 enters. The conflict is a SUPERIOR train catching an inferior one that has * not got out of the way yet, so the numbers run the other way round here. */ const leader = s.freeTrays.pop()!; s.trays.set(leader, { id: leader, trainNumber: 11, trainIsExtra: false, engineAt: 0, consist: [{ type: 'boxcar', loaded: false }], direction: 'east', position: { at: 'mainline', index }, } as never); const total = crossingStages(card, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [] }); node.transits.push({ tray: leader, stagesRemaining: total, stagesTotal: total, direction: 'east' }); // And the one arriving, held at the Division Point west of it. const dp = s.division.nodes[index - 1]; assert.ok(dp && dp.kind === 'divisionPoint', 'expected a Division Point west of the first card'); if (!dp || dp.kind !== 'divisionPoint') throw new Error('unreachable'); const follower = s.freeTrays.pop()!; s.trays.set(follower, { id: follower, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [{ type: 'boxcar', loaded: false }], direction: 'east', position: { at: 'divisionPoint', side: dp.side }, } as never); dp.holding.push(follower); /** * THE SUPERINTENDENT LETS IT IN, which is the whole point. * * A same-direction train in the Subdivision is not an absolute bar — §8.1 makes it a judgment * call, and `advance` stops and asks. Granting it is what puts one train in behind another, and * §10 is then explicit that the wreck is the Superintendent's fault. So the fixture answers * `allow: true` whenever it is asked, and the collision below is the consequence of that * ruling rather than of a rule firing on its own. */ s.clock.phase = 'mainline'; const events: GameEvent[] = []; for (let i = 0; i < 6; i++) { events.push(...advance(s).events); if (s.clock.pendingDecision !== null) { const who = s.clock.superintendent; const r = applyIntent(s, who, { type: 'mainline.clearance', allow: true }); assert.ok(r.ok, `clearance refused: ${r.ok ? '' : r.code}`); events.push(...r.events); } } return { s, node, events, follower }; }; it('runs a train into the one ahead when it ENTERS an occupied region', () => { const { events } = enteringBehind('plains'); const smash = events.find((e) => e.type === 'trainsDestroyed'); assert.ok(smash, 'a train entered a one-region card behind another and nothing happened'); }); it('holds it short instead when the card carries ABS Signals', () => { // RAR: "ABS. This is played on a mainline card to prevent collisions. If a collision would // normally occur, the train moving onto the card is instead held back." const { events } = enteringBehind('plains', { absSignals: true }); assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent it'); assert.ok( events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)), 'nothing was held short of the train ahead', ); }); it('takes the siding instead of colliding on an Uncontrolled Siding', () => { // "If a train already exists when you arrive, you go in the second stage back — you are in the // siding and are one behind the other train. This prevents a collision, since you are not in // same exact location." So: no wreck, both trains on the card, and the newcomer paying the // extra Stage for the detour. const { node, events, follower } = enteringBehind('uncontrolledSiding'); assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'the siding did not prevent a collision'); const mine = node.transits.find((t) => t.tray === follower); assert.ok(mine, 'the arriving train never made it onto the card'); assert.equal(mine.stagesTotal, 2, 'it should have entered at the back of the card, not the front'); }); it('leaves trains alone on the one card that prints "trains may pass"', () => { // Double Track holds two trains because it HAS two roads. Catching up there means going past, // which is what the card is for. // // THE UNCONTROLLED SIDING USED TO BE IN THIS LIST AND IS NOT ANY MORE (Gitea#3). It holds two // trains as well, but not by letting them share a place: the second one takes the siding and // sits a region behind, which is what keeps them apart — "you are in the siding and are one // behind the other train. This prevents a collision, since you are not in same exact location." // Marked "may pass" it skipped the collision test entirely, so the siding did nothing at all and // two trains could occupy the same region of it unchallenged. const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind); assert.deepEqual(passing, ['doubleTrack']); }); });