378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
/**
|
|
* Mainline modifiers and Maneuver cards (implications.md §7).
|
|
*
|
|
* The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes,
|
|
* Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender,
|
|
* which happens in solitaire too.
|
|
*
|
|
* Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so
|
|
* there is nothing to implement and nothing to test.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { advance } from '../src/engine/advance.ts';
|
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
|
import {
|
|
MAINLINE_MODIFIER_CARDS,
|
|
MANEUVER_CARDS,
|
|
REALIGNMENTS,
|
|
crossingStages,
|
|
mainlineModifierRule,
|
|
} from '../src/engine/content.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
|
import { coordKey } from '../src/engine/state.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
victory: 'highestAfterDays',
|
|
length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
};
|
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
|
|
|
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
|
function hand(s: GameState, kind: string, key: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
const k = card.kind as { kind: string; key?: string };
|
|
if (k.kind === kind && k.key === key) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
throw new Error(`no ${kind} card: ${key}`);
|
|
}
|
|
|
|
/** 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;
|
|
s.turn.option = 'draw';
|
|
s.turn.drawnThisTurn = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('grade modifiers change crossing time', () => {
|
|
it('a Heavy Grade takes two Stages bare', () => {
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
|
});
|
|
|
|
it('Brakeman speeds the descent but not the climb', () => {
|
|
// ASSUMPTION (§10 Q11): grades climb eastward, so westbound is downhill.
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east'), 2);
|
|
});
|
|
|
|
it('Helpers speed the climb but not the descent', () => {
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east'), 1);
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west'), 2);
|
|
});
|
|
|
|
it('Airbrakes stack with Brakeman on a slow train', () => {
|
|
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
|
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west'), 3);
|
|
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west'), 2);
|
|
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
|
});
|
|
|
|
it('never lets a train cross in no time', () => {
|
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], '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(crossingStages('plains', 'fast', false, ['brakeman'], 'west'), 1);
|
|
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east'), 2);
|
|
});
|
|
});
|
|
|
|
describe('placing a Mainline modifier', () => {
|
|
it('accepts Brakeman on a grade', () => {
|
|
const s = game();
|
|
pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
|
});
|
|
|
|
it('rejects Brakeman on anything but a grade', () => {
|
|
const s = game();
|
|
pinned(s, 1, 'plains');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
|
|
});
|
|
|
|
it('requires Brakeman before Airbrakes', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'airbrakes');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');
|
|
|
|
node.modifiers = ['brakeman'];
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
|
});
|
|
|
|
it('will not modify a card with a train on it', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
|
});
|
|
|
|
it('rejects a Mainline node that is not a Mainline card', () => {
|
|
const s = game();
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
// Node 0 is the western Division Point.
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
|
|
});
|
|
|
|
it('lays the modifier and spends the card', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'heavyGrade');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
|
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
|
|
|
assert.ok(r.ok, 'placement should be accepted');
|
|
assert.deepEqual(node.modifiers, ['brakeman']);
|
|
assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
|
|
assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
|
|
});
|
|
});
|
|
|
|
describe('Realignment converts one Mainline type to another', () => {
|
|
it('converts according to the table', () => {
|
|
const s = game();
|
|
const node = pinned(s, 1, 'curves');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
|
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
|
|
|
assert.ok(r.ok);
|
|
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
|
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
|
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
|
});
|
|
|
|
it('refuses a card with no conversion listed', () => {
|
|
const s = game();
|
|
// Tunnel is not a `from` in REALIGNMENTS.
|
|
assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
|
|
pinned(s, 1, 'tunnel');
|
|
drawTurn(s);
|
|
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
|
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
|
|
});
|
|
|
|
it('applies to any Mainline card, not only grades', () => {
|
|
assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
|
|
});
|
|
});
|
|
|
|
describe('Red Flags protect a stopped train', () => {
|
|
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
|
function rearEnder(s: GameState) {
|
|
const node = pinned(s, 1, 'plains');
|
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.trays.set('behind', {
|
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
|
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 straight with a facility next door, mid-switch. */
|
|
function setup(s: GameState) {
|
|
const area = areaOf(s, 0);
|
|
const spur: TrackCard = {
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
area.grid.set(coordKey(at(-1, 0)), spur);
|
|
const industry: TrackCard = {
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { length: 2, cars: [] },
|
|
laborers: 1, porters: 0,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
area.grid.set(coordKey(at(-2, 0)), industry);
|
|
|
|
s.trays.set('crew', {
|
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
|
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
|
direction: 'east', position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
s.turn.option = 'switch';
|
|
s.turn.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(-2, 0),
|
|
});
|
|
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', owner: 0, coord: at(-1, 0) },
|
|
'the engine never enters the industry',
|
|
);
|
|
assert.equal(s.turn.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, adjacent to the crew 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('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(-2, 0) }),
|
|
'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 remains unimplemented, 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.
|
|
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
|
assert.ok(poling, 'Poling should still be in the deck');
|
|
assert.match(poling!.effect, /TBD/i);
|
|
});
|
|
});
|