1252 lines
54 KiB
TypeScript
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');
|
|
});
|
|
});
|