Gitea#22: the Division map drew every westbound train in the wrong half of its Mainline card. `regionOfTransit` counts from the end a train entered, which is what the collision rules ask; the map wanted "which printed box, left to right" and used the same number, so an eastbound train came out right by luck and a westbound one came out mirrored. It cost a collision — Train 3 was cleared to follow T5 and ran into TX17, which the picture had drawn ahead of T5 rather than behind it. One mirror in `view.ts`, at the boundary the map is drawn from; the collision rules are untouched. Gitea#21: a second tank car would not come off at a refinery, and "Blocked — why nothing is moving" answered by describing the refinery's green box. The real answer was Train 3's printed rule — the Express works one freight car per location — so the refusal was correct and the panel sent the player to spend a Freight Agent action that could not have helped. No rule changed. The panel now names the budget, asking the reducer's own predicate so its words cannot drift from the rule. Both were replayed from the saves attached to the issues and verified in the exact position each report names. The map fix is proved by mutation: reverting the mirror fails two tests, and making the renderer ignore the region fails a third. Every existing region test ran eastbound, where the mirror is the identity, which is why the bug survived them. 891 tests pass, up from 884. Closes #21 Closes #22 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
1351 lines
60 KiB
TypeScript
1351 lines
60 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, decisionActor, turnOf } from '../src/engine/state.ts';
|
|
import { snapshot } from '../src/sim/view.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
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 hold a train out of your Limits (Gitea#19)', () => {
|
|
/**
|
|
* REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped
|
|
* train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4
|
|
* plays across 600 games, a mechanic nobody used. ABS Signals already does that job better.
|
|
*
|
|
* Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
|
* from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops —
|
|
* one card, one train.
|
|
*/
|
|
|
|
/** A westbound train one Stage from entering seat 0's district from the east. */
|
|
function approaching(s: GameState) {
|
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
|
const node = pinned(s, officeIndex + 1, 'plains');
|
|
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
|
s.trays.set('inbound', {
|
|
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
|
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return s.division.nodes[officeIndex] as Extract<typeof s.division.nodes[0], { kind: 'office' }>;
|
|
}
|
|
|
|
it('FLAG EAST holds a westbound train short of the Limits', () => {
|
|
const s = game();
|
|
const office = approaching(s);
|
|
office.redFlag = 'east';
|
|
|
|
advance(s);
|
|
const pos = s.trays.get('inbound')!.position;
|
|
assert.equal(pos.at, 'mainline', 'the flagged train came in anyway');
|
|
assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track');
|
|
});
|
|
|
|
it('is spent on the train it stops — one card, one train', () => {
|
|
const s = game();
|
|
const office = approaching(s);
|
|
office.redFlag = 'east';
|
|
|
|
advance(s);
|
|
assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train');
|
|
});
|
|
|
|
it('lets the train in on the next Mainline Phase', () => {
|
|
// "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection.
|
|
const s = game();
|
|
const office = approaching(s);
|
|
office.redFlag = 'east';
|
|
advance(s);
|
|
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
advance(s);
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in');
|
|
});
|
|
|
|
it('does not hold a train coming from the OTHER side', () => {
|
|
// "Flag East holds westbound trains" — an eastbound train arrives from the west.
|
|
const s = game();
|
|
const office = approaching(s);
|
|
office.redFlag = 'west';
|
|
|
|
advance(s);
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east');
|
|
assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent');
|
|
});
|
|
|
|
it('is played on a side, not on a train', () => {
|
|
const s = game();
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
|
|
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null);
|
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null);
|
|
});
|
|
|
|
it('will not double-flag the same side', () => {
|
|
const s = game();
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
|
const office = s.division.nodes[officeIndex] as { redFlag?: string };
|
|
office.redFlag = 'east';
|
|
|
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED');
|
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null,
|
|
'the other side should still be free');
|
|
});
|
|
});
|
|
|
|
describe('Red Flags offered at the moment of danger (Gitea#19)', () => {
|
|
/**
|
|
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
|
* collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way,
|
|
* you can play the card normally or out of phase, but only if you need it."
|
|
*
|
|
* The engine establishes the danger, so the player is never asked to judge it — which is also why
|
|
* the bot can now use this card at all. It is offered ONLY to somebody holding one.
|
|
*/
|
|
function dangerous(s: GameState, giveCard: boolean) {
|
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
|
const node = pinned(s, officeIndex + 1, 'plains');
|
|
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
|
s.trays.set('inbound', {
|
|
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
|
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
|
});
|
|
// A hopper fouling the Running Track: §8.3 makes this arrival a collision.
|
|
const area = areaOf(s, 0);
|
|
area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }];
|
|
if (giveCard) hand(s, 'maneuver', 'redFlags');
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
}
|
|
|
|
it('breaks in to offer the flag when an arrival would collide', () => {
|
|
const s = game();
|
|
dangerous(s, true);
|
|
advance(s);
|
|
assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision');
|
|
assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player');
|
|
});
|
|
|
|
it('flagging holds the train and costs the card', () => {
|
|
const s = game();
|
|
dangerous(s, true);
|
|
advance(s);
|
|
const before = (s.decks.hands.get(0) ?? []).length;
|
|
|
|
const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true });
|
|
assert.ok(r.ok, 'the flag was refused');
|
|
advance(s);
|
|
|
|
assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway');
|
|
assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless');
|
|
assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent');
|
|
});
|
|
|
|
it('declining lets the collision happen', () => {
|
|
const s = game();
|
|
dangerous(s, true);
|
|
advance(s);
|
|
|
|
assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok);
|
|
advance(s);
|
|
assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide');
|
|
});
|
|
|
|
it('does not offer a flag to a player holding none', () => {
|
|
// A prompt with one button is not a choice, and it leaks that a collision is coming.
|
|
const s = game();
|
|
dangerous(s, false);
|
|
advance(s);
|
|
assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card');
|
|
assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened');
|
|
});
|
|
|
|
it('stays quiet when the arrival is safe', () => {
|
|
const s = game();
|
|
dangerous(s, true);
|
|
// Clear the hazard: nothing fouling the Running Track, and room at the Office.
|
|
const area = areaOf(s, 0);
|
|
area.grid.get(coordKey(area.officeCoord))!.standing = [];
|
|
advance(s);
|
|
assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival');
|
|
});
|
|
});
|
|
|
|
describe('Flying Switch rolls a cut into an industry', () => {
|
|
/** A crew on a siding below the main, with a facility next door along the same track. */
|
|
function setup(s: GameState) {
|
|
const area = areaOf(s, 0);
|
|
// A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by
|
|
// rail. Every run is east-west now, and the siding gets down off the main the only way there is:
|
|
//
|
|
// row 0: [office] [turnout, leg south] <- Running Track
|
|
// row -1: [curve ne] [straight, crew] [industry] <- the siding
|
|
//
|
|
const plain = (): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
area.grid.set(coordKey(at(0, 1)), {
|
|
...plain(),
|
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' },
|
|
baseOperationalRail: false,
|
|
});
|
|
area.grid.set(coordKey(at(-1, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left' } });
|
|
area.grid.set(coordKey(at(-1, 2)), plain());
|
|
const industry: TrackCard = {
|
|
...plain(),
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { cars: [] },
|
|
laborers: 1, porters: 0,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
};
|
|
area.grid.set(coordKey(at(-1, 3)), industry);
|
|
|
|
s.trays.set('crew', {
|
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
|
direction: 'east', facing: 'e',
|
|
position: { at: 'grid', seat: 0, coord: at(-1, 2) }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
return industry;
|
|
}
|
|
|
|
it('drops the cut onto the industry track without moving the engine', () => {
|
|
const s = game();
|
|
const industry = setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
|
|
const r = applyIntent(s, 0, {
|
|
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 3),
|
|
});
|
|
assert.ok(r.ok, 'the flying switch should be accepted');
|
|
|
|
assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
|
|
assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
|
|
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
|
|
assert.deepEqual(
|
|
s.trays.get('crew')!.position,
|
|
{ at: 'grid', seat: 0, coord: at(-1, 2) },
|
|
'the engine never enters the industry',
|
|
);
|
|
assert.equal(turnOf(s, 0).movesRemaining, 5, 'the manoeuvre costs a Move');
|
|
});
|
|
|
|
it('only reaches an adjacent card', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('rolls into an industry, not onto plain track', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
// (0,0) is the Office — track-connected to the crew back up through the turnout, but not a
|
|
// freight industry.
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
|
'CANNOT_DROP_HERE',
|
|
);
|
|
});
|
|
|
|
it('will not roll onto a card with no track connection', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const area = areaOf(s, 0);
|
|
// An industry sitting directly beneath the crew. Both cards are plain east-west track, so
|
|
// nothing crosses the edge between them: adjacency is not a rail.
|
|
const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! };
|
|
area.grid.set(coordKey(at(-2, 2)), stranded);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 2) }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('cannot cut more cars than it is hauling', () => {
|
|
const s = game();
|
|
setup(s);
|
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
|
assert.equal(
|
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-1, 3) }),
|
|
'CONSIST_EMPTY',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('card coverage', () => {
|
|
it('every Mainline modifier is either implemented or a known duplicate', () => {
|
|
for (const card of MAINLINE_MODIFIER_CARDS) {
|
|
const known =
|
|
mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
|
|
assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
|
|
}
|
|
});
|
|
|
|
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
|
|
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
|
// source says otherwise, a silent no-op would be worse than a rejection.
|
|
//
|
|
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
|
|
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
|
|
// the card returns the moment its rule is known.
|
|
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
|
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
|
|
assert.match(poling!.effect, /TBD/i);
|
|
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
|
it('refuses to build beyond the Limits sign', () => {
|
|
// §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point.
|
|
// Offering the square PAST it produced, from one straight laid at (0,2):
|
|
// limits · Whistle Post · limits · straight · limits
|
|
// — track on the far side of the sign, and a second sign planted beyond that.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
|
|
const straight = trackInHand(s, 'straight', 'none');
|
|
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
|
|
const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 };
|
|
for (const placement of [beyondEast, beyondWest]) {
|
|
assert.notEqual(
|
|
check(s, 0, { type: 'card.play', cardId: straight, placement, variant: 0 }),
|
|
null,
|
|
`(${placement.row},${placement.col}) is outside the Limits and must not be placeable`,
|
|
);
|
|
}
|
|
|
|
// The sign itself IS legal — that is how the Running Track grows.
|
|
assert.equal(
|
|
check(s, 0, {
|
|
type: 'card.play', cardId: straight,
|
|
placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0,
|
|
}),
|
|
null,
|
|
'laying on the Limits sign must be how the track extends',
|
|
);
|
|
|
|
// A district opens with a TURNOUT on the Running Track — no office or industry card carries a
|
|
// stub of its own — so extend onto the east sign with one.
|
|
const turnoutCol = area.limitsEast.col;
|
|
assert.ok(
|
|
applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'turnout', 'left'),
|
|
placement: { row: area.runningRow, col: turnoutCol }, variant: 0,
|
|
}).ok,
|
|
'a turnout must be layable on the Limits sign',
|
|
);
|
|
|
|
// The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a
|
|
// left-hand curve is the `nw` arc — the one on the same diagonal as the turnout's leg.
|
|
assert.equal(
|
|
check(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'curved', 'left'),
|
|
placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1,
|
|
}),
|
|
null,
|
|
'Secondary Track below the turnout must stay legal',
|
|
);
|
|
});
|
|
|
|
it('keeps the Limits at the ends, never stranded mid-track', () => {
|
|
// Found by watching a replay: a district grew to
|
|
// (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
|
|
// with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and
|
|
// §10 both reason about "the track between the train and the Limits", and running past a
|
|
// player's Limits is what makes a collision his fault.
|
|
const s = game();
|
|
const area = areaOf(s, 0);
|
|
turnOf(s, 0).option = 'draw';
|
|
|
|
for (let n = 0; n < 4; n++) {
|
|
const target = { row: area.runningRow, col: area.limitsWest.col };
|
|
const r = applyIntent(s, 0, {
|
|
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0,
|
|
});
|
|
assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`);
|
|
}
|
|
|
|
const onRunning = [...area.grid.entries()]
|
|
.map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind }))
|
|
.filter((x) => !Number.isNaN(x.col));
|
|
const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b);
|
|
const cols = onRunning.map((x) => x.col);
|
|
|
|
assert.equal(limits.length, 2, 'there must be exactly two Limits signs');
|
|
assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card');
|
|
assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('a turnout may be laid on top of a card already down', () => {
|
|
/** Lays a straight inside the Limits and returns where it went. */
|
|
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,
|
|
direction: 'east' | 'west' = 'east',
|
|
): { region: number; regions: number } => {
|
|
const s = createGame({
|
|
id: 'reg',
|
|
seed: 4,
|
|
config: {
|
|
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['Solitaire'],
|
|
});
|
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
|
assert.ok(node && node.kind === 'mainline');
|
|
const tray = [...s.trays.keys()][0]!;
|
|
node.transits.push({ tray, stagesRemaining, stagesTotal, direction });
|
|
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
|
assert.ok(ml, 'no Mainline node in the view');
|
|
const t = ml!.trains.flat()[0]!;
|
|
return { region: t.region as number, regions: ml!.regions as number };
|
|
};
|
|
|
|
it('divides every Mainline card into two regions', () => {
|
|
// §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves
|
|
// trains still on the road when the next Day begins, locking Crew Trays permanently.
|
|
assert.equal(regionFor(2, 2).regions, 2);
|
|
});
|
|
|
|
it('moves one region per Stage for an ordinary train on a 30 card', () => {
|
|
// §8.2 exactly. This is the case the printed rule was written for.
|
|
assert.equal(regionFor(2, 2).region, 0);
|
|
assert.equal(regionFor(2, 1).region, 1);
|
|
});
|
|
|
|
it('starts a fast train further along the card rather than moving it twice', () => {
|
|
// The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map
|
|
// shows as entering at the second region. That is the same fact as "Brakemen moves your entry
|
|
// point further along", which is how the modifier cards are drawn.
|
|
assert.equal(regionFor(1, 1).region, 1);
|
|
});
|
|
|
|
it('holds a slow train at the START, not at the exit', () => {
|
|
// Three Stages will not fit three steps into two regions. The card's distance is fixed, so the
|
|
// extra Stage is spent getting going — clamping the entry instead would park it at the exit and
|
|
// read as a train that raced across and then waited.
|
|
assert.equal(regionFor(3, 3).region, 0);
|
|
assert.equal(regionFor(3, 2).region, 0);
|
|
assert.equal(regionFor(3, 1).region, 1);
|
|
});
|
|
|
|
it('never leaves the card it is on', () => {
|
|
for (let total = 1; total <= 4; total++) {
|
|
for (let left = total; left >= 1; left--) {
|
|
const r = regionFor(total, left).region;
|
|
assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('never leaves the card it is on, whichever way it runs', () => {
|
|
for (const direction of ['east', 'west'] as const) {
|
|
for (let total = 1; total <= 4; total++) {
|
|
for (let left = total; left >= 1; left--) {
|
|
const r = regionFor(total, left, direction).region;
|
|
assert.ok(r >= 0 && r <= 1, `${direction}, total ${total}, ${left} left put the train in region ${r}`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GITEA#22 — A WESTBOUND TRAIN WAS DRAWN IN THE WRONG HALF OF THE CARD.
|
|
*
|
|
* `regionOfTransit` answers "how far along its crossing is this train", counted from the end it
|
|
* ENTERED: a train with everything still to run is in region 0. That is the right question for the
|
|
* collision rules, which is what the engine asks it, and both directions share the one index space.
|
|
*
|
|
* The map asks a different question — WHICH PRINTED BOX, left to right — and used the same number
|
|
* for it. East is right on this map and always has been, so for an eastbound train the two agree by
|
|
* luck: it enters at the west end, so "just entered" and "leftmost box" are the same box. A
|
|
* westbound train enters at the EAST end, so its region 0 is the card's RIGHT-hand box, and drawing
|
|
* it at index 0 put it at the left — the whole card mirrored.
|
|
*
|
|
* Reported from seed 550943578, undo 187, and it cost a collision. Three westbound trains: TX17 had
|
|
* just entered (2 Stages still to run, so travel index 0) and T5 was nearly across (1 Stage left,
|
|
* index 1). Physically TX17 was BEHIND T5 — further east, the direction they had both come from.
|
|
* The map drew TX17 at the left and so put it further WEST, which reads as further ahead. Asked
|
|
* whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3
|
|
* entered behind — into TX17, exactly where the rules had it and nowhere near where the map did.
|
|
*
|
|
* The engine was right throughout. Only the picture lied, so the fix is one mirror in the view and
|
|
* the collision rules are untouched. This is the same class of bug as the consist row at the
|
|
* Whistle Post (`board-svg.ts`, seed 270861860), which came out mirrored for the same reason.
|
|
*/
|
|
describe('Gitea#22 — the map draws a westbound train where it actually is', () => {
|
|
it('mirrors a westbound train, because it entered from the east end', () => {
|
|
// Two-region card. Just entered, 2 Stages still to run: an eastbound train is in the WEST box
|
|
// and a westbound one is in the EAST box, because they came in at opposite ends.
|
|
assert.equal(regionFor(2, 2, 'east').region, 0);
|
|
assert.equal(regionFor(2, 2, 'west').region, 1);
|
|
|
|
// One Stage left, nearly across: the two swap.
|
|
assert.equal(regionFor(2, 1, 'east').region, 1);
|
|
assert.equal(regionFor(2, 1, 'west').region, 0);
|
|
});
|
|
|
|
it('puts the follower behind the leader, not in front of it — the seed 550943578 collision', () => {
|
|
// TX17 had just entered; T5 was a Stage from the far end. Both westbound, so BEHIND means to
|
|
// the east, which is to the right, which is the higher index.
|
|
const tx17 = regionFor(2, 2, 'west').region;
|
|
const t5 = regionFor(2, 1, 'west').region;
|
|
assert.ok(
|
|
tx17 > t5,
|
|
`a westbound train that has just entered must be drawn east of one that is nearly across, ` +
|
|
`but TX17 was drawn at ${tx17} and T5 at ${t5}`,
|
|
);
|
|
});
|
|
|
|
it('leaves an eastbound train where it has always been drawn', () => {
|
|
// The mirror must not disturb the direction that was right, which is every existing region test
|
|
// above — those all run east — and the case the printed rule was written for.
|
|
assert.equal(regionFor(2, 2, 'east').region, 0);
|
|
assert.equal(regionFor(2, 1, 'east').region, 1);
|
|
assert.equal(regionFor(1, 1, 'east').region, 1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
|
/**
|
|
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
|
|
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
|
|
* then region 1 — so it catches up whichever order the phase happens to process them in.
|
|
*/
|
|
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
|
/**
|
|
* THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN.
|
|
*
|
|
* This asked for seed 3 and asserted that its Division held a single-track Mainline card. It
|
|
* does not any more: the Division is laid out from the same RNG stream the card deck is
|
|
* shuffled from, so changing the SIZE of that deck re-deals the Division too. Gitea#14's deck
|
|
* counts moved it, and the test failed on its own precondition — "no single-track Mainline card
|
|
* in this Division" — which says nothing about the rule under test.
|
|
*
|
|
* The fixture needs A Division with a card trains may not pass on, not one particular one, so
|
|
* it now takes the first seed that provides one. That is stable across any future retune, and
|
|
* it fails loudly if such a Division stops being reachable at all.
|
|
*/
|
|
let s!: GameState;
|
|
let index = -1;
|
|
for (let seed = 3; seed < 200 && index < 0; seed++) {
|
|
s = createGame({
|
|
id: 'rear', seed,
|
|
config: {
|
|
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['bot'],
|
|
});
|
|
index = s.division.nodes.findIndex(
|
|
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
|
);
|
|
}
|
|
assert.ok(index >= 0, 'no seed under 200 deals a Division holding a single-track Mainline card');
|
|
const node = s.division.nodes[index]!;
|
|
assert.ok(node.kind === 'mainline');
|
|
if (node.kind !== 'mainline') throw new Error('unreachable');
|
|
if (opts.absSignals) node.absSignals = true;
|
|
|
|
const make = (n: number, total: number, left: number): string => {
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'boxcar', loaded: false }],
|
|
direction: 'east',
|
|
position: { at: 'mainline', index },
|
|
} as never);
|
|
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
|
|
return id;
|
|
};
|
|
make(9, 3, 2); // the leader
|
|
make(11, 2, 2); // the follower, closing
|
|
|
|
s.clock.phase = 'mainline';
|
|
const events: GameEvent[] = [];
|
|
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
|
|
return { s, events };
|
|
};
|
|
|
|
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
|
|
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
|
|
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
|
|
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
|
|
// survived, no penalty, and ABS Signals protected against nothing.
|
|
const { events } = twoTrains();
|
|
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
|
assert.ok(smash, 'the follower ran into the leader and nothing happened');
|
|
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
|
|
assert.match(smash.reason, /ran into the train ahead/);
|
|
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
|
|
assert.ok(
|
|
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
|
|
'the collision cost nothing',
|
|
);
|
|
});
|
|
|
|
it('ABS Signals holds the follower short instead', () => {
|
|
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
|
const { events } = twoTrains({ absSignals: true });
|
|
assert.ok(
|
|
!events.some((e) => e.type === 'trainsDestroyed'),
|
|
'ABS Signals did not prevent the collision',
|
|
);
|
|
assert.ok(
|
|
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
|
'nothing was held short of the train ahead',
|
|
);
|
|
});
|
|
|
|
/**
|
|
* ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3).
|
|
*
|
|
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
|
|
* granted clearance is in the same place as the train ahead the moment it arrives. Nothing tested
|
|
* that: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage crossing never
|
|
* reaches, so entering behind another train on a Plains was silently free.
|
|
*/
|
|
const enteringBehind = (card: MainlineKind, opts: { absSignals?: boolean } = {}) => {
|
|
const s = game();
|
|
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
|
const node = s.division.nodes[index]!;
|
|
assert.ok(node.kind === 'mainline');
|
|
if (node.kind !== 'mainline') throw new Error('unreachable');
|
|
node.card = card;
|
|
node.transits = [];
|
|
if (opts.absSignals) node.absSignals = true;
|
|
|
|
/**
|
|
* THE TRAIN ALREADY THERE IS THE JUNIOR ONE, and that is what makes the situation reachable.
|
|
*
|
|
* Trains move lowest number first, so a card's occupant normally clears before anything behind
|
|
* it is even considered — put train 9 on the card and train 11 at the Division Point and 9 has
|
|
* gone by the time 11 enters. The conflict is a SUPERIOR train catching an inferior one that has
|
|
* not got out of the way yet, so the numbers run the other way round here.
|
|
*/
|
|
const leader = s.freeTrays.pop()!;
|
|
s.trays.set(leader, {
|
|
id: leader, trainNumber: 11, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
|
position: { at: 'mainline', index },
|
|
} as never);
|
|
const total = crossingStages(card, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [] });
|
|
node.transits.push({ tray: leader, stagesRemaining: total, stagesTotal: total, direction: 'east' });
|
|
|
|
// And the one arriving, held at the Division Point west of it.
|
|
const dp = s.division.nodes[index - 1];
|
|
assert.ok(dp && dp.kind === 'divisionPoint', 'expected a Division Point west of the first card');
|
|
if (!dp || dp.kind !== 'divisionPoint') throw new Error('unreachable');
|
|
const follower = s.freeTrays.pop()!;
|
|
s.trays.set(follower, {
|
|
id: follower, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
|
position: { at: 'divisionPoint', side: dp.side },
|
|
} as never);
|
|
dp.holding.push(follower);
|
|
|
|
/**
|
|
* THE SUPERINTENDENT LETS IT IN, which is the whole point.
|
|
*
|
|
* A same-direction train in the Subdivision is not an absolute bar — §8.1 makes it a judgment
|
|
* call, and `advance` stops and asks. Granting it is what puts one train in behind another, and
|
|
* §10 is then explicit that the wreck is the Superintendent's fault. So the fixture answers
|
|
* `allow: true` whenever it is asked, and the collision below is the consequence of that
|
|
* ruling rather than of a rule firing on its own.
|
|
*/
|
|
s.clock.phase = 'mainline';
|
|
const events: GameEvent[] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
events.push(...advance(s).events);
|
|
if (s.clock.pendingDecision !== null) {
|
|
const who = s.clock.superintendent;
|
|
const r = applyIntent(s, who, { type: 'mainline.clearance', allow: true });
|
|
assert.ok(r.ok, `clearance refused: ${r.ok ? '' : r.code}`);
|
|
events.push(...r.events);
|
|
}
|
|
}
|
|
return { s, node, events, follower };
|
|
};
|
|
|
|
it('runs a train into the one ahead when it ENTERS an occupied region', () => {
|
|
const { events } = enteringBehind('plains');
|
|
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
|
assert.ok(smash, 'a train entered a one-region card behind another and nothing happened');
|
|
});
|
|
|
|
it('holds it short instead when the card carries ABS Signals', () => {
|
|
// RAR: "ABS. This is played on a mainline card to prevent collisions. If a collision would
|
|
// normally occur, the train moving onto the card is instead held back."
|
|
const { events } = enteringBehind('plains', { absSignals: true });
|
|
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent it');
|
|
assert.ok(
|
|
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
|
'nothing was held short of the train ahead',
|
|
);
|
|
});
|
|
|
|
it('takes the siding instead of colliding on an Uncontrolled Siding', () => {
|
|
// "If a train already exists when you arrive, you go in the second stage back — you are in the
|
|
// siding and are one behind the other train. This prevents a collision, since you are not in
|
|
// same exact location." So: no wreck, both trains on the card, and the newcomer paying the
|
|
// extra Stage for the detour.
|
|
const { node, events, follower } = enteringBehind('uncontrolledSiding');
|
|
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'the siding did not prevent a collision');
|
|
const mine = node.transits.find((t) => t.tray === follower);
|
|
assert.ok(mine, 'the arriving train never made it onto the card');
|
|
assert.equal(mine.stagesTotal, 2, 'it should have entered at the back of the card, not the front');
|
|
});
|
|
|
|
it('leaves trains alone on the one card that prints "trains may pass"', () => {
|
|
// Double Track holds two trains because it HAS two roads. Catching up there means going past,
|
|
// which is what the card is for.
|
|
//
|
|
// THE UNCONTROLLED SIDING USED TO BE IN THIS LIST AND IS NOT ANY MORE (Gitea#3). It holds two
|
|
// trains as well, but not by letting them share a place: the second one takes the siding and
|
|
// sits a region behind, which is what keeps them apart — "you are in the siding and are one
|
|
// behind the other train. This prevents a collision, since you are not in same exact location."
|
|
// Marked "may pass" it skipped the collision test entirely, so the siding did nothing at all and
|
|
// two trains could occupy the same region of it unchallenged.
|
|
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
|
assert.deepEqual(passing, ['doubleTrack']);
|
|
});
|
|
});
|