The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a multiplayer redesign and does not belong in a build people are mid-playtest on. Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut through a curve's south leg no longer couples it back to front, and a crew pulling out through a leg takes its own cut with it. Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12 office doubling and the Gap 12 industry tripling. Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no train crosses the gap. Gitea#3 — the printed speeds are scenery. One Stage per printed region, and where a train starts is what varies.
1082 lines
47 KiB
TypeScript
1082 lines
47 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',
|
|
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. */
|
|
/**
|
|
* 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("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[] } => {
|
|
/**
|
|
* 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', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: 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']);
|
|
});
|
|
});
|