Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be reproduced and is written up in TODO.md with the two questions that would pin it down. TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain` carried no tray, so there was one button per platform however many trains stood at it and the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not even asking the same question: `check` skipped a train whose card refuses passenger work and the reducer did not. Both intents now carry an optional `trayId`, one function resolves the train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train, and the label names it. A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to another district. The load carries the seat that made it (`RollingStock.origin`), stripped by `pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400 paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf. THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the reading that "Freight House" was a collective term for exactly those two, and therefore what §9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound. Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which leaves exactly the one same-district pairing the rule above refuses. NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`. Both published replays that had gone dead were re-recorded; a rules change retires a save, and `harness.test.ts` is what catches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
2165 lines
97 KiB
TypeScript
2165 lines
97 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, movesFor, reduce } from '../src/engine/apply.ts';
|
||
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } from '../src/engine/content.ts';
|
||
import type { Intent } from '../src/engine/intents.ts';
|
||
import { legalActions } from '../src/engine/legal.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||
import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
|
||
import { cardDescription, snapshot } from '../src/sim/view.ts';
|
||
|
||
const config: GameConfig = {
|
||
mode: 'solitaire',
|
||
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', seat: 0, coord },
|
||
movesUsed: 0,
|
||
});
|
||
return id;
|
||
}
|
||
|
||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||
}
|
||
|
||
const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'straight' },
|
||
baseOperationalRail: true,
|
||
standing,
|
||
standingWest: 0,
|
||
facility: null,
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('turn and phase gating', () => {
|
||
it('rejects an intent from a player who is not the actor', () => {
|
||
const s = game();
|
||
s.clock.currentActor = 1;
|
||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
|
||
});
|
||
|
||
it('rejects an intent belonging to another phase', () => {
|
||
const s = game();
|
||
s.clock.phase = 'loadUnload';
|
||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||
});
|
||
|
||
it('rejects everything once the game is finished', () => {
|
||
const s = game();
|
||
s.status = 'finished';
|
||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Local Operations: the three-way exclusive choice (§6)', () => {
|
||
it('accepts a first choice and records it', () => {
|
||
const s = game();
|
||
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.ok(r.ok);
|
||
assert.equal(turnOf(s, 0).option, 'draw');
|
||
});
|
||
|
||
it('forecloses the other two options for the Stage', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'localOps.choose', option: 'switch' }),
|
||
'OPTION_ALREADY_CHOSEN',
|
||
);
|
||
});
|
||
|
||
it('refuses sub-intents of an option that was not chosen', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Local Operations: drawing (§6.2)', () => {
|
||
it('draws from the Home Office deck into the hand', () => {
|
||
const s = game();
|
||
const before = s.decks.homeOffice.length;
|
||
const dealt = s.decks.hands.get(0)!.length;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||
assert.ok(r.ok);
|
||
assert.equal(s.decks.homeOffice.length, before - 1);
|
||
// Measured against the deal rather than against a constant: the opening hand is a house rule
|
||
// now (three random, six random, or three-and-three), and none of the three changes what a draw
|
||
// does — it adds one card, and §6.2's limit then has to be played down to three.
|
||
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
|
||
});
|
||
|
||
it('takes the TOP card from a Department pile, never one buried under it', () => {
|
||
const s = game();
|
||
const buried = s.decks.departments[1]![0]!;
|
||
// Bury it: a card discarded onto this Department goes on top and puts the other out of reach.
|
||
const spare = s.decks.hands.get(0)![0]!;
|
||
s.decks.departments[1]!.push(spare);
|
||
s.decks.hands.set(0, s.decks.hands.get(0)!.filter((c) => c !== spare));
|
||
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
|
||
assert.ok(r.ok);
|
||
assert.ok(s.decks.hands.get(0)!.includes(spare), 'the top card should be the one taken');
|
||
assert.ok(!s.decks.hands.get(0)!.includes(buried), 'a buried card must not be reachable');
|
||
assert.deepEqual(s.decks.departments[1], [buried], 'the pile should be one shorter, not refilled');
|
||
});
|
||
|
||
it('refills a Department only when taking its last card empties it', () => {
|
||
const s = game();
|
||
const target = s.decks.departments[1]![0]!;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
|
||
assert.ok(r.ok);
|
||
assert.ok(s.decks.hands.get(0)!.includes(target));
|
||
// §6.2 — an emptied Department is refilled from the Home Office deck immediately, so the
|
||
// face-up market never disappears.
|
||
assert.equal(s.decks.departments[1]!.length, 1, 'an emptied Department was not refilled');
|
||
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
|
||
});
|
||
|
||
it('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();
|
||
const dealt = s.decks.hands.get(0)!.length;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
|
||
assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything');
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||
|
||
// And the way down is play or discard — there is no per-turn cap on either, which is what makes
|
||
// a six-card opening hand playable in one turn rather than a limit that cannot be met.
|
||
const hand = () => s.decks.hands.get(0)!;
|
||
while (hand().length > HAND_LIMIT) {
|
||
const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 });
|
||
assert.ok(r.ok, 'a discard was refused while over the hand limit');
|
||
}
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
|
||
});
|
||
|
||
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
|
||
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
|
||
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
|
||
// the old single-slot model did — destroyed the buried card outright and threw the choice away.
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||
const under = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
|
||
const spare = s.decks.hands.get(0)![0]!;
|
||
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost');
|
||
// The opening hand is six, so one discard does not yet clear §6.2's limit — shed the rest.
|
||
while (s.decks.hands.get(0)!.length > HAND_LIMIT) {
|
||
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 1 });
|
||
}
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||
});
|
||
|
||
it('lets the discarding player pick which Department to use', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||
const spare = s.decks.hands.get(0)![0]!;
|
||
for (const slot of [0, 1, 2]) {
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: spare, toSlot: slot }), null, `Department ${slot}`);
|
||
}
|
||
const depths = s.decks.departments.map((p) => p.length);
|
||
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 2 }).ok);
|
||
assert.deepEqual(
|
||
s.decks.departments.map((p) => p.length),
|
||
[depths[0]!, depths[1]!, depths[2]! + 1],
|
||
'only the chosen Department should grow',
|
||
);
|
||
});
|
||
|
||
it('refuses to play a card that is not in hand', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
|
||
'CARD_NOT_IN_HAND',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Office upgrades (Gap 3b, Gap 8)', () => {
|
||
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'office' && card.kind.tier === tier) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error('no such office card');
|
||
}
|
||
|
||
it('upgrades a Whistle Post to a Depot', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
assert.ok(r.ok);
|
||
assert.equal(areaOf(s, 0).tier, 'depot');
|
||
});
|
||
|
||
it('refuses to skip a tier', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'station');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
|
||
});
|
||
|
||
it('preserves every grid connection through an upgrade', () => {
|
||
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
|
||
// Secondary Track hanging off the Office.
|
||
const s = game();
|
||
addCard(s, at(-1, 0), straight());
|
||
const gridBefore = new Map(areaOf(s, 0).grid);
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
|
||
const gridAfter = areaOf(s, 0).grid;
|
||
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
|
||
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
|
||
});
|
||
|
||
it('grants the Depot its Porters and slots on upgrade', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
|
||
assert.equal(office.facility!.porters, 1);
|
||
// The design gives slots equal to porters, not one more.
|
||
assert.equal(office.facility!.capacity.inbound, 1);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Local Operations: switching (§6.1, Appendix A)', () => {
|
||
it('moves a tray and spends a Move', () => {
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||
assert.ok(r.ok);
|
||
assert.equal(turnOf(s, 0).movesRemaining, MOVES_PER_LOCAL_OPS - 1);
|
||
});
|
||
|
||
it('couples standing cars automatically and mandatorily', () => {
|
||
// §A.4 — "you MUST pick them up".
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||
assert.ok(r.ok);
|
||
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
|
||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||
});
|
||
|
||
it('refuses a move to an unreachable card', () => {
|
||
const s = game();
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
|
||
'ILLEGAL_MOVE',
|
||
);
|
||
});
|
||
|
||
it('runs out of Moves after six', () => {
|
||
const s = game();
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
turnOf(s, 0).movesRemaining = 0;
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
|
||
'NO_MOVES_REMAINING',
|
||
);
|
||
});
|
||
|
||
it('drops cars from the seated end, in order', () => {
|
||
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
const tray = placeTray(s, at(0, 2), [
|
||
{ type: 'boxcar', loaded: false },
|
||
{ type: 'hopper', loaded: true },
|
||
]);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
|
||
assert.ok(r.ok);
|
||
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
|
||
assert.equal(left.length, 1);
|
||
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
|
||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||
});
|
||
|
||
it('refuses to drop cars at the Office', () => {
|
||
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
||
const s = game();
|
||
const office = areaOf(s, 0).officeCoord;
|
||
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
|
||
'CANNOT_DROP_HERE',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Freight Agent operations (§6.3)', () => {
|
||
function withFacility(s: GameState): GridCoord {
|
||
const coord = at(-1, 0);
|
||
addCard(s, coord, {
|
||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight',
|
||
subtype: 'mineTipple',
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [],
|
||
inboundBox: [],
|
||
capacity: { outbound: 3, inbound: 0 },
|
||
menAtWork: [null, null, null],
|
||
// An empty hopper is SPOTTED, which is what the LABORERS need (§9.3). The Freight Agent
|
||
// does not: stocking works over a bare track too, and the tests below cover that.
|
||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||
laborers: 3,
|
||
porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
return coord;
|
||
}
|
||
|
||
it('stocks the green Outbound box from the Division Yard', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const before = s.yards.divisionYard.length;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.ok(r.ok);
|
||
assert.equal(s.yards.divisionYard.length, before - 1);
|
||
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
|
||
});
|
||
|
||
it('stocks the green box before the car is spotted (§6.3)', () => {
|
||
/**
|
||
* REPORTED FROM PLAY: "A freight agent should be able to load an outbound green box prior to
|
||
* having the car there that matches the load he's putting in there."
|
||
*
|
||
* §6.3 asks for nothing but a car in the Division Yard and room in the box. The empty-car
|
||
* requirement is §9.3's, on the Laborer who moves the load out of the box — see the test below.
|
||
* The engine used to hoist it forward onto stocking and so refused the ordinary play of having
|
||
* the cargo waiting while the car is still being switched in.
|
||
*/
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.industryTrack.cars = [];
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.ok(r.ok, 'staging cargo against a bare industry track is legal');
|
||
assert.equal(f.outboundBox.length, 1);
|
||
});
|
||
|
||
it('still refuses to stock with no matching loaded car in the Division Yard', () => {
|
||
// The requirement §6.3 DOES make: the load has to come from somewhere.
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'hopper' && c.loaded));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'NO_SUITABLE_CAR',
|
||
);
|
||
});
|
||
|
||
it('allows only one Freight Agent operation per Stage', () => {
|
||
// This is the bottleneck the whole economy rests on (card-reference.md §7).
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'OPTION_ALREADY_CHOSEN',
|
||
);
|
||
});
|
||
|
||
it('refuses to overfill the Outbound box', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.outboundBox = [
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
];
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'BOX_FULL',
|
||
);
|
||
});
|
||
|
||
it('refuses to load a facility that only unloads', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.allows = { outbound: false, inbound: true };
|
||
// Give it something to clear, so the Freight Agent option itself remains available.
|
||
f.inboundBox = [{ type: 'hopper', loaded: true }];
|
||
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.ok(chose.ok);
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'NO_SUCH_FACILITY',
|
||
);
|
||
});
|
||
|
||
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
|
||
// A player with no Facility cannot choose an option that has no possible follow-up.
|
||
const s = game();
|
||
assert.equal(
|
||
check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
|
||
'NO_SUCH_FACILITY',
|
||
);
|
||
});
|
||
|
||
it('makes the switch option unavailable with no train to switch', () => {
|
||
const s = game();
|
||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||
function facilityWithLoad(s: GameState): GridCoord {
|
||
const coord = at(-1, 0);
|
||
addCard(s, coord, {
|
||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight',
|
||
subtype: 'mineTipple',
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [],
|
||
inboundBox: [],
|
||
capacity: { outbound: 3, inbound: 0 },
|
||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||
laborers: 3,
|
||
porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
s.clock.phase = 'loadUnload';
|
||
return coord;
|
||
}
|
||
|
||
it('advances a load one box per Laborer action', () => {
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
|
||
assert.ok(r.ok);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
assert.equal(f.menAtWork![0], null);
|
||
assert.notEqual(f.menAtWork![1], null);
|
||
assert.equal(f.usedThisStage.laborers, 1);
|
||
});
|
||
|
||
it('spends each Laborer only once per Stage', () => {
|
||
// §9.1 — Laborers and Porters may be used once each in a Stage.
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.usedThisStage.laborers = f.laborers;
|
||
assert.equal(
|
||
check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
|
||
'RESOURCE_SPENT',
|
||
);
|
||
});
|
||
|
||
it('scores exactly one Revenue when a load comes off WORK', () => {
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||
|
||
assert.equal(s.players[0]!.revenue, 0);
|
||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
|
||
assert.ok(r.ok);
|
||
assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
|
||
assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
|
||
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
|
||
});
|
||
|
||
it('will not complete a load with no empty car spotted', () => {
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||
f.industryTrack.cars = [];
|
||
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
|
||
});
|
||
|
||
it('holds a staged load in the green box until a car is spotted (§9.3)', () => {
|
||
/**
|
||
* THE OTHER HALF OF THE STOCKING RULE. The Freight Agent may stage a load with nothing on the
|
||
* industry track (§6.3), but "Load the car" requires "*a load in the Green Loading Box AND an
|
||
* empty car of the required type on the industry's track*" — so the load simply waits in the
|
||
* box, which is a wait and not a jam: only a load on MEN | AT | WORK locks the track.
|
||
*/
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.menAtWork = [null, null, null];
|
||
f.outboundBox = [{ type: 'hopper', loaded: true }];
|
||
f.industryTrack.cars = [];
|
||
|
||
assert.equal(
|
||
check(s, 0, { type: 'laborer.startLoad', at: coord }),
|
||
'NO_EMPTY_CAR_SPOTTED',
|
||
'no car on the track — the Laborers cannot start it',
|
||
);
|
||
|
||
// A crew sets an empty hopper out, and the same staged load may now start down the sign.
|
||
f.industryTrack.cars = [{ type: 'hopper', loaded: false }];
|
||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), null);
|
||
const r = applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
|
||
assert.ok(r.ok);
|
||
assert.equal(f.outboundBox.length, 0, 'the load left the green box');
|
||
assert.notEqual(f.menAtWork![0], null, 'and is on MEN');
|
||
});
|
||
|
||
it('will not start a load onto a spotted car of the wrong type', () => {
|
||
// Strict matching: a hopper load cannot be swapped onto a tank (§9.3).
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.menAtWork = [null, null, null];
|
||
f.outboundBox = [{ type: 'hopper', loaded: true }];
|
||
f.industryTrack.cars = [{ type: 'tank', loaded: false }];
|
||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
|
||
});
|
||
|
||
it('unloads the car the player actually picked, not always the westmost one', () => {
|
||
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
|
||
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
|
||
// The reducer for `unloadBegan` used to re-derive the target with
|
||
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
|
||
// track order no matter which `carIndex` the intent actually named.
|
||
const s = game();
|
||
const coord = at(-1, 0);
|
||
addCard(s, coord, {
|
||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight',
|
||
subtype: 'freightHouse',
|
||
allows: { outbound: true, inbound: true },
|
||
outboundBox: [],
|
||
inboundBox: [],
|
||
capacity: { outbound: 1, inbound: 1 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: {
|
||
cars: [
|
||
{ type: 'boxcar', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'tank', loaded: true },
|
||
],
|
||
},
|
||
laborers: 3,
|
||
porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
s.clock.phase = 'loadUnload';
|
||
// An empty replacement of every type standing on the track, so `check` never refuses for a
|
||
// reason unrelated to what this test is about.
|
||
s.yards.divisionYard.push(
|
||
{ type: 'boxcar', loaded: false },
|
||
{ type: 'hopper', loaded: false },
|
||
{ type: 'tank', loaded: false },
|
||
);
|
||
|
||
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
|
||
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
|
||
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
|
||
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
|
||
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
|
||
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
|
||
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
|
||
});
|
||
|
||
it('takes four Laborer actions in total for one point', () => {
|
||
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
||
const s = game();
|
||
const coord = facilityWithLoad(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.laborers = 99; // isolate the pipeline from the per-Stage cap
|
||
|
||
let actions = 0;
|
||
for (let box = 0; box < 3; box++) {
|
||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
|
||
assert.ok(r.ok, `advance from box ${box}`);
|
||
actions++;
|
||
}
|
||
assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
|
||
assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the Superintendent clearance ruling (§8.1)', () => {
|
||
it('is refused when no decision is pending', () => {
|
||
const s = game();
|
||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
|
||
});
|
||
|
||
it('is accepted out of turn order, because it interrupts an automatic phase', () => {
|
||
const s = game();
|
||
s.clock.phase = 'mainline';
|
||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||
s.clock.pendingDecision = { 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: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight',
|
||
subtype: 'mineTipple',
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [],
|
||
inboundBox: [],
|
||
capacity: { outbound: 3, inbound: 0 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [] },
|
||
laborers: 3,
|
||
porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||
});
|
||
|
||
it('narrows to one option once a choice is made', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const kinds = new Set(legalActions(s, 0).map((i) => i.type));
|
||
assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
|
||
assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
|
||
assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
|
||
});
|
||
|
||
it('offers reachable destinations for a placed tray', () => {
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
|
||
assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
|
||
for (const m of moves) assert.equal(check(s, 0, m), null);
|
||
assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
|
||
});
|
||
|
||
it('offers nothing illegal after the game ends', () => {
|
||
const s = game();
|
||
s.status = 'finished';
|
||
assert.equal(legalActions(s, 0).length, 0);
|
||
});
|
||
});
|
||
|
||
describe('an industry handles every commodity its profile names (regression)', () => {
|
||
it('derives car types from the sheet rather than a hand-written map', () => {
|
||
// REGRESSION, and the reason tank cars were dropped ZERO times in 100 games.
|
||
//
|
||
// Two maps of industry -> car type existed. The engine's returned `carTypes[0]`, so the second
|
||
// commodity of a two-commodity industry was unreachable — a Power Plant burns coal OR oil and
|
||
// a Grocer's Warehouse receives dry goods OR perishables. The bot kept its own switch beside
|
||
// it, which had drifted further: it named `produceShed` and `oilRefinery`, neither of which is
|
||
// an industry, and omitted `freightHouse`, `refinery` and `packingSheds`, which are. Half the
|
||
// industries in the deck therefore never asked for a car at all.
|
||
//
|
||
// Asserting equality with the profile is the point: it fails if anyone writes a third map.
|
||
for (const p of INDUSTRY_PROFILES) {
|
||
const f = {
|
||
kind: 'freight',
|
||
subtype: p.kind,
|
||
allows: { outbound: p.baseOut > 0, inbound: p.baseIn > 0 },
|
||
outboundBox: [], inboundBox: [],
|
||
capacity: { outbound: p.baseOut, inbound: p.baseIn },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [] },
|
||
laborers: p.baseLoaders, porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
} as never;
|
||
assert.deepEqual(
|
||
[...facilityCarTypes(f)],
|
||
[...p.carTypes],
|
||
`${p.kind} handles ${p.carTypes.join('/')} on the sheet`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('names both commodities of the two-commodity industries', () => {
|
||
// Guards the specific collapse: `carTypes[0]` alone passes the test above only if someone also
|
||
// rewrites the profiles, but silently fails this one.
|
||
const twoCommodity = INDUSTRY_PROFILES.filter((p) => p.carTypes.length > 1).map((p) => p.kind);
|
||
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
|
||
});
|
||
});
|
||
|
||
describe('a Modifier only goes beside a host that can use it (regression)', () => {
|
||
it('offers a Waiting Area no square that is not beside a Passenger Facility', () => {
|
||
// REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a
|
||
// Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement
|
||
// check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area
|
||
// was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to
|
||
// whichever facility the scan happened to reach first. The player was shown three legal spots
|
||
// for a card that has one.
|
||
const s = game();
|
||
const area = areaOf(s, 0);
|
||
|
||
// A freight industry away from the Office, and nothing else nearby.
|
||
area.grid.set(coordKey({ row: -1, col: 4 }), {
|
||
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' },
|
||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||
facility: {
|
||
kind: 'freight', subtype: 'mineTipple',
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [] },
|
||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
} as never);
|
||
|
||
const waiting = [...s.cards.entries()].find(
|
||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
|
||
);
|
||
assert.ok(waiting, 'no Waiting Area card in the deck');
|
||
const [cardId] = waiting!;
|
||
s.decks.hands.set(0, [cardId]);
|
||
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('the Limits bound the district, and the nine spots reach round a Facility', () => {
|
||
/**
|
||
* A district whose Running Track has been extended one square east, so the sign stands at col 2
|
||
* and there is a siding hanging below the main to build on:
|
||
*
|
||
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2
|
||
* row -1: [curve ne]
|
||
*
|
||
* Returns the industry-ready square under the sign, at (-1, 2).
|
||
*/
|
||
function district(s: GameState): GridCoord {
|
||
const area = areaOf(s, 0);
|
||
const plain = (geometry: object): TrackCard => ({
|
||
geometry: geometry as TrackCard['geometry'],
|
||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||
});
|
||
const col = area.limitsEast.col;
|
||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
|
||
}));
|
||
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
|
||
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
|
||
}));
|
||
area.limitsEast = at(area.runningRow, col + 1);
|
||
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'draw';
|
||
return at(area.runningRow - 1, col + 1);
|
||
}
|
||
|
||
const trackInHand = (s: GameState, geometry: string): string => {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'track' && card.kind.geometry === geometry) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error(`no ${geometry} card`);
|
||
};
|
||
|
||
const industryOf = (s: GameState, facility: string): string => {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error(`no industry card: ${facility}`);
|
||
};
|
||
|
||
const modifierOf = (s: GameState, modifier: string): string => {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error(`no modifier card: ${modifier}`);
|
||
};
|
||
|
||
/** An industry standing on the board, so a Modifier has a host to hang off. */
|
||
const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => {
|
||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
|
||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||
facility: {
|
||
kind: 'freight', subtype: kind,
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [] },
|
||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
} as never);
|
||
};
|
||
|
||
it('accepts a siding in the sign\'s own column and refuses one past it', () => {
|
||
/**
|
||
* REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines
|
||
* Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound
|
||
* belongs to the district rather than to the one row the sign stands in — it used to guard the
|
||
* Running Track alone, and a siding could run east past a player's own sign, taking industries
|
||
* with it, onto track that §8.1 and §10 do not consider his territory at all.
|
||
*
|
||
* INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening
|
||
* district is signs at ±1 around the Office, so the strict reading would leave one buildable
|
||
* column and break §11.3's promise that both Secondary rows are usable from the first Stage.
|
||
*/
|
||
const s = game();
|
||
const under = district(s);
|
||
const cardId = trackInHand(s, 'straight');
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: under }),
|
||
null,
|
||
'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it',
|
||
);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||
'OUTSIDE_LIMITS',
|
||
'a siding was built outside the district it belongs to',
|
||
);
|
||
});
|
||
|
||
it('refuses an industry outside the Limits — a Facility carries track', () => {
|
||
// §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound
|
||
// is the same one, and said with the same code rather than left to read as NOT_CONNECTED.
|
||
const s = game();
|
||
const under = district(s);
|
||
const cardId = industryOf(s, 'mineTipple');
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||
'OUTSIDE_LIMITS',
|
||
);
|
||
});
|
||
|
||
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
|
||
/**
|
||
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
|
||
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
|
||
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
|
||
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
|
||
*/
|
||
const s = game();
|
||
const under = district(s);
|
||
buildFacility(s, 'packingSheds', under);
|
||
const cardId = modifierOf(s, 'iceHouse');
|
||
|
||
const offered = legalActions(s, 0)
|
||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||
.map((i) => coordKey((i as { placement: GridCoord }).placement));
|
||
|
||
// South-east of the host, and orthogonally adjacent to nothing at all.
|
||
const southEast = at(under.row - 1, under.col + 1);
|
||
assert.ok(
|
||
offered.includes(coordKey(southEast)),
|
||
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
|
||
);
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||
});
|
||
|
||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
||
/**
|
||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
||
*/
|
||
const s = game();
|
||
const under = district(s);
|
||
buildFacility(s, 'packingSheds', under);
|
||
const cardId = modifierOf(s, 'iceHouse');
|
||
const area = areaOf(s, 0);
|
||
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||
null,
|
||
'a Modifier beside a host at the limit was refused the spot outside it',
|
||
);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||
'ON_RUNNING_TRACK',
|
||
'a Modifier was allowed to stand in the row the Running Track grows along',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("a Modifier grants only what its host's flow can use", () => {
|
||
const withFacility = (kind: string, out: boolean, into: boolean) => ({
|
||
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
|
||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||
facility: {
|
||
kind: 'freight', subtype: kind,
|
||
allows: { outbound: out, inbound: into },
|
||
outboundBox: [], inboundBox: [],
|
||
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [] },
|
||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
});
|
||
|
||
/** Plays `modifier` next to the facility at `at`, and returns the facility and its card. */
|
||
const modifierBeside = (modifier: string, kind: string, out: boolean, into: boolean) => {
|
||
const s = game();
|
||
const area = areaOf(s, 0);
|
||
const at = { row: -1, col: 4 };
|
||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
|
||
const card = [...s.cards.entries()].find(
|
||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === modifier,
|
||
);
|
||
assert.ok(card, `no ${modifier} in the deck`);
|
||
s.decks.hands.set(0, [card![0]]);
|
||
const r = applyIntent(s, 0, {
|
||
type: 'card.play', cardId: card![0], placement: { row: at.row, col: at.col + 1 },
|
||
});
|
||
assert.ok(r.ok, `the ${modifier} should be playable beside a ${kind}`);
|
||
const host = area.grid.get(coordKey(at))!;
|
||
return { f: host.facility!, card: host };
|
||
};
|
||
|
||
const iceHouseBeside = (kind: string, out: boolean, into: boolean) =>
|
||
modifierBeside('iceHouse', kind, out, into).f;
|
||
|
||
it('gives a Packing Sheds the outbound slot its card prints', () => {
|
||
// The host ships, so every printed grant lands. This is the control for the case below.
|
||
const f = iceHouseBeside('packingSheds', true, false);
|
||
assert.equal(f.capacity.outbound, 2, 'the outbound slot did not arrive');
|
||
assert.equal(f.laborers, 2, 'the laborer did not arrive');
|
||
});
|
||
|
||
it("drops the outbound slot on a Grocer's Warehouse, which only receives", () => {
|
||
// REPORTED as "the Ice House added the laborer but not the outbound slot" — and it is not a bug.
|
||
// A Grocer's is flow:'inbound', so `allows.outbound` is false and the capacity had nowhere to
|
||
// go: it was raised anyway, on a direction that can never be drawn or stocked.
|
||
const f = iceHouseBeside('grocersWarehouse', false, true);
|
||
assert.equal(f.laborers, 2, 'the laborer should still arrive — Laborers have no direction');
|
||
assert.equal(f.capacity.outbound, 0, 'outbound capacity was credited to a facility that cannot ship');
|
||
assert.equal(f.capacity.inbound, 1, 'the modifier must not silently move the grant to the other direction');
|
||
});
|
||
|
||
it('gives a Truck Dock a second red box and not one more car length', () => {
|
||
/**
|
||
* The case as it was described: "if I have a Grocer's Warehouse that receives, it has one red
|
||
* box. If it has a Truck Dock next to it, that should give it a second red box. There is no
|
||
* change to the number of cars that are allowed to be dropped here."
|
||
*
|
||
* Both halves matter, and the second half is the one that used to be wrong: the Truck Dock's
|
||
* inbound grant lengthened the industry track along with the box, because the track's length WAS
|
||
* the box count. The boxes are how much work the industry can hold; the card holds four cars
|
||
* either way.
|
||
*/
|
||
const { f, card } = modifierBeside('truckDock', 'grocersWarehouse', true, true);
|
||
assert.equal(f.capacity.inbound, 2, 'the Truck Dock did not add the red box it prints');
|
||
assert.equal(f.capacity.outbound, 1, 'the Truck Dock added a green box, which it does not print');
|
||
|
||
assert.equal(
|
||
spaceOn(card as never), MAX_CONSIST,
|
||
'a Modifier changed how many cars may be set out at the industry it stands beside',
|
||
);
|
||
});
|
||
|
||
it('does not give a Passenger Facility an industry track by way of a Modifier', () => {
|
||
// FOUND by rendering 12,000 frames of real games rather than a fixture: a Waiting Area beside the
|
||
// Office lengthened `industryTrack`, and the board then drew the Depot a square to spot a car on.
|
||
// A Waiting Area's "+1" is a passenger slot; passengers board off the platform.
|
||
//
|
||
// The length field is gone now — an industry track holds four cars like any other card, so there
|
||
// was never a number for a Modifier to raise. What is still worth pinning down is the property
|
||
// that bug was really about: an Office keeps its cars in `standing` and has no industry track for
|
||
// a Modifier to hand it, no matter what the Modifier prints.
|
||
const s = game();
|
||
const area = areaOf(s, 0);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
|
||
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
|
||
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
|
||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||
const office = officeCard.facility!;
|
||
office.allows = { outbound: true, inbound: true };
|
||
|
||
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(
|
||
carsOn(officeCard), officeCard.standing,
|
||
'a Modifier gave a Passenger Facility an industry track to spot cars on',
|
||
);
|
||
});
|
||
|
||
it('suppresses a grant the host cannot use — and gives it back when it can', () => {
|
||
/**
|
||
* REPORTED from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot only
|
||
* shows one green / one red box. I expected two, because Restaurant increases outbound by one."
|
||
*
|
||
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1
|
||
* outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right,
|
||
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
|
||
* only the difference between two tiers and knew nothing about what had been discarded.
|
||
*
|
||
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
|
||
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
|
||
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
|
||
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
|
||
* and tests nothing about the upgrade path.
|
||
*/
|
||
const s = game();
|
||
const area = areaOf(s, 0);
|
||
const office = area.grid.get(coordKey(area.officeCoord))!;
|
||
assert.equal(office.facility!.allows.outbound, false, 'a Whistle Post is not a Passenger Facility');
|
||
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const restaurant = [...s.cards.entries()].find(
|
||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
|
||
)!;
|
||
s.decks.hands.set(0, [restaurant[0]]);
|
||
assert.ok(applyIntent(s, 0, {
|
||
type: 'card.play',
|
||
cardId: restaurant[0],
|
||
placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col },
|
||
}).ok, 'the Restaurant could not be played beside the Office');
|
||
|
||
const f = office.facility!;
|
||
assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have');
|
||
assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed');
|
||
|
||
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Whistle'));
|
||
assert.ok(fv, 'the Office is missing from the panel');
|
||
assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported');
|
||
assert.match(fv!.suppressed[0]!, /Restaurant/);
|
||
assert.deepEqual(fv!.modifiers, ['Restaurant'], 'the modifier is not listed against its host');
|
||
|
||
// Upgrading makes it a Passenger Facility, and the slot the Restaurant always printed arrives.
|
||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
|
||
assert.equal(f.allows.outbound, true);
|
||
assert.equal(
|
||
f.capacity.outbound,
|
||
officeProfile('depot').passengerOut + 1,
|
||
"the Restaurant's slot did not come back when the Office could finally use it",
|
||
);
|
||
|
||
// And it is paid ONCE: a second upgrade must not grant it again.
|
||
const afterDepot = f.capacity.outbound;
|
||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'depot', to: 'station' });
|
||
assert.equal(
|
||
f.capacity.outbound,
|
||
afterDepot + (officeProfile('station').passengerOut - officeProfile('depot').passengerOut),
|
||
'the Restaurant was paid a second time on the next upgrade',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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] [lim] <- Running Track
|
||
* row -1: [curve ne] [siding square]
|
||
*
|
||
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
|
||
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
|
||
* rather than written straight into the grid. Without that the siding square would be outside the
|
||
* district's own Limits, which is no longer a place track may go.
|
||
*/
|
||
function withSiding(s: GameState): GridCoord {
|
||
const area = areaOf(s, 0);
|
||
const plain = (geometry: object): TrackCard => ({
|
||
geometry: geometry as TrackCard['geometry'],
|
||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||
});
|
||
const col = area.limitsEast.col;
|
||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
|
||
}));
|
||
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
|
||
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
|
||
}));
|
||
area.limitsEast = at(area.runningRow, col + 1);
|
||
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'draw';
|
||
return at(area.runningRow - 1, col + 1);
|
||
}
|
||
|
||
/** Puts an industry card of `kind` in hand and returns its id. */
|
||
function industryInHand(s: GameState, kind: string): string {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'freightFacility' && card.kind.facility === kind) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error(`no industry card: ${kind}`);
|
||
}
|
||
|
||
/** Puts an industry straight onto the board, bypassing the hand. */
|
||
function build(s: GameState, kind: string, coord: GridCoord): void {
|
||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||
geometry: { kind: 'facility', facility: kind as never },
|
||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||
});
|
||
}
|
||
|
||
it('refuses an industry on the Running Track', () => {
|
||
// The sheet's "Placed" column, identical for all six: "Straight, Stub (not on Running Track)".
|
||
// An industry has to hang off a siding — which is what makes building one worth the cards.
|
||
const s = game();
|
||
withSiding(s);
|
||
const area = areaOf(s, 0);
|
||
const cardId = industryInHand(s, 'mineTipple');
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, area.limitsEast.col) }),
|
||
'ON_RUNNING_TRACK',
|
||
);
|
||
});
|
||
|
||
it('accepts the same industry on a stub off the Running Track', () => {
|
||
const s = game();
|
||
const siding = withSiding(s);
|
||
const cardId = industryInHand(s, 'mineTipple');
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
|
||
});
|
||
|
||
it('refuses a second industry of the same kind', () => {
|
||
// The sheet states this in the Freight House row, which lists Freight House among its own
|
||
// lockouts. It is a general rule and applies to every kind.
|
||
const s = game();
|
||
const siding = withSiding(s);
|
||
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
|
||
const cardId = industryInHand(s, 'mineTipple');
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), 'FACILITY_LOCKED');
|
||
});
|
||
|
||
it('refuses a producer once its consumer is built, and the reverse', () => {
|
||
// Every locked pair is a producer and the consumer of the same commodity: Mine Tipple makes the
|
||
// coal a Power Plant burns, the Refinery makes the oil it also burns, Packing Sheds fill the
|
||
// reefers a Grocer's Warehouse empties. One end of a chain or the other, never both.
|
||
const pairs: [string, string][] = [
|
||
['mineTipple', 'powerPlant'],
|
||
['refinery', 'powerPlant'],
|
||
['packingSheds', 'grocersWarehouse'],
|
||
['freightHouse', 'grocersWarehouse'],
|
||
];
|
||
for (const [a, b] of pairs) {
|
||
for (const [built, wanted] of [[a, b], [b, a]] as [string, string][]) {
|
||
const s = game();
|
||
const siding = withSiding(s);
|
||
build(s, built, at(areaOf(s, 0).runningRow - 1, siding.col + 1));
|
||
const cardId = industryInHand(s, wanted);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId, placement: siding }),
|
||
'FACILITY_LOCKED',
|
||
`${wanted} should be locked out by ${built}`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('allows industries that share no commodity', () => {
|
||
// Mine Tipple ships coal and Packing Sheds ship produce; neither consumes what the other makes,
|
||
// so both may stand in one district. The lockouts must not be a blanket ban.
|
||
const s = game();
|
||
const siding = withSiding(s);
|
||
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
|
||
const cardId = industryInHand(s, 'packingSheds');
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
|
||
});
|
||
|
||
it('matches the lockouts on the sheet exactly', () => {
|
||
// Transcribed from the "Lockouts" column, so a change to the catalogue has to be deliberate.
|
||
const expected: Record<string, string[]> = {
|
||
freightHouse: ['grocersWarehouse'],
|
||
mineTipple: ['powerPlant'],
|
||
refinery: ['powerPlant'],
|
||
powerPlant: ['mineTipple', 'refinery'],
|
||
packingSheds: ['grocersWarehouse'],
|
||
grocersWarehouse: ['packingSheds', 'freightHouse'],
|
||
};
|
||
for (const p of INDUSTRY_PROFILES) {
|
||
assert.deepEqual([...p.lockouts].sort(), [...expected[p.kind]!].sort(), p.kind);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendix A)', () => {
|
||
const car = (type: string, loaded = false): { type: string; loaded: boolean } => ({ type, loaded });
|
||
|
||
it('holds an engine and up to four cars', () => {
|
||
// "Up to five tokens: an engine, and up to four cars, one of which could be a caboose." The
|
||
// engine is not one of the consist entries — §8.2 counts the consist as Rolling Stock and the
|
||
// four-car limit is a limit on cars, not on the locomotive hauling them.
|
||
const s = game();
|
||
const id = placeTray(s, at(0, 0), [car('boxcar'), car('hopper'), car('tank'), car('caboose')] as never);
|
||
assert.equal(s.trays.get(id)!.consist.length, 4);
|
||
});
|
||
|
||
it('couples to the nose going forward and to the tail backing up', () => {
|
||
// Appendix A: "Engines also have couplers on the front end... a train can pick up two cars and
|
||
// add them to the Crew Tray in order that they were in, pushing them into the Facility." Which
|
||
// end they land on is the whole point of a run-around: it decides which car comes off next.
|
||
const build = (toNose: boolean): string[] => {
|
||
const s = game();
|
||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
||
reduce(s, { type: 'carsCoupled', 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;
|
||
turnOf(s, 0).option = 'switch';
|
||
// One car ahead of the engine and one behind: at most one may come off either end.
|
||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null);
|
||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null);
|
||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2, fromNose: true }), 'CONSIST_EMPTY');
|
||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('only the train being made up may take cars (§7)', () => {
|
||
/** A tray with a short consist, placed wherever the test needs it. */
|
||
function trayAt(s: GameState, position: CrewTray['position']): void {
|
||
s.clock.phase = 'newTrain';
|
||
s.trays.set('t', {
|
||
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||
consist: [], direction: 'west', position, movesUsed: 0,
|
||
});
|
||
}
|
||
|
||
const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true };
|
||
|
||
it('accepts a car for a train standing at a Division Point', () => {
|
||
const s = game();
|
||
trayAt(s, { at: 'divisionPoint', side: 'east' });
|
||
assert.equal(check(s, 0, addHopper), null);
|
||
});
|
||
|
||
it('refuses to load a train standing in the district', () => {
|
||
// The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division
|
||
// Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding
|
||
// in your own district: cars appeared on a train nobody was making up.
|
||
const s = game();
|
||
trayAt(s, { at: 'grid', seat: 0, coord: at(0, 1) });
|
||
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||
});
|
||
|
||
it('refuses to load a train out on the Mainline', () => {
|
||
const s = game();
|
||
trayAt(s, { at: 'mainline', index: 1 });
|
||
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||
});
|
||
|
||
it('refuses to load a train whose consist is already what its card calls for', () => {
|
||
// Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing
|
||
// at a Division Point — otherwise the phase and `check` disagree about who is being filled.
|
||
const s = game();
|
||
trayAt(s, { at: 'divisionPoint', side: 'east' });
|
||
const tray = s.trays.get('t')!;
|
||
tray.consist = [
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'caboose', loaded: true },
|
||
];
|
||
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('an Office upgrade keeps what Modifiers added (§9)', () => {
|
||
it('does not wipe a Restaurant when the Depot becomes a Station', () => {
|
||
// The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant
|
||
// played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next
|
||
// upgrade. The card had been spent and the effect vanished with no message.
|
||
const s = game();
|
||
const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
|
||
|
||
const play = (tier: 'depot' | 'station'): void => {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
|
||
s.decks.hands.set(0, [id]);
|
||
turnOf(s, 0).option = null;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
assert.ok(r.ok, `${tier} upgrade should apply`);
|
||
return;
|
||
}
|
||
throw new Error(`no ${tier} card`);
|
||
};
|
||
|
||
play('depot');
|
||
const base = { out: office().capacity.outbound, porters: office().porters };
|
||
|
||
// A Restaurant beside the Office: +1 passenger out, +1 porter.
|
||
office().capacity.outbound += 1;
|
||
office().porters += 1;
|
||
|
||
play('station');
|
||
|
||
const station = { out: 2, porters: 2 }; // Station's printed numbers
|
||
assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade');
|
||
assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade');
|
||
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the engine is drawn pointing east or west, whatever track it is standing on', () => {
|
||
/**
|
||
* Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept
|
||
* that ▲ after it departed onto the Division — where there is no north or south at all — because
|
||
* nothing resets `facing` when a train leaves a district. A Division runs east and west, and a
|
||
* player reads a train the way a railroader does: which END the engine is on.
|
||
*
|
||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||
*/
|
||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||
|
||
it('carries the east-west sense across north-south track', () => {
|
||
const s = game();
|
||
const id = placeTray(s, at(0, 0));
|
||
const tray = s.trays.get(id)!;
|
||
|
||
reduce(s, moved(id, 'w'));
|
||
assert.equal(railFacingOf(tray), 'w');
|
||
|
||
// Onto a curve, and away up a spur. The port is north; the engine is still on the west end.
|
||
reduce(s, moved(id, 'n'));
|
||
assert.equal(tray.facing, 'n', 'the movement port must still be the real one');
|
||
assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end');
|
||
});
|
||
|
||
it('follows the engine around 180° of curves, because that really is a turn', () => {
|
||
// Forward through two curves — west port to north, north to east — and the engine genuinely
|
||
// does come out pointing the other way. Held values are held, not frozen.
|
||
const s = game();
|
||
const id = placeTray(s, at(0, 0));
|
||
const tray = s.trays.get(id)!;
|
||
for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f));
|
||
assert.equal(railFacingOf(tray), 'e');
|
||
});
|
||
|
||
it('falls back to the direction of the run for a tray that has not moved yet', () => {
|
||
// A tray placed straight onto the board has no history to carry, and `direction` is the only
|
||
// east-west fact about it.
|
||
const s = game();
|
||
const tray = s.trays.get(placeTray(s, at(0, 0)))!;
|
||
assert.equal(tray.railFacing, undefined);
|
||
assert.equal(railFacingOf(tray), 'e', 'placed running east');
|
||
tray.direction = 'west';
|
||
assert.equal(railFacingOf(tray), 'w');
|
||
});
|
||
|
||
it('never reports north or south to the board', () => {
|
||
// The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a
|
||
// north-south spur used to break.
|
||
const s = game();
|
||
const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never);
|
||
addCard(s, at(0, 0), straight());
|
||
reduce(s, moved(id, 's'));
|
||
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
|
||
const train = cell?.trains[0];
|
||
assert.ok(train, 'the train should be on the card');
|
||
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a train that rounds a curve points where the curve took it', () => {
|
||
/**
|
||
* REPORTED from play: "a train reversed into a siding and the display of the cars was reversed."
|
||
*
|
||
* `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` —
|
||
* which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a
|
||
* north-west curve through its west port and the far end is north, not east. So a train that
|
||
* rounded a curve was left facing a port the card does not have.
|
||
*
|
||
* Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the
|
||
* card lacks yields nothing, so the crew could only ever back out the way it came. Display: the
|
||
* east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine
|
||
* west could leave the board still drawing it east — the consist mirrored, which is what was seen.
|
||
*/
|
||
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: null,
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
|
||
/**
|
||
* Runs a crew one Move and reports where the engine ended up pointing.
|
||
*
|
||
* The card it starts ON matters as much as the one it moves to: a crew facing north or south has
|
||
* to be standing on something with that port, and only a curve has one.
|
||
*/
|
||
const roundIt = (
|
||
from: GridCoord,
|
||
to: GridCoord,
|
||
origin: TrackCard,
|
||
dest: TrackCard,
|
||
start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' },
|
||
reverse = false,
|
||
): { facing?: string; drawn: string } => {
|
||
const s = game();
|
||
addCard(s, from, origin);
|
||
addCard(s, to, dest);
|
||
const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never);
|
||
const tray = s.trays.get(id)!;
|
||
tray.facing = start.facing;
|
||
tray.railFacing = start.railFacing;
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
|
||
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
|
||
return { facing: tray.facing, drawn: railFacingOf(tray) };
|
||
};
|
||
|
||
it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => {
|
||
// East along the running track into a curve that turns north. The far end is NORTH.
|
||
const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' });
|
||
assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have');
|
||
});
|
||
|
||
it('does not strand the crew on the curve it just rounded', () => {
|
||
// The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without
|
||
// that port yields nothing at all — so the only move left was backing out the way it came.
|
||
const s = game();
|
||
addCard(s, at(0, 0), straight());
|
||
addCard(s, at(0, 1), curve('nw')); // joins north and west
|
||
addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues
|
||
addCard(s, at(1, 2), straight());
|
||
const id = placeTray(s, at(0, 0), []);
|
||
s.trays.get(id)!.facing = 'e';
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
|
||
const { to } = movesFor(s, 0, id);
|
||
assert.ok(
|
||
to.some((c) => c.row === 1 && c.col === 1),
|
||
'the crew cannot carry on round the curve — it can only back out the way it came',
|
||
);
|
||
});
|
||
|
||
it('can still back out of the curve it just rounded', () => {
|
||
/**
|
||
* THE OTHER HALF OF THE SAME MISTAKE, and the one that bites hardest.
|
||
*
|
||
* Forward exits by `facing`; reverse used to exit by `opposite(facing)` — which, again, is the
|
||
* other end of a STRAIGHT and of nothing else. A crew standing on a north-west curve facing
|
||
* north reverses out through WEST, the way it came; `opposite('n')` is south, a port the curve
|
||
* does not have, so backing up found nothing.
|
||
*
|
||
* Fixing only the forward case moved the problem rather than solving it: before, a crew that
|
||
* rounded a curve could only back out; after, it could only carry on. A crew must be able to do
|
||
* both, which is what makes a siding reachable and therefore what makes setting out a cut
|
||
* possible at all.
|
||
*/
|
||
const s = game();
|
||
addCard(s, at(0, 0), straight());
|
||
addCard(s, at(0, 1), curve('nw')); // joins north and west
|
||
addCard(s, at(1, 1), curve('se')); // the spur carries on north
|
||
const id = placeTray(s, at(0, 0), []);
|
||
s.trays.get(id)!.facing = 'e';
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
|
||
const { to } = movesFor(s, 0, id);
|
||
assert.ok(
|
||
to.some((c) => c.row === 0 && c.col === 0),
|
||
'the crew cannot back off the curve onto the track it came from',
|
||
);
|
||
assert.ok(
|
||
to.some((c) => c.row === 1 && c.col === 1),
|
||
'the crew cannot carry on round the curve either',
|
||
);
|
||
// And backing up really does go there, rather than merely being offered.
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||
});
|
||
|
||
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
|
||
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
|
||
// board has to say so — this is the mirrored consist that was reported.
|
||
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' });
|
||
assert.equal(out.facing, 'w', 'the curve turned the engine west');
|
||
assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east');
|
||
});
|
||
|
||
it('still points back the way it came when it BACKS round a curve', () => {
|
||
// Backing up does not turn a train around, whatever the track does underneath it: the engine
|
||
// trails, pointing out through the port the train came in by. That is geometry-independent and
|
||
// was already right — this is here so the fix above cannot quietly change it.
|
||
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true);
|
||
assert.equal(out.facing, 'n', 'backing up turned the train around');
|
||
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the switching job a player actually does: put a car in a siding, take an empty to an industry', () => {
|
||
/**
|
||
* REPORTED from play: "I'm trying to switch to get an empty car to an industry, but I can't drop
|
||
* any cars on the siding first."
|
||
*
|
||
* Setting out a cut needs no Move and is refused almost nowhere — but you can only set out where
|
||
* the train IS, and the Office square itself is barred (§A.4). So "I cannot drop" is nearly always
|
||
* "I cannot GET there", and getting there means leaving the Running Track through a turnout and a
|
||
* curve. That is exactly where `opposite(facing)` was wrong in both directions.
|
||
*
|
||
* This walks the whole errand rather than any one rule, because each individual rule passed its
|
||
* own test while the errand was impossible.
|
||
*/
|
||
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||
});
|
||
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||
});
|
||
|
||
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
||
const s = game();
|
||
// Running Track east from the Office, with a turnout diverging north onto a short siding.
|
||
addCard(s, at(0, 0), straight());
|
||
addCard(s, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' }));
|
||
addCard(s, at(0, 2), straight());
|
||
addCard(s, at(1, 1), curve('se')); // up off the turnout, then east along the siding
|
||
addCard(s, at(1, 2), straight());
|
||
|
||
const id = placeTray(s, at(0, 0), [
|
||
{ type: 'boxcar', loaded: false },
|
||
{ type: 'tank', loaded: false },
|
||
] as never);
|
||
s.trays.get(id)!.facing = 'e';
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
|
||
// Out of the Office and up onto the siding. A turnout is not somewhere a train may stop, so the
|
||
// first square it can finish on is the curve beyond it.
|
||
const reachable = movesFor(s, 0, id).to;
|
||
assert.ok(
|
||
reachable.some((c) => c.row === 1 && c.col === 1),
|
||
'the siding is not reachable from the Running Track at all',
|
||
);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 1), reverse: false }).ok);
|
||
|
||
// Set the cut out. This is the step that was reported as impossible.
|
||
const drop = applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 });
|
||
assert.ok(drop.ok, `setting out on the siding was refused: ${drop.ok ? '' : drop.code}`);
|
||
assert.deepEqual(
|
||
areaOf(s, 0).grid.get(coordKey(at(1, 1)))!.standing.map((c) => c.type),
|
||
['tank'],
|
||
'the car did not end up on the siding',
|
||
);
|
||
assert.equal(s.trays.get(id)!.consist.length, 1, 'the train still has the car it set out');
|
||
|
||
// And it can get back off the siding again, carrying the empty on toward an industry.
|
||
const after = movesFor(s, 0, id).to;
|
||
assert.ok(
|
||
after.some((c) => c.row === 0 && c.col === 0) || after.some((c) => c.row === 0 && c.col === 2),
|
||
'the crew is stranded on the siding — it cannot return to the Running Track',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* The v0.4.9d playtest, three reports with two causes.
|
||
*
|
||
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
|
||
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
|
||
* trains in a station: the select button does not work, regardless of which you pick it is always
|
||
* one train, not the other" — the porter intents carrying no tray.
|
||
*/
|
||
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
|
||
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
|
||
|
||
/** An Office that can work passengers, with someone waiting and Porters to hand. */
|
||
function platform(s: GameState): void {
|
||
const f = office(s);
|
||
f.allows = { outbound: true, inbound: true };
|
||
f.porters = 4;
|
||
f.capacity = { outbound: 2, inbound: 2 };
|
||
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||
s.yards.divisionYard.push({ type: 'coach', loaded: false });
|
||
s.clock.phase = 'loadUnload';
|
||
s.clock.currentActor = 0;
|
||
}
|
||
|
||
/** A tray standing on an A/D track at the Office. */
|
||
function atOffice(s: GameState, consist: CrewTray['consist']): string {
|
||
const id = s.freeTrays.pop()!;
|
||
s.trays.set(id, {
|
||
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
|
||
direction: 'east', position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
|
||
});
|
||
areaOf(s, 0).adOccupancy.push(id);
|
||
return id;
|
||
}
|
||
|
||
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
|
||
function freightHouse(s: GameState, coord: GridCoord): void {
|
||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight', subtype: 'freightHouse',
|
||
allows: { outbound: true, inbound: true },
|
||
outboundBox: [{ type: 'boxcar', loaded: true }],
|
||
inboundBox: [],
|
||
capacity: { outbound: 1, inbound: 1 },
|
||
menAtWork: [null, null, null],
|
||
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
|
||
laborers: 9, porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [], enhancements: [],
|
||
} as TrackCard);
|
||
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
|
||
s.clock.phase = 'loadUnload';
|
||
s.clock.currentActor = 0;
|
||
}
|
||
|
||
/** Walk a staged load all the way onto the spotted car. */
|
||
function finishLoad(s: GameState, coord: GridCoord): void {
|
||
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
|
||
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
|
||
}
|
||
|
||
it('refuses to unload the boxcar the Freight House just loaded', () => {
|
||
const s = game();
|
||
const coord = at(-1, 0);
|
||
freightHouse(s, coord);
|
||
finishLoad(s, coord);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
|
||
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
|
||
assert.equal(
|
||
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
|
||
'LOADED_IN_THIS_DISTRICT',
|
||
);
|
||
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
|
||
assert.ok(
|
||
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
|
||
'the unload was still offered',
|
||
);
|
||
});
|
||
|
||
it('unloads a load that came from somewhere else', () => {
|
||
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
|
||
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
|
||
// traffic a district lives on.
|
||
const s = game();
|
||
const coord = at(-1, 0);
|
||
freightHouse(s, coord);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
|
||
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
|
||
});
|
||
|
||
it('refuses to detrain the passengers this Office just put aboard', () => {
|
||
const s = game();
|
||
platform(s);
|
||
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
|
||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
|
||
const coach = s.trays.get(tray)!.consist[0]!;
|
||
assert.equal(coach.loaded, true, 'nobody boarded');
|
||
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
|
||
assert.equal(
|
||
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
|
||
'LOADED_IN_THIS_DISTRICT',
|
||
);
|
||
});
|
||
|
||
it('detrains passengers who boarded somewhere else', () => {
|
||
const s = game();
|
||
platform(s);
|
||
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
|
||
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
|
||
});
|
||
|
||
it('takes the origin stamp off a coach that reaches the red box', () => {
|
||
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
|
||
// heads back to a yard from there; carrying the stamp on would poison the common supply.
|
||
const s = game();
|
||
platform(s);
|
||
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
|
||
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
|
||
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
|
||
});
|
||
});
|
||
|
||
describe('two trains in one station are told apart (v0.4.9e)', () => {
|
||
function twoAtOffice(s: GameState): [string, string] {
|
||
const area = areaOf(s, 0);
|
||
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
|
||
f.allows = { outbound: true, inbound: true };
|
||
f.porters = 4;
|
||
f.capacity = { outbound: 2, inbound: 2 };
|
||
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
|
||
s.clock.phase = 'loadUnload';
|
||
s.clock.currentActor = 0;
|
||
const ids: string[] = [];
|
||
for (let n = 0; n < 2; n++) {
|
||
const id = s.freeTrays.pop()!;
|
||
s.trays.set(id, {
|
||
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||
consist: [{ type: 'coach', loaded: false }],
|
||
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||
});
|
||
area.adOccupancy.push(id);
|
||
ids.push(id);
|
||
}
|
||
return [ids[0]!, ids[1]!];
|
||
}
|
||
|
||
it('offers boarding on each train, not once for the platform', () => {
|
||
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
|
||
// which you pick, it is always one train, not the other." There was one button, because the
|
||
// intent carried no train at all.
|
||
const s = game();
|
||
const [a, b] = twoAtOffice(s);
|
||
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
|
||
assert.deepEqual(
|
||
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
|
||
[a, b].sort(),
|
||
'both trains at the platform must be offered',
|
||
);
|
||
});
|
||
|
||
it('boards the train the player named, not the first on the A/D tracks', () => {
|
||
const s = game();
|
||
const [a, b] = twoAtOffice(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
|
||
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
|
||
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
|
||
});
|
||
|
||
it('still works for an intent that names no train, so old saves replay', () => {
|
||
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
|
||
// record every save and every undo replays against.
|
||
const s = game();
|
||
const [a] = twoAtOffice(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
|
||
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
|
||
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
|
||
* drive right through them."
|
||
*
|
||
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
|
||
* engine can see always couple — which means cars a train can drive through are cars the engine does
|
||
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
|
||
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
|
||
* so if the case is found later it is somewhere none of them cover.
|
||
*/
|
||
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
|
||
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
|
||
const caboose = () => ({ type: 'caboose' as const, loaded: false });
|
||
|
||
/** A Freight House card, with cars spotted on its industry track. */
|
||
function industry(cars: TrackCard['standing']): TrackCard {
|
||
return {
|
||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||
baseOperationalRail: true, standing: [], standingWest: 0,
|
||
facility: {
|
||
kind: 'freight', subtype: 'freightHouse',
|
||
allows: { outbound: true, inbound: true },
|
||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
|
||
menAtWork: [null, null, null], industryTrack: { cars: [...cars] },
|
||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [], enhancements: [],
|
||
} as TrackCard;
|
||
}
|
||
|
||
const empty = (s: GameState, ...coords: GridCoord[]): void => {
|
||
for (const c of coords) {
|
||
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
|
||
}
|
||
};
|
||
|
||
it('takes a cut standing on plain track on the way to the caboose', () => {
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
|
||
addCard(s, at(0, 0), straight([caboose()]));
|
||
const id = placeTray(s, at(0, 2));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||
empty(s, at(0, 1), at(0, 0));
|
||
});
|
||
|
||
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
|
||
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
|
||
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
|
||
// where the two could have come apart.
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
|
||
addCard(s, at(0, 0), straight([caboose()]));
|
||
const id = placeTray(s, at(0, 2));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||
empty(s, at(0, 1), at(0, 0));
|
||
});
|
||
|
||
it("takes the train's own cut off the square it is standing on as well", () => {
|
||
const s = game();
|
||
addCard(s, at(0, 1), straight());
|
||
addCard(s, at(0, 0), straight([caboose()]));
|
||
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||
empty(s, at(0, 1), at(0, 0));
|
||
});
|
||
});
|