Files
station-master/test/apply.test.ts
T
JesseandClaude Opus 5 5d825b97d2 v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
2026-08-08 05:23:17 -04:00

1084 lines
45 KiB
TypeScript

/**
* Build order step 3 — "Individual actions apply correctly; illegal ones rejected."
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, 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 = 77): GameState =>
createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
/** Puts a tray on the player's grid so switching intents have something to act on. */
function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id,
trainNumber: null,
trainIsExtra: false,
engineAt: 0,
consist,
direction: 'east',
position: { at: 'grid', owner: 0, coord },
movesUsed: 0,
});
return id;
}
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
areaOf(s, 0).grid.set(coordKey(coord), card);
}
const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
facility: null,
modifiers: [],
enhancements: [],
});
// ---------------------------------------------------------------------------
describe('turn and phase gating', () => {
it('rejects an intent from a player who is not the actor', () => {
const s = game();
s.clock.currentActor = 1;
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
});
it('rejects an intent belonging to another phase', () => {
const s = game();
s.clock.phase = 'loadUnload';
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
});
it('rejects everything once the game is finished', () => {
const s = game();
s.status = 'finished';
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: the three-way exclusive choice (§6)', () => {
it('accepts a first choice and records it', () => {
const s = game();
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.ok(r.ok);
assert.equal(s.turn.option, 'draw');
});
it('forecloses the other two options for the Stage', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(
check(s, 0, { type: 'localOps.choose', option: 'switch' }),
'OPTION_ALREADY_CHOSEN',
);
});
it('refuses sub-intents of an option that was not chosen', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: drawing (§6.2)', () => {
it('draws from the Home Office deck into the hand', () => {
const s = game();
const before = s.decks.homeOffice.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.equal(s.decks.homeOffice.length, before - 1);
assert.equal(s.decks.hands.get(0)!.length, 4);
});
it('takes the TOP card from a Department pile, never one buried under it', () => {
const s = game();
const buried = s.decks.departments[1]![0]!;
// Bury it: a card discarded onto this Department goes on top and puts the other out of reach.
const spare = s.decks.hands.get(0)![0]!;
s.decks.departments[1]!.push(spare);
s.decks.hands.set(0, s.decks.hands.get(0)!.filter((c) => c !== spare));
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(spare), 'the top card should be the one taken');
assert.ok(!s.decks.hands.get(0)!.includes(buried), 'a buried card must not be reachable');
assert.deepEqual(s.decks.departments[1], [buried], 'the pile should be one shorter, not refilled');
});
it('refills a Department only when taking its last card empties it', () => {
const s = game();
const target = s.decks.departments[1]![0]!;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(target));
// §6.2 — an emptied Department is refilled from the Home Office deck immediately, so the
// face-up market never disappears.
assert.equal(s.decks.departments[1]!.length, 1, 'an emptied Department was not refilled');
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
});
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
// deck and Department slots."
//
// This had a `deckReshuffled` event declared in `events.ts` and a line of narration written for
// it, and was never emitted or reduced anywhere. The game looked like it had a reshuffle.
const s = game();
const all = [...s.decks.homeOffice];
s.decks.salvageYard = all.slice(0, 40);
s.decks.homeOffice = all.slice(40, 41);
const loose = (): number =>
s.decks.homeOffice.length +
s.decks.departments.reduce((n, p) => n + p.length, 0) +
s.decks.salvageYard.length +
[...s.decks.hands.values()].reduce((n, h) => n + h.length, 0);
const before = loose();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
assert.ok(
s.decks.departments.every((p) => p.length === 1),
'the Departments must be turned face up again, one card each',
);
assert.equal(loose(), before, 'the reshuffle created or destroyed cards');
const ids = [
...s.decks.homeOffice,
...s.decks.departments.flat(),
...s.decks.salvageYard,
...[...s.decks.hands.values()].flat(),
];
assert.equal(new Set(ids).size, ids.length, 'a card ended up in two places');
});
it('does not reshuffle while the deck still has cards', () => {
const s = game();
s.decks.salvageYard = [...s.decks.homeOffice].slice(0, 10);
s.decks.homeOffice = [...s.decks.homeOffice].slice(10);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(!r.events.some((e) => e.type === 'deckReshuffled'), 'reshuffled with cards still in the deck');
});
it('leaves a genuinely exhausted game exhausted', () => {
// Cards played onto the board are on the table, not in the Salvage Yard, so a game CAN run out
// for real. Reshuffling an empty sweep would loop forever pretending otherwise.
const s = game();
s.decks.homeOffice = [];
s.decks.salvageYard = [];
for (const pile of s.decks.departments) pile.length = 0;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'DECK_EMPTY');
});
it('replays the reshuffle identically from the same seed', () => {
// The shuffled order rides the event, and `rngState` with it, so a save — a seed plus intents —
// reconstructs the same deck. A reshuffle that re-rolled would fork every replay after it.
const build = () => {
const s = game();
const all = [...s.decks.homeOffice];
s.decks.salvageYard = all.slice(0, 40);
s.decks.homeOffice = all.slice(40, 41);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
return s;
};
assert.deepEqual(build().decks.homeOffice, build().decks.homeOffice);
assert.deepEqual(build().decks.departments, build().decks.departments);
});
it('allows only one draw per Stage', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN');
});
it('will not end the phase over the hand limit', () => {
// §6.2 — "must reduce his hand to no more than three cards".
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1);
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
});
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
// the old single-slot model did — destroyed the buried card outright and threw the choice away.
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
const under = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
const spare = s.decks.hands.get(0)![0]!;
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
assert.ok(r.ok);
assert.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost');
assert.equal(check(s, 0, { type: 'draw.end' }), null);
});
it('lets the discarding player pick which Department to use', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
const spare = s.decks.hands.get(0)![0]!;
for (const slot of [0, 1, 2]) {
assert.equal(check(s, 0, { type: 'card.discard', cardId: spare, toSlot: slot }), null, `Department ${slot}`);
}
const depths = s.decks.departments.map((p) => p.length);
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 2 }).ok);
assert.deepEqual(
s.decks.departments.map((p) => p.length),
[depths[0]!, depths[1]!, depths[2]! + 1],
'only the chosen Department should grow',
);
});
it('refuses to play a card that is not in hand', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
'CARD_NOT_IN_HAND',
);
});
});
// ---------------------------------------------------------------------------
describe('Office upgrades (Gap 3b, Gap 8)', () => {
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'office' && card.kind.tier === tier) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error('no such office card');
}
it('upgrades a Whistle Post to a Depot', () => {
const s = game();
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok);
assert.equal(areaOf(s, 0).tier, 'depot');
});
it('refuses to skip a tier', () => {
const s = game();
const id = handCardOfTier(s, 'station');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
});
it('preserves every grid connection through an upgrade', () => {
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
// Secondary Track hanging off the Office.
const s = game();
addCard(s, at(-1, 0), straight());
const gridBefore = new Map(areaOf(s, 0).grid);
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'card.play', cardId: id });
const gridAfter = areaOf(s, 0).grid;
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
});
it('grants the Depot its Porters and slots on upgrade', () => {
const s = game();
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'card.play', cardId: id });
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
assert.equal(office.facility!.porters, 1);
// The design gives slots equal to porters, not one more.
assert.equal(office.facility!.capacity.inbound, 1);
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: switching (§6.1, Appendix A)', () => {
it('moves a tray and spends a Move', () => {
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok);
assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
});
it('couples standing cars automatically and mandatorily', () => {
// §A.4 — "you MUST pick them up".
const s = game();
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
assert.equal(s.trays.get(tray)!.consist.length, 1);
});
it('refuses a move to an unreachable card', () => {
const s = game();
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
'ILLEGAL_MOVE',
);
});
it('runs out of Moves after six', () => {
const s = game();
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
s.turn.movesRemaining = 0;
assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
'NO_MOVES_REMAINING',
);
});
it('drops cars from the seated end, in order', () => {
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 2), [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
assert.ok(r.ok);
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
assert.equal(left.length, 1);
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
assert.equal(s.trays.get(tray)!.consist.length, 1);
});
it('refuses to drop cars at the Office', () => {
// §A.4 — a passenger platform is no place to switch Rolling Stock.
const s = game();
const office = areaOf(s, 0).officeCoord;
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
'CANNOT_DROP_HERE',
);
});
});
// ---------------------------------------------------------------------------
describe('Freight Agent operations (§6.3)', () => {
function withFacility(s: GameState): GridCoord {
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 4, cars: [] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
return coord;
}
it('stocks the green Outbound box from the Division Yard', () => {
const s = game();
const coord = withFacility(s);
const before = s.yards.divisionYard.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
assert.ok(r.ok);
assert.equal(s.yards.divisionYard.length, before - 1);
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
});
it('allows only one Freight Agent operation per Stage', () => {
// This is the bottleneck the whole economy rests on (card-reference.md §7).
const s = game();
const coord = withFacility(s);
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'OPTION_ALREADY_CHOSEN',
);
});
it('refuses to overfill the Outbound box', () => {
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.outboundBox = [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
];
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'BOX_FULL',
);
});
it('refuses to load a facility that only unloads', () => {
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.allows = { outbound: false, inbound: true };
// Give it something to clear, so the Freight Agent option itself remains available.
f.inboundBox = [{ type: 'hopper', loaded: true }];
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
assert.ok(chose.ok);
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'NO_SUCH_FACILITY',
);
});
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
// A player with no Facility cannot choose an option that has no possible follow-up.
const s = game();
assert.equal(
check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
'NO_SUCH_FACILITY',
);
});
it('makes the switch option unavailable with no train to switch', () => {
const s = game();
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
});
});
// ---------------------------------------------------------------------------
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
function facilityWithLoad(s: GameState): GridCoord {
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
return coord;
}
it('advances a load one box per Laborer action', () => {
const s = game();
const coord = facilityWithLoad(s);
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
assert.ok(r.ok);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.equal(f.menAtWork[0], null);
assert.notEqual(f.menAtWork[1], null);
assert.equal(f.usedThisStage.laborers, 1);
});
it('spends each Laborer only once per Stage', () => {
// §9.1 — Laborers and Porters may be used once each in a Stage.
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.usedThisStage.laborers = f.laborers;
assert.equal(
check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
'RESOURCE_SPENT',
);
});
it('scores exactly one Revenue when a load comes off WORK', () => {
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
assert.equal(s.players[0]!.revenue, 0);
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
assert.ok(r.ok);
assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
});
it('will not complete a load with no empty car spotted', () => {
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
f.industryTrack.cars = [];
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
});
it('takes four Laborer actions in total for one point', () => {
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.laborers = 99; // isolate the pipeline from the per-Stage cap
let actions = 0;
for (let box = 0; box < 3; box++) {
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
assert.ok(r.ok, `advance from box ${box}`);
actions++;
}
assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
});
});
// ---------------------------------------------------------------------------
describe('the Superintendent clearance ruling (§8.1)', () => {
it('is refused when no decision is pending', () => {
const s = game();
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
});
it('is accepted out of turn order, because it interrupts an automatic phase', () => {
const s = game();
s.clock.phase = 'mainline';
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
assert.ok(r.ok);
assert.equal(s.clock.pendingDecision, null);
});
it('is refused to a player who is not the Superintendent', () => {
const s = game();
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
s.clock.superintendent = 1;
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
});
});
// ---------------------------------------------------------------------------
describe('legalActions shares its rules with apply (component 6)', () => {
it('offers only intents that apply would accept', () => {
// The whole discipline of legal.ts in one assertion.
const s = game();
for (const i of legalActions(s, 0)) {
assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`);
}
});
const chooseOptions = (s: GameState) =>
legalActions(s, 0)
.filter((i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose')
.map((i) => i.option)
.sort();
it('offers only "draw" on the opening Stage', () => {
// §6 — the other two options have no possible follow-up yet: a player opens with no Facility
// and no train. This is why the very first Stages are forced development.
assert.deepEqual(chooseOptions(game()), ['draw']);
});
it('offers all three once the player has a facility and a train', () => {
const s = game();
addCard(s, at(0, 2), straight());
placeTray(s, at(0, 2));
addCard(s, at(1, 0), {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 4, cars: [] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
});
it('narrows to one option once a choice is made', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const kinds = new Set(legalActions(s, 0).map((i) => i.type));
assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
});
it('offers reachable destinations for a placed tray', () => {
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
for (const m of moves) assert.equal(check(s, 0, m), null);
assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
});
it('offers nothing illegal after the game ends', () => {
const s = game();
s.status = 'finished';
assert.equal(legalActions(s, 0).length, 0);
});
});
describe('an industry handles every commodity its profile names (regression)', () => {
it('derives car types from the sheet rather than a hand-written map', () => {
// REGRESSION, and the reason tank cars were dropped ZERO times in 100 games.
//
// Two maps of industry -> car type existed. The engine's returned `carTypes[0]`, so the second
// commodity of a two-commodity industry was unreachable — a Power Plant burns coal OR oil and
// a Grocer's Warehouse receives dry goods OR perishables. The bot kept its own switch beside
// it, which had drifted further: it named `produceShed` and `oilRefinery`, neither of which is
// an industry, and omitted `freightHouse`, `refinery` and `packingSheds`, which are. Half the
// industries in the deck therefore never asked for a car at all.
//
// Asserting equality with the profile is the point: it fails if anyone writes a third map.
for (const p of INDUSTRY_PROFILES) {
const f = {
kind: 'freight',
subtype: p.kind,
allows: { outbound: p.baseOut > 0, inbound: p.baseIn > 0 },
outboundBox: [], inboundBox: [],
capacity: { outbound: p.baseOut, inbound: p.baseIn },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: p.baseLoaders, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
} as never;
assert.deepEqual(
[...facilityCarTypes(f)],
[...p.carTypes],
`${p.kind} handles ${p.carTypes.join('/')} on the sheet`,
);
}
});
it('names both commodities of the two-commodity industries', () => {
// Guards the specific collapse: `carTypes[0]` alone passes the test above only if someone also
// rewrites the profiles, but silently fails this one.
const twoCommodity = INDUSTRY_PROFILES.filter((p) => p.carTypes.length > 1).map((p) => p.kind);
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
});
});
describe('a Modifier only goes beside a host that can use it (regression)', () => {
it('offers a Waiting Area no square that is not beside a Passenger Facility', () => {
// REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a
// Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement
// check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area
// was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to
// whichever facility the scan happened to reach first. The player was shown three legal spots
// for a card that has one.
const s = game();
const area = areaOf(s, 0);
// A freight industry away from the Office, and nothing else nearby.
area.grid.set(coordKey({ row: -1, col: 4 }), {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
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 },
},
} as never);
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
);
assert.ok(waiting, 'no Waiting Area card in the deck');
const [cardId] = waiting!;
s.decks.hands.set(0, [cardId]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const spots = legalActions(s, 0).filter(
(i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined,
);
assert.ok(spots.length > 0, 'a Waiting Area has nowhere legal at all — the Office is a host');
// Every offered square must touch the Office and none may touch only the Mine Tipple.
for (const i of spots) {
const p = (i as { placement: { row: number; col: number } }).placement;
const touchesOffice =
Math.abs(p.row - area.officeCoord.row) <= 1 && Math.abs(p.col - area.officeCoord.col) <= 1;
assert.ok(
touchesOffice,
`a Waiting Area was offered (${p.row}, ${p.col}), which is not beside a Passenger Facility`,
);
}
});
});
// ---------------------------------------------------------------------------
describe('Industry cards go on a stub, and lock each other out', () => {
/**
* A district with a siding hanging off the Running Track, which is the only place an industry may
* go. Returns the siding square east of the curve.
*
* row 0: [lim] [office] [turnout, leg south] … <- Running Track
* row -1: [curve ne] [siding square]
*/
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
}));
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
return at(area.runningRow - 1, col + 1);
}
/** Puts an industry card of `kind` in hand and returns its id. */
function industryInHand(s: GameState, kind: string): string {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'freightFacility' && card.kind.facility === kind) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no industry card: ${kind}`);
}
/** Puts an industry straight onto the board, bypassing the hand. */
function build(s: GameState, kind: string, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind as never },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
}
it('refuses an industry on the Running Track', () => {
// The sheet's "Placed" column, identical for all six: "Straight, Stub (not on Running Track)".
// An industry has to hang off a siding — which is what makes building one worth the cards.
const s = game();
withSiding(s);
const area = areaOf(s, 0);
const cardId = industryInHand(s, 'mineTipple');
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, area.limitsEast.col) }),
'ON_RUNNING_TRACK',
);
});
it('accepts the same industry on a stub off the Running Track', () => {
const s = game();
const siding = withSiding(s);
const cardId = industryInHand(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
});
it('refuses a second industry of the same kind', () => {
// The sheet states this in the Freight House row, which lists Freight House among its own
// lockouts. It is a general rule and applies to every kind.
const s = game();
const siding = withSiding(s);
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), 'FACILITY_LOCKED');
});
it('refuses a producer once its consumer is built, and the reverse', () => {
// Every locked pair is a producer and the consumer of the same commodity: Mine Tipple makes the
// coal a Power Plant burns, the Refinery makes the oil it also burns, Packing Sheds fill the
// reefers a Grocer's Warehouse empties. One end of a chain or the other, never both.
const pairs: [string, string][] = [
['mineTipple', 'powerPlant'],
['refinery', 'powerPlant'],
['packingSheds', 'grocersWarehouse'],
['freightHouse', 'grocersWarehouse'],
];
for (const [a, b] of pairs) {
for (const [built, wanted] of [[a, b], [b, a]] as [string, string][]) {
const s = game();
const siding = withSiding(s);
build(s, built, at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, wanted);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: siding }),
'FACILITY_LOCKED',
`${wanted} should be locked out by ${built}`,
);
}
}
});
it('allows industries that share no commodity', () => {
// Mine Tipple ships coal and Packing Sheds ship produce; neither consumes what the other makes,
// so both may stand in one district. The lockouts must not be a blanket ban.
const s = game();
const siding = withSiding(s);
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, 'packingSheds');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
});
it('matches the lockouts on the sheet exactly', () => {
// Transcribed from the "Lockouts" column, so a change to the catalogue has to be deliberate.
const expected: Record<string, string[]> = {
freightHouse: ['grocersWarehouse'],
mineTipple: ['powerPlant'],
refinery: ['powerPlant'],
powerPlant: ['mineTipple', 'refinery'],
packingSheds: ['grocersWarehouse'],
grocersWarehouse: ['packingSheds', 'freightHouse'],
};
for (const p of INDUSTRY_PROFILES) {
assert.deepEqual([...p.lockouts].sort(), [...expected[p.kind]!].sort(), p.kind);
}
});
});
// ---------------------------------------------------------------------------
describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendix A)', () => {
const car = (type: string, loaded = false): { type: string; loaded: boolean } => ({ type, loaded });
it('holds an engine and up to four cars', () => {
// "Up to five tokens: an engine, and up to four cars, one of which could be a caboose." The
// engine is not one of the consist entries — §8.2 counts the consist as Rolling Stock and the
// four-car limit is a limit on cars, not on the locomotive hauling them.
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar'), car('hopper'), car('tank'), car('caboose')] as never);
assert.equal(s.trays.get(id)!.consist.length, 4);
});
it('couples to the nose going forward and to the tail backing up', () => {
// Appendix A: "Engines also have couplers on the front end... a train can pick up two cars and
// add them to the Crew Tray in order that they were in, pushing them into the Facility." Which
// end they land on is the whole point of a run-around: it decides which car comes off next.
const build = (toNose: boolean): string[] => {
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
return s.trays.get(id)!.consist.map((c) => c.type);
};
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
assert.deepEqual(build(false), ['boxcar', 'hopper'], 'backing up couples them behind');
});
it('moves the engine back when cars are taken onto the nose, and forward when they are set out', () => {
// Cars taken on the nose go AHEAD of the engine, so it is no longer leading. That is what §8.2
// then refuses to let out of the Office, and setting the nose cars out is the way back.
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
const tray = s.trays.get(id)!;
tray.engineAt = 0;
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
});
it('will not cut a train in the middle', () => {
// A drop takes a cut off an OUTER end. Lifting cars from beside the engine would leave the far
// end of the train coupled to nothing — a cut no coupler could make.
const s = game();
// On a Limits card, not the Office — Rolling Stock may never be left at the Office (§A.4).
const id = placeTray(s, at(0, 1), [car('boxcar'), car('hopper')] as never);
s.trays.get(id)!.engineAt = 1;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
// One car ahead of the engine and one behind: at most one may come off either end.
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2, fromNose: true }), 'CONSIST_EMPTY');
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
});
});
// ---------------------------------------------------------------------------
describe('only the train being made up may take cars (§7)', () => {
/** A tray with a short consist, placed wherever the test needs it. */
function trayAt(s: GameState, position: CrewTray['position']): void {
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position, movesUsed: 0,
});
}
const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true };
it('accepts a car for a train standing at a Division Point', () => {
const s = game();
trayAt(s, { at: 'divisionPoint', side: 'east' });
assert.equal(check(s, 0, addHopper), null);
});
it('refuses to load a train standing in the district', () => {
// The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division
// Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding
// in your own district: cars appeared on a train nobody was making up.
const s = game();
trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) });
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
it('refuses to load a train out on the Mainline', () => {
const s = game();
trayAt(s, { at: 'mainline', index: 1 });
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
it('refuses to load a train whose consist is already what its card calls for', () => {
// Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing
// at a Division Point — otherwise the phase and `check` disagree about who is being filled.
const s = game();
trayAt(s, { at: 'divisionPoint', side: 'east' });
const tray = s.trays.get('t')!;
tray.consist = [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
];
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
});
// ---------------------------------------------------------------------------
describe('an Office upgrade keeps what Modifiers added (§9)', () => {
it('does not wipe a Restaurant when the Depot becomes a Station', () => {
// The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant
// played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next
// upgrade. The card had been spent and the effect vanished with no message.
const s = game();
const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
const play = (tier: 'depot' | 'station'): void => {
for (const [id, card] of s.cards) {
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
s.decks.hands.set(0, [id]);
s.turn.option = null;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok, `${tier} upgrade should apply`);
return;
}
throw new Error(`no ${tier} card`);
};
play('depot');
const base = { out: office().capacity.outbound, porters: office().porters };
// A Restaurant beside the Office: +1 passenger out, +1 porter.
office().capacity.outbound += 1;
office().porters += 1;
play('station');
const station = { out: 2, porters: 2 }; // Station's printed numbers
assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade');
assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade');
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
});
});