Files
station-master/test/apply.test.ts
T

1252 lines
54 KiB
TypeScript

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