Files
station-master/test/apply.test.ts
T
Jesse.MarkowitzandClaude Opus 5 3befc420da v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, three were decisions. Every save on the test server was replayed
against this build BEFORE release, which is how the cost of each rule was known
before it was chosen rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a
Passenger Facility, so the opening of every game was spent unable to work a
passenger and one arrival away from a collision. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. The deck follows the choice — starting on
Depots the four Depot upgrade cards are left out, because an upgrade must be to
the next tier and a Depot card at a table of Depots is a dead draw. How much
easier it is showed up as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one collision.

NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that
changes how a game is DEALT rather than how it plays, so replaying a save under
the wrong opening is a different railroad from intent one — silently, with no
error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the
resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
Custom, which is how the distinction was found.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was moved into
the free slot, and the arriving train was pushed in after it without anyone asking
again whether there was room — so the collision §8.3 calls for never happened. The
held train keeps priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the
pick-up restrictions are enforced afterwards in `check`, so a square the rails
reached and the card forbade was reachable, un-offered, and absent from the block
list with nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:07:21 -04:00

2483 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, movesFor, reduce } from '../src/engine/apply.ts';
import { pump } from '../src/engine/advance.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } 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 { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: 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', seat: 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,
standingWest: 0,
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(turnOf(s, 0).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;
const dealt = s.decks.hands.get(0)!.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);
// Measured against the deal rather than against a constant: the opening hand is a house rule
// now (three random, six random, or three-and-three), and none of the three changes what a draw
// does — it adds one card, and §6.2's limit then has to be played down to three.
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
});
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('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
/**
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
* salvage deck, that train is already on the timetable. It does not make sense to put that back
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
* that's in a discard pile could potentially get reused later, and so should have that
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
* should get shuffled back in so that they could get run again."*
*
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
*/
const s = game();
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
const pool = [...s.decks.homeOffice];
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
const discardedTrain = trains[1]!; // never played: sitting in a Department
const playedExtra = extras[0]!; // a single run, free to run again
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
s.decks.homeOffice = [others[5]!];
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');
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
const hands = new Set([...s.decks.hands.values()].flat());
// THE RULING, both halves.
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
assert.ok(
s.decks.salvageYard.includes(spentTrain),
'a played timetabled train should stay in the Salvage Yard, not vanish',
);
assert.ok(
recovered.has(discardedTrain) || hands.has(discardedTrain),
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
);
assert.ok(
recovered.has(playedExtra) || hands.has(playedExtra),
'a played Extra must come back — an Extra is one run, not a standing slot',
);
});
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');
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
assert.ok(
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
'the Salvage Yard must be swept apart from spent timetabled trains',
);
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();
const dealt = s.decks.hands.get(0)!.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything');
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// And the way down is play or discard — there is no per-turn cap on either, which is what makes
// a six-card opening hand playable in one turn rather than a limit that cannot be met.
const hand = () => s.decks.hands.get(0)!;
while (hand().length > HAND_LIMIT) {
const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 });
assert.ok(r.ok, 'a discard was refused while over the hand limit');
}
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
});
describe('which train cards may be discarded (Gitea#9, superseding Gitea#6)', () => {
/**
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
* it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either
* play or discard. If someone else wants to pick it up, they are more than able to. The reason:
* I don't want, if you decide to play a game longer than five days, to decide that maybe there
* are too many trains, the stations are jammed, and the railroad doesn't need any more."
*
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
* it cannot be what jams it — and whether the Timetabled half applies is a New Game setting,
* because the reasoning is about long games and a five-Day game may want Gitea#6's pressure.
*
* Note there is still no FORCING mechanism, and deliberately so: the corner is what the two
* existing rules produce together whenever the setting is off.
*/
const handOf = (s: GameState, kinds: string[]): string[] => {
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
// depend on what the shuffle happened to deal.
const picked: string[] = [];
for (const want of kinds) {
for (const [id, card] of s.cards) {
if (card.kind.kind !== want || picked.includes(id)) continue;
picked.push(id);
break;
}
}
assert.equal(picked.length, kinds.length, 'the catalogue is missing a card this test needs');
s.decks.hands.set(0, picked);
return picked;
};
/** The same game with the setting turned off — Gitea#6's rule, still reachable. */
const strictGame = (): GameState =>
createGame({
id: 'g',
seed: 77,
config: { ...config, houseRules: { ...(config.houseRules ?? {}), discardTimetabled: false } },
playerNames: ['Jesse'],
});
it('lets a Timetabled train be discarded, and still refuses an Extra', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
null,
'Gitea#9 allows this and it was refused',
);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
'an Extra never joins the timetable, so Gitea#9 does not reach it',
);
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
});
it('puts the discarded train where a rival can pick it up', () => {
// The other half of the ruling — "if someone else wants to pick it up, they are more than able
// to" — needed no machinery, because a discard already goes face-up onto a Department pile.
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
const pile = s.decks.departments[1]!;
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
});
it('offers the discard as a legal action, so the bot can take it', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled, extra] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
const offered = legalActions(s, 0).filter((i) => i.type === 'card.discard');
assert.ok(
offered.some((i) => i.type === 'card.discard' && i.cardId === timetabled),
'a Timetabled train was not offered as a discard',
);
assert.ok(
!offered.some((i) => i.type === 'card.discard' && i.cardId === extra),
'an Extra was offered as a discard',
);
});
it('keeps Gitea#6 reachable when the setting is off', () => {
const s = strictGame();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
for (const id of [timetabled!, extra!]) {
assert.equal(
check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
);
}
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
});
it('leaves PLAYING a train as the only way out of a hand of four, setting off', () => {
const s = strictGame();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
// Over the limit, so the turn cannot be ended...
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// ...and not one of them may be discarded...
for (const id of four) {
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
}
// ...but playing one is always legal, so the player is never actually stuck.
assert.equal(check(s, 0, { type: 'card.play', cardId: four[0]! }), null);
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT);
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
});
it('a hand of four Extras is the corner that survives Gitea#9 with the setting ON', () => {
// Gitea#9 does not reach an Extra, so the deadlock-that-is-not-a-deadlock is still real in a
// default game — worth pinning, since it is now the ONLY way to reach it.
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const four = handOf(s, ['extraTrain', 'extraTrain', 'extraTrain', 'extraTrain']);
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
for (const id of four) {
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
}
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
assert.equal(check(s, 0, { type: 'draw.end' }), null);
});
it('lets a train be held across Stages and into the next Day', () => {
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud. An Extra is
// used, because it is the card that still cannot be got rid of any other way.
const s = game();
const [extra] = handOf(s, ['extraTrain', 'track']);
const startDay = s.clock.day;
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
for (let guard = 0; guard < 400 && s.clock.day === startDay; guard++) {
pump(s);
const actor = s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
const end = options.find((i) => i.type.endsWith('.end')) ?? options[0];
if (!end) break;
applyIntent(s, actor, end);
}
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
assert.ok(
(s.decks.hands.get(0) ?? []).includes(extra!),
'the train did not survive being held into the next Day',
);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
'a Day boundary made an Extra discardable',
);
});
it('tells the player on the card itself which of the two rules applies', () => {
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
// reason given. Since Gitea#9 there are TWO reasons, so the card has to say which.
const s = game();
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
const f = snapshot(s, [], null);
// `hand` is reversed for display, so compare as a set rather than by position.
assert.deepEqual([...f.handDiscardable].sort(), [false, true, true]);
const said = f.handKeepWhy.filter((w): w is string => w !== null);
assert.equal(said.length, 1, 'exactly one card in this hand may not be discarded');
assert.match(said[0]!, /An Extra is never discarded/);
const strict = strictGame();
handOf(strict, ['timetabledTrain', 'extraTrain', 'track']);
const sf = snapshot(strict, [], null);
assert.deepEqual([...sf.handDiscardable].sort(), [false, false, true]);
assert.ok(
sf.handKeepWhy.some((w) => w !== null && /never discarded in this game/.test(w)),
'the setting being off is not explained on the card',
);
});
});
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');
// The opening hand is six, so one discard does not yet clear §6.2's limit — shed the rest.
while (s.decks.hands.get(0)!.length > HAND_LIMIT) {
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 1 });
}
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(turnOf(s, 0).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' });
turnOf(s, 0).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: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
// An empty hopper is SPOTTED, which is what the LABORERS need (§9.3). The Freight Agent
// does not: stocking works over a bare track too, and the tests below cover that.
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
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('stocks the green box before the car is spotted (§6.3)', () => {
/**
* REPORTED FROM PLAY: "A freight agent should be able to load an outbound green box prior to
* having the car there that matches the load he's putting in there."
*
* §6.3 asks for nothing but a car in the Division Yard and room in the box. The empty-car
* requirement is §9.3's, on the Laborer who moves the load out of the box — see the test below.
* The engine used to hoist it forward onto stocking and so refused the ordinary play of having
* the cargo waiting while the car is still being switched in.
*/
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.industryTrack.cars = [];
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
assert.ok(r.ok, 'staging cargo against a bare industry track is legal');
assert.equal(f.outboundBox.length, 1);
});
it('still refuses to stock with no matching loaded car in the Division Yard', () => {
// The requirement §6.3 DOES make: the load has to come from somewhere.
const s = game();
const coord = withFacility(s);
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'hopper' && c.loaded));
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'NO_SUITABLE_CAR',
);
});
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('returns a cleared car to the yard EMPTY, not still loaded', () => {
/**
* REGRESSION, v0.8.1.0. `inboundCleared` pushed `pooled(e.stock)` under a comment reading
* "a car back in a yard is back in the common supply, carrying nothing" — and `pooled` does not
* do that. It strips the `origin` stamp and keeps `loaded` DELIBERATELY, because a train can
* retire at a Division Point with freight still aboard (`advance.ts`). So every car the Freight
* Agent cleared arrived in the Classification Yard still carrying a load that had already been
* delivered and already paid for.
*
* It bites hardest on coaches: `passengersDetrained` takes `type === 'coach' && !loaded` out of
* the Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared
* coach came back as stock that could not unload a passenger.
*/
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.allows = { outbound: false, inbound: true };
f.inboundBox = [{ type: 'coach', loaded: true }];
const before = s.yards.classificationYard.length;
assert.ok(applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }).ok);
assert.ok(
applyIntent(s, 0, { type: 'freightAgent.clearInbound', at: coord, index: 0 }).ok,
'the Freight Agent could not clear the red box',
);
assert.equal(f.inboundBox.length, 0, 'the red box was not cleared');
assert.equal(s.yards.classificationYard.length, before + 1, 'the car did not reach the yard');
const arrived = s.yards.classificationYard[s.yards.classificationYard.length - 1]!;
assert.equal(arrived.type, 'coach', 'the wrong car reached the yard');
assert.equal(arrived.loaded, false, 'the cleared car arrived in the yard still loaded');
// And a coach that arrives empty is stock a Porter can actually use again.
assert.ok(
s.yards.classificationYard.some((c) => c.type === 'coach' && !c.loaded),
'no empty coach reached the common supply',
);
});
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: [],
standingWest: 0,
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: { 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('holds a staged load in the green box until a car is spotted (§9.3)', () => {
/**
* THE OTHER HALF OF THE STOCKING RULE. The Freight Agent may stage a load with nothing on the
* industry track (§6.3), but "Load the car" requires "*a load in the Green Loading Box AND an
* empty car of the required type on the industry's track*" — so the load simply waits in the
* box, which is a wait and not a jam: only a load on MEN | AT | WORK locks the track.
*/
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, null];
f.outboundBox = [{ type: 'hopper', loaded: true }];
f.industryTrack.cars = [];
assert.equal(
check(s, 0, { type: 'laborer.startLoad', at: coord }),
'NO_EMPTY_CAR_SPOTTED',
'no car on the track — the Laborers cannot start it',
);
// A crew sets an empty hopper out, and the same staged load may now start down the sign.
f.industryTrack.cars = [{ type: 'hopper', loaded: false }];
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), null);
const r = applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
assert.ok(r.ok);
assert.equal(f.outboundBox.length, 0, 'the load left the green box');
assert.notEqual(f.menAtWork![0], null, 'and is on MEN');
});
it('will not start a load onto a spotted car of the wrong type', () => {
// Strict matching: a hopper load cannot be swapped onto a tank (§9.3).
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, null];
f.outboundBox = [{ type: 'hopper', loaded: true }];
f.industryTrack.cars = [{ type: 'tank', loaded: false }];
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
});
it('unloads the car the player actually picked, not always the westmost one', () => {
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
// The reducer for `unloadBegan` used to re-derive the target with
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
// track order no matter which `carIndex` the intent actually named.
const s = game();
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: {
cars: [
{ type: 'boxcar', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'tank', loaded: true },
],
},
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
// An empty replacement of every type standing on the track, so `check` never refuses for a
// reason unrelated to what this test is about.
s.yards.divisionYard.push(
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: false },
{ type: 'tank', loaded: false },
);
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
});
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 = { kind: 'clearance', 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 = { kind: 'clearance', 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: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { 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: { 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: { 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]);
openOffice(s); // a Waiting Area needs a Passenger Facility, which a Whistle Post is not
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`,
);
}
});
it('refuses a second Modifier of the same kind in one Office Area', () => {
// REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from
// doubling up since Q4, but a Modifier is a different card kind and had no such check at all.
const s = game();
const area = areaOf(s, 0);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const copies = [...s.cards.entries()]
.filter(([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea')
.map(([id]) => id);
assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area');
s.decks.hands.set(0, [copies[0]!]);
openOffice(s);
const first = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined,
) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } };
assert.ok(first, 'no legal square for the first Waiting Area');
assert.ok(applyIntent(s, 0, first).ok, 'the first Waiting Area should be playable');
// A different copy, on a different square, is still a second Waiting Area in this district.
s.decks.hands.set(0, [copies[1]!]);
const elsewhere = { row: first.placement.row, col: first.placement.col + 1 };
assert.equal(
check(s, 0, { type: 'card.play', cardId: copies[1]!, placement: elsewhere }),
'FACILITY_LOCKED',
'a district accepted two Waiting Areas',
);
assert.equal(
legalActions(s, 0).filter((i) => i.type === 'card.play' && i.cardId === copies[1] && i.placement).length,
0,
'the second copy is still being offered squares it cannot legally take',
);
});
});
// ---------------------------------------------------------------------------
/**
* Raise a player's Office to a tier that IS a Passenger Facility.
*
* A Waiting Area, Restaurant or Hotel may not be played at a Whistle Post (2026-09-23), and these
* fixtures name the Whistle Post opening, so a test about those cards has to open the Office first.
* Mirrors the `officeUpgraded` reducer: the tier, and the passenger flow that comes with it.
*/
function openOffice(s: GameState, player = 0 as never, tier: 'depot' | 'station' | 'terminal' = 'depot'): void {
const area = areaOf(s, player);
const to = officeProfile(tier);
area.tier = tier;
const card = area.grid.get(coordKey(area.officeCoord));
if (card?.facility) {
card.facility.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
card.facility.capacity = { outbound: to.passengerOut, inbound: to.passengerIn };
card.facility.porters = to.porters;
}
}
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
/**
* A district whose Running Track has been extended one square east, so the sign stands at col 2
* and there is a siding hanging below the main to build on:
*
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2
* row -1: [curve ne]
*
* Returns the industry-ready square under the sign, at (-1, 2).
*/
function district(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], standingWest: 0, 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',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'draw';
return at(area.runningRow - 1, col + 1);
}
const trackInHand = (s: GameState, geometry: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'track' && card.kind.geometry === geometry) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no ${geometry} card`);
};
const industryOf = (s: GameState, facility: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no industry card: ${facility}`);
};
const modifierOf = (s: GameState, modifier: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no modifier card: ${modifier}`);
};
/** An industry standing on the board, so a Modifier has a host to hang off. */
const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
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 },
},
} as never);
};
it('accepts a siding in the sign\'s own column and refuses one past it', () => {
/**
* REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines
* Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound
* belongs to the district rather than to the one row the sign stands in — it used to guard the
* Running Track alone, and a siding could run east past a player's own sign, taking industries
* with it, onto track that §8.1 and §10 do not consider his territory at all.
*
* INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening
* district is signs at ±1 around the Office, so the strict reading would leave one buildable
* column and break §11.3's promise that both Secondary rows are usable from the first Stage.
*/
const s = game();
const under = district(s);
const cardId = trackInHand(s, 'straight');
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: under }),
null,
'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
'a siding was built outside the district it belongs to',
);
});
it('refuses an industry outside the Limits — a Facility carries track', () => {
// §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound
// is the same one, and said with the same code rather than left to read as NOT_CONNECTED.
const s = game();
const under = district(s);
const cardId = industryOf(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
);
});
it('refuses a Modifier on a diagonal, and offers only the four orthogonal spots', () => {
/**
* JESSE'S RULING, 2026-09-23, REVERSING HIS OWN EARLIER REPORT. This asserted the opposite: he
* had reported that a Modifier could not be placed to the south-east of his industry, and §9's
* "any of the nine nearby spots" was read as all eight neighbours. A Modifier must sit SQUARE
* against what it serves now — a card on a corner touches it at a point, not along an edge.
*
* The fixture is unchanged so the reversal is asserted on the very square that prompted the
* original change.
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
// sign goes out one more column and the host keeps all nine.
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
const offered = legalActions(s, 0)
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
.map((i) => coordKey((i as { placement: GridCoord }).placement));
// South-east of the host: touching it at a corner only.
const southEast = at(under.row - 1, under.col + 1);
assert.ok(
!offered.includes(coordKey(southEast)),
`the diagonal spot (${southEast.row}, ${southEast.col}) is still offered — offered: ${offered.join(' ')}`,
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: southEast }),
'NOT_CONNECTED',
'a Modifier was accepted on a diagonal',
);
// The orthogonal neighbours are still there, or the card would have nowhere to go at all.
const east = at(under.row, under.col + 1);
assert.ok(
offered.includes(coordKey(east)),
`the square east of the host is not offered — offered: ${offered.join(' ')}`,
);
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: east }), null);
});
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
/**
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
* refusing the outer three looked like it would make the card unplayable where the district
* ends. What decided it was the board — a card standing outside your own sign, in territory
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
*
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
* and a Modifier parked there would block the player's own sign from moving outward.
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
const area = areaOf(s, 0);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
'OUTSIDE_LIMITS',
'a Modifier was allowed to stand outside the district it belongs to',
);
// The spot inside the sign, beside the same host, is the one a player actually has — free,
// adjacent, and on the sign's own column, which `withinLimits` includes.
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
null,
'a Modifier was refused a free, connected spot inside the Limits',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
'ON_RUNNING_TRACK',
'a Modifier was allowed to stand in the row the Running Track grows along',
);
});
});
// ---------------------------------------------------------------------------
describe("a Modifier grants only what its host's flow can use", () => {
const withFacility = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [],
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
});
/** Plays `modifier` next to the facility at `at`, and returns the facility and its card. */
const modifierBeside = (modifier: string, kind: string, out: boolean, into: boolean) => {
const s = game();
const area = areaOf(s, 0);
const at = { row: -1, col: 4 };
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
// host well east of the opening sign. Modifiers have been bounded by the Limits since
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
// fail as OUTSIDE_LIMITS and prove nothing about flow.
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const card = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === modifier,
);
assert.ok(card, `no ${modifier} in the deck`);
s.decks.hands.set(0, [card![0]]);
const r = applyIntent(s, 0, {
type: 'card.play', cardId: card![0], placement: { row: at.row, col: at.col + 1 },
});
assert.ok(r.ok, `the ${modifier} should be playable beside a ${kind}`);
const host = area.grid.get(coordKey(at))!;
return { f: host.facility!, card: host };
};
const iceHouseBeside = (kind: string, out: boolean, into: boolean) =>
modifierBeside('iceHouse', kind, out, into).f;
it('gives a Packing Sheds the outbound slot its card prints', () => {
// The host ships, so every printed grant lands. This is the control for the case below.
const f = iceHouseBeside('packingSheds', true, false);
assert.equal(f.capacity.outbound, 2, 'the outbound slot did not arrive');
assert.equal(f.laborers, 2, 'the laborer did not arrive');
});
it("drops the outbound slot on a Grocer's Warehouse, which only receives", () => {
// REPORTED as "the Ice House added the laborer but not the outbound slot" — and it is not a bug.
// A Grocer's is flow:'inbound', so `allows.outbound` is false and the capacity had nowhere to
// go: it was raised anyway, on a direction that can never be drawn or stocked.
const f = iceHouseBeside('grocersWarehouse', false, true);
assert.equal(f.laborers, 2, 'the laborer should still arrive — Laborers have no direction');
assert.equal(f.capacity.outbound, 0, 'outbound capacity was credited to a facility that cannot ship');
assert.equal(f.capacity.inbound, 1, 'the modifier must not silently move the grant to the other direction');
});
it('gives a Truck Dock a second red box and not one more car length', () => {
/**
* The case as it was described: "if I have a Grocer's Warehouse that receives, it has one red
* box. If it has a Truck Dock next to it, that should give it a second red box. There is no
* change to the number of cars that are allowed to be dropped here."
*
* Both halves matter, and the second half is the one that used to be wrong: the Truck Dock's
* inbound grant lengthened the industry track along with the box, because the track's length WAS
* the box count. The boxes are how much work the industry can hold; the card holds four cars
* either way.
*/
const { f, card } = modifierBeside('truckDock', 'grocersWarehouse', true, true);
assert.equal(f.capacity.inbound, 2, 'the Truck Dock did not add the red box it prints');
assert.equal(f.capacity.outbound, 1, 'the Truck Dock added a green box, which it does not print');
assert.equal(
spaceOn(card as never), MAX_CONSIST,
'a Modifier changed how many cars may be set out at the industry it stands beside',
);
});
it('does not give a Passenger Facility an industry track by way of a Modifier', () => {
// FOUND by rendering 12,000 frames of real games rather than a fixture: a Waiting Area beside the
// Office lengthened `industryTrack`, and the board then drew the Depot a square to spot a car on.
// A Waiting Area's "+1" is a passenger slot; passengers board off the platform.
//
// The length field is gone now — an industry track holds four cars like any other card, so there
// was never a number for a Modifier to raise. What is still worth pinning down is the property
// that bug was really about: an Office keeps its cars in `standing` and has no industry track for
// a Modifier to hand it, no matter what the Modifier prints.
const s = game();
const area = areaOf(s, 0);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
openOffice(s);
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
const office = officeCard.facility!;
const slotsBefore = office.capacity.outbound;
const portersBefore = office.porters;
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
)!;
s.decks.hands.set(0, [waiting[0]]);
const spot = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === waiting[0] && i.placement !== undefined,
);
assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office');
assert.ok(applyIntent(s, 0, spot!).ok);
// Relative, so the assertion says what the Modifier is worth rather than what a Depot prints.
assert.equal(office.capacity.outbound, slotsBefore + 1, 'the passenger slot should still be granted');
assert.equal(office.porters, portersBefore + 1, 'the porter should still be granted');
assert.equal(
carsOn(officeCard), officeCard.standing,
'a Modifier gave a Passenger Facility an industry track to spot cars on',
);
});
it('refuses a passenger Modifier at a Whistle Post, which cannot use it at all', () => {
/**
* JESSE'S RULING, 2026-09-23, REVERSING the 2026-09-17 call that let these stand dormant.
*
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility — it allows
* neither direction — so the card's +1 outbound was discarded on the spot and only its porter
* landed. Dormant was defensible while the panel explained itself, but a card that can be played
* to no effect is a trap however well it is labelled.
*
* THE RECOVERY PATH IN `officeUpgraded` IS LEFT IN PLACE and is now unreachable by play: it
* restores a grant suppressed at a Whistle Post, and no such grant can be created any more. It
* is kept because it is correct, and relaxing this rule would need it back.
*/
const s = game();
const area = areaOf(s, 0);
const office = area.grid.get(coordKey(area.officeCoord))!;
assert.equal(office.facility!.allows.outbound, false, 'a Whistle Post is not a Passenger Facility');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const restaurant = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
)!;
s.decks.hands.set(0, [restaurant[0]]);
const beside = { row: area.officeCoord.row - 1, col: area.officeCoord.col };
assert.equal(
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
'OFFICE_NOT_PASSENGER',
'a Restaurant was accepted at a Whistle Post',
);
const offered = legalActions(s, 0).filter(
(i) => i.type === 'card.play' && i.cardId === restaurant[0] && i.placement !== undefined,
);
assert.equal(offered.length, 0, 'a Restaurant was offered a square at a Whistle Post');
// Upgrade the Office and the very same square becomes legal.
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
assert.equal(
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
null,
'a Restaurant is still refused at a Depot, which IS a Passenger Facility',
);
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }).ok);
const f = office.facility!;
assert.equal(f.capacity.outbound, officeProfile('depot').passengerOut + 1, 'the slot did not land');
assert.equal(f.porters, officeProfile('depot').porters + 1, 'the porter did not land');
});
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], standingWest: 0, 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',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).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: [], standingWest: 0, 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', player: 0, 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', player: 0, 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', player: 0, 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;
turnOf(s, 0).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', seat: 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]);
turnOf(s, 0).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');
});
});
// ---------------------------------------------------------------------------
describe('the engine is drawn pointing east or west, whatever track it is standing on', () => {
/**
* Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept
* that ▲ after it departed onto the Division — where there is no north or south at all — because
* nothing resets `facing` when a train leaves a district. A Division runs east and west, and a
* player reads a train the way a railroader does: which END the engine is on.
*
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
reduce(s, moved(id, 'w'));
assert.equal(railFacingOf(tray), 'w');
// Onto a curve, and away up a spur. The port is north; the engine is still on the west end.
reduce(s, moved(id, 'n'));
assert.equal(tray.facing, 'n', 'the movement port must still be the real one');
assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end');
});
it('follows the engine around 180° of curves, because that really is a turn', () => {
// Forward through two curves — west port to north, north to east — and the engine genuinely
// does come out pointing the other way. Held values are held, not frozen.
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f));
assert.equal(railFacingOf(tray), 'e');
});
it('falls back to the direction of the run for a tray that has not moved yet', () => {
// A tray placed straight onto the board has no history to carry, and `direction` is the only
// east-west fact about it.
const s = game();
const tray = s.trays.get(placeTray(s, at(0, 0)))!;
assert.equal(tray.railFacing, undefined);
assert.equal(railFacingOf(tray), 'e', 'placed running east');
tray.direction = 'west';
assert.equal(railFacingOf(tray), 'w');
});
it('never reports north or south to the board', () => {
// The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a
// north-south spur used to break.
const s = game();
const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never);
addCard(s, at(0, 0), straight());
reduce(s, moved(id, 's'));
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
const train = cell?.trains[0];
assert.ok(train, 'the train should be on the card');
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
});
});
// ---------------------------------------------------------------------------
describe('a train that rounds a curve points where the curve took it', () => {
/**
* REPORTED from play: "a train reversed into a siding and the display of the cars was reversed."
*
* `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` —
* which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a
* north-west curve through its west port and the far end is north, not east. So a train that
* rounded a curve was left facing a port the card does not have.
*
* Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the
* card lacks yields nothing, so the crew could only ever back out the way it came. Display: the
* east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine
* west could leave the board still drawing it east — the consist mirrored, which is what was seen.
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
});
/**
* Runs a crew one Move and reports where the engine ended up pointing.
*
* The card it starts ON matters as much as the one it moves to: a crew facing north or south has
* to be standing on something with that port, and only a curve has one.
*/
const roundIt = (
from: GridCoord,
to: GridCoord,
origin: TrackCard,
dest: TrackCard,
start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' },
reverse = false,
): { facing?: string; drawn: string } => {
const s = game();
addCard(s, from, origin);
addCard(s, to, dest);
const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never);
const tray = s.trays.get(id)!;
tray.facing = start.facing;
tray.railFacing = start.railFacing;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
return { facing: tray.facing, drawn: railFacingOf(tray) };
};
it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => {
// East along the running track into a curve that turns north. The far end is NORTH.
const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' });
assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have');
});
it('does not strand the crew on the curve it just rounded', () => {
// The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without
// that port yields nothing at all — so the only move left was backing out the way it came.
const s = game();
addCard(s, at(0, 0), straight());
addCard(s, at(0, 1), curve('nw')); // joins north and west
addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues
addCard(s, at(1, 2), straight());
const id = placeTray(s, at(0, 0), []);
s.trays.get(id)!.facing = 'e';
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
const { to } = movesFor(s, 0, id);
assert.ok(
to.some((c) => c.row === 1 && c.col === 1),
'the crew cannot carry on round the curve — it can only back out the way it came',
);
});
it('can still back out of the curve it just rounded', () => {
/**
* THE OTHER HALF OF THE SAME MISTAKE, and the one that bites hardest.
*
* Forward exits by `facing`; reverse used to exit by `opposite(facing)` — which, again, is the
* other end of a STRAIGHT and of nothing else. A crew standing on a north-west curve facing
* north reverses out through WEST, the way it came; `opposite('n')` is south, a port the curve
* does not have, so backing up found nothing.
*
* Fixing only the forward case moved the problem rather than solving it: before, a crew that
* rounded a curve could only back out; after, it could only carry on. A crew must be able to do
* both, which is what makes a siding reachable and therefore what makes setting out a cut
* possible at all.
*/
const s = game();
addCard(s, at(0, 0), straight());
addCard(s, at(0, 1), curve('nw')); // joins north and west
addCard(s, at(1, 1), curve('se')); // the spur carries on north
const id = placeTray(s, at(0, 0), []);
s.trays.get(id)!.facing = 'e';
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
const { to } = movesFor(s, 0, id);
assert.ok(
to.some((c) => c.row === 0 && c.col === 0),
'the crew cannot back off the curve onto the track it came from',
);
assert.ok(
to.some((c) => c.row === 1 && c.col === 1),
'the crew cannot carry on round the curve either',
);
// And backing up really does go there, rather than merely being offered.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
});
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
// board has to say so — this is the mirrored consist that was reported.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' });
assert.equal(out.facing, 'w', 'the curve turned the engine west');
assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east');
});
it('still points back the way it came when it BACKS round a curve', () => {
// Backing up does not turn a train around, whatever the track does underneath it: the engine
// trails, pointing out through the port the train came in by. That is geometry-independent and
// was already right — this is here so the fix above cannot quietly change it.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true);
assert.equal(out.facing, 'n', 'backing up turned the train around');
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
});
});
// ---------------------------------------------------------------------------
describe('the switching job a player actually does: put a car in a siding, take an empty to an industry', () => {
/**
* REPORTED from play: "I'm trying to switch to get an empty car to an industry, but I can't drop
* any cars on the siding first."
*
* Setting out a cut needs no Move and is refused almost nowhere — but you can only set out where
* the train IS, and the Office square itself is barred (§A.4). So "I cannot drop" is nearly always
* "I cannot GET there", and getting there means leaving the Running Track through a turnout and a
* curve. That is exactly where `opposite(facing)` was wrong in both directions.
*
* This walks the whole errand rather than any one rule, because each individual rule passed its
* own test while the errand was impossible.
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
it('takes a cut off the Running Track into a siding and sets it out', () => {
const s = game();
// Running Track east from the Office, with a turnout diverging north onto a short siding.
addCard(s, at(0, 0), straight());
addCard(s, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' }));
addCard(s, at(0, 2), straight());
addCard(s, at(1, 1), curve('se')); // up off the turnout, then east along the siding
addCard(s, at(1, 2), straight());
const id = placeTray(s, at(0, 0), [
{ type: 'boxcar', loaded: false },
{ type: 'tank', loaded: false },
] as never);
s.trays.get(id)!.facing = 'e';
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
// Out of the Office and up onto the siding. A turnout is not somewhere a train may stop, so the
// first square it can finish on is the curve beyond it.
const reachable = movesFor(s, 0, id).to;
assert.ok(
reachable.some((c) => c.row === 1 && c.col === 1),
'the siding is not reachable from the Running Track at all',
);
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 1), reverse: false }).ok);
// Set the cut out. This is the step that was reported as impossible.
const drop = applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 });
assert.ok(drop.ok, `setting out on the siding was refused: ${drop.ok ? '' : drop.code}`);
assert.deepEqual(
areaOf(s, 0).grid.get(coordKey(at(1, 1)))!.standing.map((c) => c.type),
['tank'],
'the car did not end up on the siding',
);
assert.equal(s.trays.get(id)!.consist.length, 1, 'the train still has the car it set out');
// And it can get back off the siding again, carrying the empty on toward an industry.
const after = movesFor(s, 0, id).to;
assert.ok(
after.some((c) => c.row === 0 && c.col === 0) || after.some((c) => c.row === 0 && c.col === 2),
'the crew is stranded on the siding — it cannot return to the Running Track',
);
});
});
// ---------------------------------------------------------------------------
/**
* The v0.4.9d playtest, three reports with two causes.
*
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
* trains in a station: the select button does not work, regardless of which you pick it is always
* one train, not the other" — the porter intents carrying no tray.
*/
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
/** An Office that can work passengers, with someone waiting and Porters to hand. */
function platform(s: GameState): void {
const f = office(s);
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }];
s.yards.divisionYard.push({ type: 'coach', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** A tray standing on an A/D track at the Office. */
function atOffice(s: GameState, 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', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
});
areaOf(s, 0).adOccupancy.push(id);
return id;
}
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
function freightHouse(s: GameState, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'boxcar', loaded: true }],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
laborers: 9, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard);
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** Walk a staged load all the way onto the spotted car. */
function finishLoad(s: GameState, coord: GridCoord): void {
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
}
it('refuses to unload the boxcar the Freight House just loaded', () => {
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
finishLoad(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
assert.equal(
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
'LOADED_IN_THIS_DISTRICT',
);
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
assert.ok(
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
'the unload was still offered',
);
});
it('unloads a load that came from somewhere else', () => {
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
// traffic a district lives on.
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
});
it('refuses to detrain the passengers this Office just put aboard', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
const coach = s.trays.get(tray)!.consist[0]!;
assert.equal(coach.loaded, true, 'nobody boarded');
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
assert.equal(
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('detrains passengers who boarded somewhere else', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
});
it('takes the origin stamp off a coach that reaches the red box', () => {
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
// heads back to a yard from there; carrying the stamp on would poison the common supply.
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
});
});
describe('two trains in one station are told apart (v0.4.9e)', () => {
function twoAtOffice(s: GameState): [string, string] {
const area = areaOf(s, 0);
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
const ids: string[] = [];
for (let n = 0; n < 2; n++) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(id);
ids.push(id);
}
return [ids[0]!, ids[1]!];
}
it('offers boarding on each train, not once for the platform', () => {
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
// which you pick, it is always one train, not the other." There was one button, because the
// intent carried no train at all.
const s = game();
const [a, b] = twoAtOffice(s);
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
assert.deepEqual(
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
[a, b].sort(),
'both trains at the platform must be offered',
);
});
it('boards the train the player named, not the first on the A/D tracks', () => {
const s = game();
const [a, b] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
});
it('still works for an intent that names no train, so old saves replay', () => {
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
// record every save and every undo replays against.
const s = game();
const [a] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
});
});
/**
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
* drive right through them."
*
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
* engine can see always couple — which means cars a train can drive through are cars the engine does
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
* so if the case is found later it is somewhere none of them cover.
*/
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
const caboose = () => ({ type: 'caboose' as const, loaded: false });
/** A Freight House card, with cars spotted on its industry track. */
function industry(cars: TrackCard['standing']): TrackCard {
return {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true, standing: [], standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { cars: [...cars] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard;
}
const empty = (s: GameState, ...coords: GridCoord[]): void => {
for (const c of coords) {
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
}
};
it('takes a cut standing on plain track on the way to the caboose', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
// where the two could have come apart.
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it("takes the train's own cut off the square it is standing on as well", () => {
const s = game();
addCard(s, at(0, 1), straight());
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
});