Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
871 lines
36 KiB
TypeScript
871 lines
36 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 { 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',
|
|
victory: 'highestAfterDays',
|
|
length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
};
|
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
|
|
|
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
|
function hand(s: GameState, kind: string, key: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
const k = card.kind as { kind: string; key?: string };
|
|
if (k.kind === kind && k.key === key) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
throw new Error(`no ${kind} card: ${key}`);
|
|
}
|
|
|
|
/**
|
|
* Puts a specific TRACK card in hand and returns its id.
|
|
*
|
|
* Track is an ordinary deck card, so building a district means holding the right piece — which is
|
|
* exactly the constraint these tests need to set up deliberately rather than draw for.
|
|
*/
|
|
function trackInHand(s: GameState, geometry: string, hand: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
const k = card.kind as { kind: string; geometry?: string; hand?: string };
|
|
if (k.kind === 'track' && k.geometry === geometry && k.hand === hand) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
throw new Error(`no track card: ${geometry}/${hand}`);
|
|
}
|
|
|
|
/** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */
|
|
function pinned(s: GameState, index: number, card: string) {
|
|
const node = s.division.nodes[index];
|
|
if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`);
|
|
node.card = card as never;
|
|
node.transits = [];
|
|
return node;
|
|
}
|
|
|
|
/** Lets the player actually play a card this turn. */
|
|
function drawTurn(s: GameState): void {
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'draw';
|
|
turnOf(s, 0).drawnThisTurn = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('grade modifiers change crossing time', () => {
|
|
it('a Heavy Grade takes two Stages bare', () => {
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
|
});
|
|
|
|
it('Brakeman speeds the descent but not the climb', () => {
|
|
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
|
// way is UPHILL. With up = east, a westbound train is descending.
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
|
|
});
|
|
|
|
it('follows the orientation the player chose, not a fixed compass direction', () => {
|
|
// The same train on the same card, with the card turned around: Brakeman helps a westbound
|
|
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
|
|
});
|
|
|
|
it('Helpers speed the climb but not the descent', () => {
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
|
});
|
|
|
|
it('Airbrakes stack with Brakeman on a slow train', () => {
|
|
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
|
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
|
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
|
assert.equal(
|
|
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
|
1,
|
|
);
|
|
});
|
|
|
|
it('never lets a train cross in no time', () => {
|
|
assert.equal(
|
|
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
|
1,
|
|
);
|
|
});
|
|
|
|
it('leaves non-grade cards alone', () => {
|
|
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
|
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
|
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
|
});
|
|
});
|
|
|
|
describe('placing a Mainline modifier', () => {
|
|
it('accepts Brakeman on a grade', () => {
|
|
const s = game();
|
|
pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
|
});
|
|
|
|
it('rejects Brakeman on anything but a grade', () => {
|
|
const s = game();
|
|
pinned(s, 1, 'plains');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
|
|
});
|
|
|
|
it('requires Brakeman before Airbrakes', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'airbrakes');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');
|
|
|
|
node.modifiers = ['brakeman'];
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
|
});
|
|
|
|
it('will not modify a card with a train on it', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
|
});
|
|
|
|
it('rejects a Mainline node that is not a Mainline card', () => {
|
|
const s = game();
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
// Node 0 is the western Division Point.
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
|
|
});
|
|
|
|
it('lays the modifier and spends the card', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
|
|
|
assert.ok(r.ok, 'placement should be accepted');
|
|
assert.deepEqual(node.modifiers, ['brakeman']);
|
|
assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
|
|
assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
|
|
});
|
|
});
|
|
|
|
describe('Realignment converts one Mainline type to another', () => {
|
|
it('converts according to the table', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'curves');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
|
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
|
|
|
assert.ok(r.ok);
|
|
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
|
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
|
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
|
});
|
|
|
|
it('refuses a card with no conversion listed', () => {
|
|
const s = game();
|
|
// Tunnel is not a `from` in REALIGNMENTS.
|
|
assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
|
|
pinned(s, 1, 'tunnel');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
|
|
});
|
|
|
|
it('applies to any Mainline card, not only grades', () => {
|
|
assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
|
|
});
|
|
});
|
|
|
|
describe('Red Flags protect a stopped train', () => {
|
|
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
|
function rearEnder(s: GameState) {
|
|
const node = pinned(s, 1, 'plains');
|
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.trays.set('behind', {
|
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
const dp = s.division.nodes[0];
|
|
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return node;
|
|
}
|
|
|
|
it('holds the approaching train instead of letting it close', () => {
|
|
const s = game();
|
|
const node = rearEnder(s);
|
|
node.redFlagged = ['ahead'];
|
|
|
|
advance(s);
|
|
assert.deepEqual(
|
|
s.trays.get('behind')!.position,
|
|
{ at: 'divisionPoint', side: 'west' },
|
|
'the flagged train must not be approached',
|
|
);
|
|
});
|
|
|
|
it('comes in when the protected train rolls', () => {
|
|
const s = game();
|
|
const node = rearEnder(s);
|
|
node.redFlagged = ['ahead'];
|
|
// Bring the protected train to the end of its crossing so it leaves the card.
|
|
node.transits[0]!.stagesRemaining = 1;
|
|
|
|
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
|
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
|
});
|
|
|
|
it('only protects a train out on the Mainline', () => {
|
|
const s = game();
|
|
rearEnder(s);
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
|
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
|
'NO_PLACEMENT',
|
|
'a train sitting at a Division Point cannot be rear-ended',
|
|
);
|
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
|
});
|
|
|
|
it('will not double-flag the same train', () => {
|
|
const s = game();
|
|
const node = rearEnder(s);
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
|
node.redFlagged = ['ahead'];
|
|
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
|
'OPTION_ALREADY_CHOSEN',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Flying Switch rolls a cut into an industry', () => {
|
|
/** A crew on a siding below the main, with a facility next door along the same track. */
|
|
function setup(s: GameState) {
|
|
const area = areaOf(s, 0);
|
|
// A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by
|
|
// rail. Every run is east-west now, and the siding gets down off the main the only way there is:
|
|
//
|
|
// row 0: [office] [turnout, leg south] <- Running Track
|
|
// row -1: [curve ne] [straight, crew] [industry] <- the siding
|
|
//
|
|
const plain = (): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
area.grid.set(coordKey(at(0, 1)), {
|
|
...plain(),
|
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' },
|
|
baseOperationalRail: false,
|
|
});
|
|
area.grid.set(coordKey(at(-1, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left' } });
|
|
area.grid.set(coordKey(at(-1, 2)), plain());
|
|
const industry: TrackCard = {
|
|
...plain(),
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { cars: [] },
|
|
laborers: 1, porters: 0,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
};
|
|
area.grid.set(coordKey(at(-1, 3)), industry);
|
|
|
|
s.trays.set('crew', {
|
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
|
direction: 'east', facing: 'e',
|
|
position: { at: 'grid', seat: 0, coord: at(-1, 2) }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
return industry;
|
|
}
|
|
|
|
it('drops the cut onto the industry track without moving the engine', () => {
|
|
const s = game();
|
|
const industry = setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
|
|
const r = applyIntent(s, 0, {
|
|
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 3),
|
|
});
|
|
assert.ok(r.ok, 'the flying switch should be accepted');
|
|
|
|
assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
|
|
assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
|
|
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
|
|
assert.deepEqual(
|
|
s.trays.get('crew')!.position,
|
|
{ at: 'grid', seat: 0, coord: at(-1, 2) },
|
|
'the engine never enters the industry',
|
|
);
|
|
assert.equal(turnOf(s, 0).movesRemaining, 5, 'the manoeuvre costs a Move');
|
|
});
|
|
|
|
it('only reaches an adjacent card', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('rolls into an industry, not onto plain track', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
// (0,0) is the Office — track-connected to the crew back up through the turnout, but not a
|
|
// freight industry.
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
|
'CANNOT_DROP_HERE',
|
|
);
|
|
});
|
|
|
|
it('will not roll onto a card with no track connection', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const area = areaOf(s, 0);
|
|
// An industry sitting directly beneath the crew. Both cards are plain east-west track, so
|
|
// nothing crosses the edge between them: adjacency is not a rail.
|
|
const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! };
|
|
area.grid.set(coordKey(at(-2, 2)), stranded);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 2) }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('cannot cut more cars than it is hauling', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-1, 3) }),
|
|
'CONSIST_EMPTY',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('card coverage', () => {
|
|
it('every Mainline modifier is either implemented or a known duplicate', () => {
|
|
for (const card of MAINLINE_MODIFIER_CARDS) {
|
|
const known =
|
|
mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
|
|
assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
|
|
}
|
|
});
|
|
|
|
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
|
|
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
|
// source says otherwise, a silent no-op would be worse than a rejection.
|
|
//
|
|
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
|
|
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
|
|
// the card returns the moment its rule is known.
|
|
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
|
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
|
|
assert.match(poling!.effect, /TBD/i);
|
|
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
|
it('refuses to build beyond the Limits sign', () => {
|
|
// §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point.
|
|
// Offering the square PAST it produced, from one straight laid at (0,2):
|
|
// limits · Whistle Post · limits · straight · limits
|
|
// — track on the far side of the sign, and a second sign planted beyond that.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
|
|
const straight = trackInHand(s, 'straight', 'none');
|
|
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
|
|
const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 };
|
|
for (const placement of [beyondEast, beyondWest]) {
|
|
assert.notEqual(
|
|
check(s, 0, { type: 'card.play', cardId: straight, placement, variant: 0 }),
|
|
null,
|
|
`(${placement.row},${placement.col}) is outside the Limits and must not be placeable`,
|
|
);
|
|
}
|
|
|
|
// The sign itself IS legal — that is how the Running Track grows.
|
|
assert.equal(
|
|
check(s, 0, {
|
|
type: 'card.play', cardId: straight,
|
|
placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0,
|
|
}),
|
|
null,
|
|
'laying on the Limits sign must be how the track extends',
|
|
);
|
|
|
|
// A district opens with a TURNOUT on the Running Track — no office or industry card carries a
|
|
// stub of its own — so extend onto the east sign with one.
|
|
const turnoutCol = area.limitsEast.col;
|
|
assert.ok(
|
|
applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', 'left'),
|
|
placement: { row: area.runningRow, col: turnoutCol }, variant: 0,
|
|
}).ok,
|
|
'a turnout must be layable on the Limits sign',
|
|
);
|
|
|
|
// The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a
|
|
// left-hand curve is the `nw` arc — the one on the same diagonal as the turnout's leg.
|
|
assert.equal(
|
|
check(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'curved', 'left'),
|
|
placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1,
|
|
}),
|
|
null,
|
|
'Secondary Track below the turnout must stay legal',
|
|
);
|
|
});
|
|
|
|
it('keeps the Limits at the ends, never stranded mid-track', () => {
|
|
// Found by watching a replay: a district grew to
|
|
// (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
|
|
// with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and
|
|
// §10 both reason about "the track between the train and the Limits", and running past a
|
|
// player's Limits is what makes a collision his fault.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
|
|
for (let n = 0; n < 4; n++) {
|
|
const target = { row: area.runningRow, col: area.limitsWest.col };
|
|
const r = applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0,
|
|
});
|
|
assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`);
|
|
}
|
|
|
|
const onRunning = [...area.grid.entries()]
|
|
.map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind }))
|
|
.filter((x) => !Number.isNaN(x.col));
|
|
const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b);
|
|
const cols = onRunning.map((x) => x.col);
|
|
|
|
assert.equal(limits.length, 2, 'there must be exactly two Limits signs');
|
|
assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card');
|
|
assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('a turnout may be laid on top of a card already down', () => {
|
|
/** Lays a straight inside the Limits and returns where it went. */
|
|
const layStraight = (s: GameState): GridCoord => {
|
|
const area = areaOf(s, 0);
|
|
const target = { row: area.runningRow, col: area.limitsEast.col };
|
|
const r = applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0,
|
|
});
|
|
assert.ok(r.ok, 'the straight should lay on the Limits sign');
|
|
return target;
|
|
};
|
|
|
|
it('upgrades a straight, which is how a district opens off track already built', () => {
|
|
// REPORTED from playtesting: a district can only hang off a turnout, so a player who laid a
|
|
// straight along the main and then wanted to branch there had no move at all — the piece had to
|
|
// have been a turnout when it went down.
|
|
const s = game();
|
|
turnOf(s, 0).option = 'draw';
|
|
const at = layStraight(s);
|
|
|
|
for (const hand of ['left', 'right'] as const) {
|
|
for (let variant = 0; variant < 2; variant++) {
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: at, variant }),
|
|
null,
|
|
`a ${hand}-hand turnout at rotation ${variant} must be able to upgrade a straight`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const r = applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0,
|
|
});
|
|
assert.ok(r.ok);
|
|
const now = areaOf(s, 0).grid.get(`${at.row},${at.col}`)!;
|
|
assert.equal(now.geometry.kind === 'track' && now.geometry.geometry, 'turnout', 'the square is still a straight');
|
|
});
|
|
|
|
it('upgrades a curve only when the turnout diverges onto the same arc', () => {
|
|
// The ARC, not merely the diagonal. A `sw` curve and an `ne` one share a slope but leave by
|
|
// opposite edges, so swapping one for the other would move the leg off whatever it joined.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
|
|
// A turnout on the main, then its matching curve on the row below — the start of every siding.
|
|
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
|
|
assert.ok(applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
|
|
}).ok, 'the turnout should lay on the Limits sign');
|
|
|
|
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
|
|
assert.ok(applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
|
|
}).ok, 'the matching curve should hang under the turnout');
|
|
|
|
const laid = area.grid.get(`${curveAt.row},${curveAt.col}`)!;
|
|
const arc = laid.geometry.kind === 'track' ? laid.geometry.arc : undefined;
|
|
assert.equal(arc, 'ne', 'the fixture is not the curve this test means to upgrade');
|
|
|
|
// A turnout whose leg is that same `ne` arc may replace it; every other rotation may not.
|
|
const matching: string[] = [];
|
|
for (const hand of ['left', 'right'] as const) {
|
|
for (let variant = 0; variant < 2; variant++) {
|
|
const code = check(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: curveAt, variant,
|
|
});
|
|
if (code === null) matching.push(`${hand}/${variant}`);
|
|
else assert.equal(code, 'NOT_UPGRADEABLE_TRACK', `${hand}/${variant} should be refused as a mismatch`);
|
|
}
|
|
}
|
|
assert.deepEqual(matching, ['right/1'], 'exactly the turnout diverging onto `ne` should be accepted');
|
|
});
|
|
|
|
it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
const at = layStraight(s);
|
|
const square = area.grid.get(`${at.row},${at.col}`)!;
|
|
|
|
square.standing = [{ type: 'boxcar', loaded: false }];
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }),
|
|
'UPGRADE_OCCUPIED',
|
|
'track was swapped out from under a standing car',
|
|
);
|
|
|
|
square.standing = [];
|
|
square.enhancements = ['interlocking'];
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }),
|
|
'UPGRADE_ENHANCED',
|
|
'the upgrade lifted a built Interlocking with the card',
|
|
);
|
|
});
|
|
|
|
it('refuses to upgrade anything that is not a straight or a matching curve', () => {
|
|
// The Office is east-west track like a straight, but it is not track to build over.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
assert.equal(
|
|
check(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'),
|
|
placement: area.officeCoord, variant: 0,
|
|
}),
|
|
'NOT_UPGRADEABLE_TRACK',
|
|
'a turnout was laid over the Office',
|
|
);
|
|
});
|
|
|
|
it('does not let a straight or a curve upgrade anything — only a turnout may', () => {
|
|
const s = game();
|
|
turnOf(s, 0).option = 'draw';
|
|
const at = layStraight(s);
|
|
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: at, variant: 0 }),
|
|
'NOT_UPGRADEABLE_TRACK',
|
|
'a straight was laid on top of a straight',
|
|
);
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: at, variant: 0 }),
|
|
'NOT_UPGRADEABLE_TRACK',
|
|
'a curve was laid on top of a straight',
|
|
);
|
|
});
|
|
|
|
it('offers the upgrade in the legal-move list, or it may as well not exist', () => {
|
|
// The 18 Enhancement cards were permanently dead for weeks for exactly this reason: the engine
|
|
// allowed the play and `legalIntents` never once offered a square it could go on.
|
|
const s = game();
|
|
turnOf(s, 0).option = 'draw';
|
|
const at = layStraight(s);
|
|
const cardId = trackInHand(s, 'turnout', 'right');
|
|
|
|
const offered = legalActions(s, 0).some(
|
|
(i) => i.type === 'card.play' && i.cardId === cardId &&
|
|
i.placement?.row === at.row && i.placement?.col === at.col,
|
|
);
|
|
assert.ok(offered, 'the occupied square is never offered, so the upgrade cannot be played');
|
|
});
|
|
});
|
|
|
|
describe("a train is made up to its card's consist (§8.2)", () => {
|
|
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
|
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
|
// no caboose, because placeCar checked only MAX_CONSIST and yard availability.
|
|
const s = game();
|
|
s.clock.phase = 'newTrain';
|
|
s.trays.set('t', {
|
|
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
|
});
|
|
|
|
for (let n = 0; n < 3; n++) {
|
|
const r = applyIntent(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true });
|
|
assert.ok(r.ok, `hopper ${n + 1} of 3 should be accepted`);
|
|
}
|
|
assert.equal(
|
|
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
|
|
'NO_SUITABLE_CAR',
|
|
'a fourth freight car exceeds the card',
|
|
);
|
|
assert.equal(
|
|
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'coach', loaded: true }),
|
|
'NO_SUITABLE_CAR',
|
|
'this card carries no coaches',
|
|
);
|
|
assert.equal(
|
|
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'caboose', loaded: true }),
|
|
null,
|
|
'the card calls for a caboose',
|
|
);
|
|
});
|
|
|
|
it('couples nothing behind the caboose', () => {
|
|
// §A.3 — the caboose rides last.
|
|
const s = game();
|
|
s.clock.phase = 'newTrain';
|
|
s.trays.set('t', {
|
|
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'caboose', loaded: true }],
|
|
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
|
});
|
|
assert.equal(
|
|
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
|
|
'NO_SUITABLE_CAR',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
|
/** Put one train mid-crossing and ask the view where the map should draw it. */
|
|
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => {
|
|
const s = createGame({
|
|
id: 'reg',
|
|
seed: 4,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['Solitaire'],
|
|
});
|
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
|
assert.ok(node && node.kind === 'mainline');
|
|
const tray = [...s.trays.keys()][0]!;
|
|
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' });
|
|
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
|
assert.ok(ml, 'no Mainline node in the view');
|
|
const t = ml!.trains.flat()[0]!;
|
|
return { region: t.region as number, regions: ml!.regions as number };
|
|
};
|
|
|
|
it('divides every Mainline card into two regions', () => {
|
|
// §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves
|
|
// trains still on the road when the next Day begins, locking Crew Trays permanently.
|
|
assert.equal(regionFor(2, 2).regions, 2);
|
|
});
|
|
|
|
it('moves one region per Stage for an ordinary train on a 30 card', () => {
|
|
// §8.2 exactly. This is the case the printed rule was written for.
|
|
assert.equal(regionFor(2, 2).region, 0);
|
|
assert.equal(regionFor(2, 1).region, 1);
|
|
});
|
|
|
|
it('starts a fast train further along the card rather than moving it twice', () => {
|
|
// The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map
|
|
// shows as entering at the second region. That is the same fact as "Brakemen moves your entry
|
|
// point further along", which is how the modifier cards are drawn.
|
|
assert.equal(regionFor(1, 1).region, 1);
|
|
});
|
|
|
|
it('holds a slow train at the START, not at the exit', () => {
|
|
// Three Stages will not fit three steps into two regions. The card's distance is fixed, so the
|
|
// extra Stage is spent getting going — clamping the entry instead would park it at the exit and
|
|
// read as a train that raced across and then waited.
|
|
assert.equal(regionFor(3, 3).region, 0);
|
|
assert.equal(regionFor(3, 2).region, 0);
|
|
assert.equal(regionFor(3, 1).region, 1);
|
|
});
|
|
|
|
it('never leaves the card it is on', () => {
|
|
for (let total = 1; total <= 4; total++) {
|
|
for (let left = total; left >= 1; left--) {
|
|
const r = regionFor(total, left).region;
|
|
assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
|
/**
|
|
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
|
|
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
|
|
* then region 1 — so it catches up whichever order the phase happens to process them in.
|
|
*/
|
|
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
|
const s = createGame({
|
|
id: 'rear', seed: 3,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['bot'],
|
|
});
|
|
const index = s.division.nodes.findIndex(
|
|
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
|
);
|
|
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
|
|
const node = s.division.nodes[index]!;
|
|
assert.ok(node.kind === 'mainline');
|
|
if (node.kind !== 'mainline') throw new Error('unreachable');
|
|
if (opts.absSignals) node.absSignals = true;
|
|
|
|
const make = (n: number, total: number, left: number): string => {
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'boxcar', loaded: false }],
|
|
direction: 'east',
|
|
position: { at: 'mainline', index },
|
|
} as never);
|
|
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
|
|
return id;
|
|
};
|
|
make(9, 3, 2); // the leader
|
|
make(11, 2, 2); // the follower, closing
|
|
|
|
s.clock.phase = 'mainline';
|
|
const events: GameEvent[] = [];
|
|
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
|
|
return { s, events };
|
|
};
|
|
|
|
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
|
|
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
|
|
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
|
|
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
|
|
// survived, no penalty, and ABS Signals protected against nothing.
|
|
const { events } = twoTrains();
|
|
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
|
assert.ok(smash, 'the follower ran into the leader and nothing happened');
|
|
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
|
|
assert.match(smash.reason, /ran into the train ahead/);
|
|
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
|
|
assert.ok(
|
|
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
|
|
'the collision cost nothing',
|
|
);
|
|
});
|
|
|
|
it('ABS Signals holds the follower short instead', () => {
|
|
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
|
const { events } = twoTrains({ absSignals: true });
|
|
assert.ok(
|
|
!events.some((e) => e.type === 'trainsDestroyed'),
|
|
'ABS Signals did not prevent the collision',
|
|
);
|
|
assert.ok(
|
|
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
|
'nothing was held short of the train ahead',
|
|
);
|
|
});
|
|
|
|
it('leaves trains alone on a card that prints "trains may pass"', () => {
|
|
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
|
|
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
|
|
// times a game while the bot never once granted clearance — the tell that they were all
|
|
// passing cards.
|
|
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
|
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
|
|
});
|
|
});
|