Files
station-master/test/mainline-cards.test.ts
T
Jesse.MarkowitzandClaude Opus 5 5e34c73b16 Extras that must run loaded, and a circus paid per district (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."

MAKE-UP. X17 Campaign, X18 Circus and X19 Military carry `mustRunLoaded`. It is a
preference order rather than a flat requirement, so the rule is asked of the
DIVISION YARD: an empty is refused only while the yard can still supply a loaded
car this train would accept, and once it cannot, the empty is legal and the train
may still depart short. Per category, since that is the slot the car competes for
— a loaded coach is no reason to refuse an empty boxcar.

SCORING, per Jesse's ruling (2026-08-29): "once per stop in an office area. In a
multiplayer game, each player could score if the circus stops in their area." So
`stopPointClaimed` (a boolean, once per game) becomes `stopPointSeats` (the seats
already paid). A Circus touring three districts is paid three times; one parked in
the same district all game is paid once. The other half of the ruling — "if the
circus train gets recycled and played a second time as a second extra, then it
could again score points later too" — needs no code: a train is made up onto a
fresh tray every time, so a re-played Extra starts with an empty list.

The point now requires the train to be FULLY LOADED, meaning every non-caboose car
loaded. A coach counts as loaded when occupied, which is what makes this the right
test for the Campaign Train: X17 carries one coach and no freight, so "fully
loaded" is exactly "the candidate is aboard". X17 also GAINS the per-stop point —
it had `stopThenExpedite` and no scoring rule at all, and Jesse's "credit for a
circus or campaign train (one point per stop)" says it should score.

TWO BUGS FIXED ALONG THE WAY:

  - `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, RENDERED to the player
    as "(empties only)" by both web/game.ts and sim/view.ts, and enforced by
    nothing — `acceptsCar` never read it, so the Appleseed could be made up loaded
    while its own card said otherwise. It is the same rule as this issue pointing
    the other way, and it would have been perverse to add one and leave the other.
  - Setting up out on the Mainline paid a point to PLAYER 0 whoever was playing:
    `playerAtSeat` needs a seat, off the grid there is none, and the fallback was
    `0`. Scoping the rule to Office Areas is what the ruling says and removes the
    misattribution rather than patching it.

Both loading rules exempt the caboose: every caboose in ROLLING_STOCK_SUPPLY is
minted loaded, so an unexempted rule would bar the one car a consist lists by name.

`trainNeedingCars` now asks the full question per car rather than the shape
question. It shares its predicate with `check` precisely to avoid the stall its own
comment describes, and the shape question stopped being the same question: an
empties-only train facing a yard of loaded cars would have been told a car was
available and then refused every one.

The two published replays that stopped replaying under the new rules were retired
and re-recorded with save-replay.ts, which verifies each candidate before writing
it. That is what test/harness.test.ts is for and what its comment prescribes.

846 tests pass.

Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 06:44:13 -04:00

1179 lines
52 KiB
TypeScript

/**
* 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, 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. */
/**
* 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<typeof crossingStages>[0],
over: Partial<Parameters<typeof crossingStages>[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 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('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<typeof madeUp>, 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): { 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[] } => {
/**
* 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']);
});
});