The v0.7.1 bugfix set ported to the playtest line: Gitea#8, #10, #9 and #2. Reasoning, and what differs from main, in CHANGELOG.md. - Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with. A caboose carries the crew, not freight, so it is never a load. - Gitea#10: a dialog when the Day rolls over, carrying the standings, the Days left and your Revenue against the target with the pace. Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game ends. - Gitea#9, superseding Gitea#6 from v0.4.9f: a Timetabled train may be discarded, an Extra may not. This line takes the plain rule — main has it as a New Game setting. - Gitea#2: the shortage stays, by Jesse's ruling. A blocked platform now gives its reason instead of simply having no button. Three things are written for this line rather than ported: the Timetabled discard has no setting and no URL parameter, the Day-end dialog reads f.objective (this line's per-player target) where main reads its combined victory dials, and Frame gained the viewer index the dialog needs to mark your row. 636 tests pass; no published replay was retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
2349 lines
106 KiB
TypeScript
2349 lines
106 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 { pump } from '../src/engine/advance.ts';
|
||
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } from '../src/engine/content.ts';
|
||
import type { Intent } from '../src/engine/intents.ts';
|
||
import { legalActions } from '../src/engine/legal.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||
import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
|
||
import { cardDescription, snapshot } from '../src/sim/view.ts';
|
||
|
||
const config: GameConfig = {
|
||
mode: 'solitaire',
|
||
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');
|
||
});
|
||
|
||
describe('which train cards may be discarded (Gitea#9, superseding Gitea#6)', () => {
|
||
/**
|
||
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
|
||
* it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either
|
||
* play or discard. If someone else wants to pick it up, they are more than able to. The reason:
|
||
* I don't want, if you decide to play a game longer than five days, to decide that maybe there
|
||
* are too many trains, the stations are jammed, and the railroad doesn't need any more."
|
||
*
|
||
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
|
||
* it cannot be what jams it — and whether the Timetabled half applies is a New Game setting,
|
||
* because the reasoning is about long games and a five-Day game may want Gitea#6's pressure.
|
||
*
|
||
* Note there is still no FORCING mechanism, and deliberately so: the corner is what the two
|
||
* existing rules produce together whenever the setting is off.
|
||
*/
|
||
const handOf = (s: GameState, kinds: string[]): string[] => {
|
||
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
||
// depend on what the shuffle happened to deal.
|
||
const picked: string[] = [];
|
||
for (const want of kinds) {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind !== want || picked.includes(id)) continue;
|
||
picked.push(id);
|
||
break;
|
||
}
|
||
}
|
||
assert.equal(picked.length, kinds.length, 'the catalogue is missing a card this test needs');
|
||
s.decks.hands.set(0, picked);
|
||
return picked;
|
||
};
|
||
|
||
/** The same game with the setting turned off — Gitea#6's rule, still reachable. */
|
||
const strictGame = (): GameState =>
|
||
createGame({
|
||
id: 'g',
|
||
seed: 77,
|
||
config: { ...config, houseRules: { ...(config.houseRules ?? {}), discardTimetabled: false } },
|
||
playerNames: ['Jesse'],
|
||
});
|
||
|
||
it('lets a Timetabled train be discarded, and still refuses an Extra', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||
null,
|
||
'Gitea#9 allows this and it was refused',
|
||
);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||
'TRAINS_ARE_NEVER_DISCARDED',
|
||
'an Extra never joins the timetable, so Gitea#9 does not reach it',
|
||
);
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||
});
|
||
|
||
it('puts the discarded train where a rival can pick it up', () => {
|
||
// The other half of the ruling — "if someone else wants to pick it up, they are more than able
|
||
// to" — needed no machinery, because a discard already goes face-up onto a Department pile.
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
|
||
const pile = s.decks.departments[1]!;
|
||
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
|
||
});
|
||
|
||
it('offers the discard as a legal action, so the bot can take it', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const [timetabled, extra] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||
const offered = legalActions(s, 0).filter((i) => i.type === 'card.discard');
|
||
assert.ok(
|
||
offered.some((i) => i.type === 'card.discard' && i.cardId === timetabled),
|
||
'a Timetabled train was not offered as a discard',
|
||
);
|
||
assert.ok(
|
||
!offered.some((i) => i.type === 'card.discard' && i.cardId === extra),
|
||
'an Extra was offered as a discard',
|
||
);
|
||
});
|
||
|
||
it('keeps Gitea#6 reachable when the setting is off', () => {
|
||
const s = strictGame();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||
for (const id of [timetabled!, extra!]) {
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }),
|
||
'TRAINS_ARE_NEVER_DISCARDED',
|
||
);
|
||
}
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||
});
|
||
|
||
it('leaves PLAYING a train as the only way out of a hand of four, setting off', () => {
|
||
const s = strictGame();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
||
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
||
|
||
// Over the limit, so the turn cannot be ended...
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||
// ...and not one of them may be discarded...
|
||
for (const id of four) {
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
|
||
}
|
||
// ...but playing one is always legal, so the player is never actually stuck.
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId: four[0]! }), null);
|
||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
|
||
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT);
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
|
||
});
|
||
|
||
it('a hand of four Extras is the corner that survives Gitea#9 with the setting ON', () => {
|
||
// Gitea#9 does not reach an Extra, so the deadlock-that-is-not-a-deadlock is still real in a
|
||
// default game — worth pinning, since it is now the ONLY way to reach it.
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const four = handOf(s, ['extraTrain', 'extraTrain', 'extraTrain', 'extraTrain']);
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||
for (const id of four) {
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
|
||
}
|
||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||
});
|
||
|
||
it('lets a train be held across Stages and into the next Day', () => {
|
||
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
|
||
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud. An Extra is
|
||
// used, because it is the card that still cannot be got rid of any other way.
|
||
const s = game();
|
||
const [extra] = handOf(s, ['extraTrain', 'track']);
|
||
const startDay = s.clock.day;
|
||
|
||
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
||
for (let guard = 0; guard < 400 && s.clock.day === startDay; guard++) {
|
||
pump(s);
|
||
const actor = s.clock.currentActor;
|
||
if (actor === null) break;
|
||
const options = legalActions(s, actor);
|
||
const end = options.find((i) => i.type.endsWith('.end')) ?? options[0];
|
||
if (!end) break;
|
||
applyIntent(s, actor, end);
|
||
}
|
||
|
||
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
||
assert.ok(
|
||
(s.decks.hands.get(0) ?? []).includes(extra!),
|
||
'the train did not survive being held into the next Day',
|
||
);
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||
'TRAINS_ARE_NEVER_DISCARDED',
|
||
'a Day boundary made an Extra discardable',
|
||
);
|
||
});
|
||
|
||
it('tells the player on the card itself which of the two rules applies', () => {
|
||
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
|
||
// reason given. Since Gitea#9 there are TWO reasons, so the card has to say which.
|
||
const s = game();
|
||
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||
const f = snapshot(s, [], null);
|
||
// `hand` is reversed for display, so compare as a set rather than by position.
|
||
assert.deepEqual([...f.handDiscardable].sort(), [false, true, true]);
|
||
const said = f.handKeepWhy.filter((w): w is string => w !== null);
|
||
assert.equal(said.length, 1, 'exactly one card in this hand may not be discarded');
|
||
assert.match(said[0]!, /An Extra is never discarded/);
|
||
|
||
const strict = strictGame();
|
||
handOf(strict, ['timetabledTrain', 'extraTrain', 'track']);
|
||
const sf = snapshot(strict, [], null);
|
||
assert.deepEqual([...sf.handDiscardable].sort(), [false, false, true]);
|
||
assert.ok(
|
||
sf.handKeepWhy.some((w) => w !== null && /never discarded in this game/.test(w)),
|
||
'the setting being off is not explained on the card',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
|
||
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
|
||
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
|
||
// the old single-slot model did — destroyed the buried card outright and threw the choice away.
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||
const under = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
|
||
const spare = s.decks.hands.get(0)![0]!;
|
||
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
|
||
assert.ok(r.ok);
|
||
assert.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost');
|
||
// The opening hand is six, so one discard does not yet clear §6.2's limit — shed the rest.
|
||
while (s.decks.hands.get(0)!.length > HAND_LIMIT) {
|
||
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 1 });
|
||
}
|
||
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||
});
|
||
|
||
it('lets the discarding player pick which Department to use', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||
const spare = s.decks.hands.get(0)![0]!;
|
||
for (const slot of [0, 1, 2]) {
|
||
assert.equal(check(s, 0, { type: 'card.discard', cardId: spare, toSlot: slot }), null, `Department ${slot}`);
|
||
}
|
||
const depths = s.decks.departments.map((p) => p.length);
|
||
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 2 }).ok);
|
||
assert.deepEqual(
|
||
s.decks.departments.map((p) => p.length),
|
||
[depths[0]!, depths[1]!, depths[2]! + 1],
|
||
'only the chosen Department should grow',
|
||
);
|
||
});
|
||
|
||
it('refuses to play a card that is not in hand', () => {
|
||
const s = game();
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
|
||
'CARD_NOT_IN_HAND',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Office upgrades (Gap 3b, Gap 8)', () => {
|
||
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
|
||
for (const [id, card] of s.cards) {
|
||
if (card.kind.kind === 'office' && card.kind.tier === tier) {
|
||
s.decks.hands.set(0, [id]);
|
||
return id;
|
||
}
|
||
}
|
||
throw new Error('no such office card');
|
||
}
|
||
|
||
it('upgrades a Whistle Post to a Depot', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
assert.ok(r.ok);
|
||
assert.equal(areaOf(s, 0).tier, 'depot');
|
||
});
|
||
|
||
it('refuses to skip a tier', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'station');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
|
||
});
|
||
|
||
it('preserves every grid connection through an upgrade', () => {
|
||
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
|
||
// Secondary Track hanging off the Office.
|
||
const s = game();
|
||
addCard(s, at(-1, 0), straight());
|
||
const gridBefore = new Map(areaOf(s, 0).grid);
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
|
||
const gridAfter = areaOf(s, 0).grid;
|
||
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
|
||
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
|
||
});
|
||
|
||
it('grants the Depot its Porters and slots on upgrade', () => {
|
||
const s = game();
|
||
const id = handCardOfTier(s, 'depot');
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
|
||
assert.equal(office.facility!.porters, 1);
|
||
// The design gives slots equal to porters, not one more.
|
||
assert.equal(office.facility!.capacity.inbound, 1);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Local Operations: switching (§6.1, Appendix A)', () => {
|
||
it('moves a tray and spends a Move', () => {
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||
assert.ok(r.ok);
|
||
assert.equal(turnOf(s, 0).movesRemaining, MOVES_PER_LOCAL_OPS - 1);
|
||
});
|
||
|
||
it('couples standing cars automatically and mandatorily', () => {
|
||
// §A.4 — "you MUST pick them up".
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||
assert.ok(r.ok);
|
||
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
|
||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||
});
|
||
|
||
it('refuses a move to an unreachable card', () => {
|
||
const s = game();
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
|
||
'ILLEGAL_MOVE',
|
||
);
|
||
});
|
||
|
||
it('runs out of Moves after six', () => {
|
||
const s = game();
|
||
const tray = placeTray(s, at(0, 1));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
turnOf(s, 0).movesRemaining = 0;
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
|
||
'NO_MOVES_REMAINING',
|
||
);
|
||
});
|
||
|
||
it('drops cars from the seated end, in order', () => {
|
||
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
|
||
const s = game();
|
||
addCard(s, at(0, 2), straight());
|
||
const tray = placeTray(s, at(0, 2), [
|
||
{ type: 'boxcar', loaded: false },
|
||
{ type: 'hopper', loaded: true },
|
||
]);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
|
||
assert.ok(r.ok);
|
||
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
|
||
assert.equal(left.length, 1);
|
||
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
|
||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||
});
|
||
|
||
it('refuses to drop cars at the Office', () => {
|
||
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
||
const s = game();
|
||
const office = areaOf(s, 0).officeCoord;
|
||
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
|
||
'CANNOT_DROP_HERE',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Freight Agent operations (§6.3)', () => {
|
||
function withFacility(s: GameState): GridCoord {
|
||
const coord = at(-1, 0);
|
||
addCard(s, coord, {
|
||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||
baseOperationalRail: true,
|
||
standing: [],
|
||
standingWest: 0,
|
||
facility: {
|
||
kind: 'freight',
|
||
subtype: 'mineTipple',
|
||
allows: { outbound: true, inbound: false },
|
||
outboundBox: [],
|
||
inboundBox: [],
|
||
capacity: { outbound: 3, inbound: 0 },
|
||
menAtWork: [null, null, null],
|
||
// An empty hopper is SPOTTED, which is what the LABORERS need (§9.3). The Freight Agent
|
||
// does not: stocking works over a bare track too, and the tests below cover that.
|
||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||
laborers: 3,
|
||
porters: 0,
|
||
usedThisStage: { laborers: 0, porters: 0 },
|
||
},
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
return coord;
|
||
}
|
||
|
||
it('stocks the green Outbound box from the Division Yard', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const before = s.yards.divisionYard.length;
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.ok(r.ok);
|
||
assert.equal(s.yards.divisionYard.length, before - 1);
|
||
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
|
||
});
|
||
|
||
it('stocks the green box before the car is spotted (§6.3)', () => {
|
||
/**
|
||
* REPORTED FROM PLAY: "A freight agent should be able to load an outbound green box prior to
|
||
* having the car there that matches the load he's putting in there."
|
||
*
|
||
* §6.3 asks for nothing but a car in the Division Yard and room in the box. The empty-car
|
||
* requirement is §9.3's, on the Laborer who moves the load out of the box — see the test below.
|
||
* The engine used to hoist it forward onto stocking and so refused the ordinary play of having
|
||
* the cargo waiting while the car is still being switched in.
|
||
*/
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.industryTrack.cars = [];
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.ok(r.ok, 'staging cargo against a bare industry track is legal');
|
||
assert.equal(f.outboundBox.length, 1);
|
||
});
|
||
|
||
it('still refuses to stock with no matching loaded car in the Division Yard', () => {
|
||
// The requirement §6.3 DOES make: the load has to come from somewhere.
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'hopper' && c.loaded));
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'NO_SUITABLE_CAR',
|
||
);
|
||
});
|
||
|
||
it('allows only one Freight Agent operation per Stage', () => {
|
||
// This is the bottleneck the whole economy rests on (card-reference.md §7).
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'OPTION_ALREADY_CHOSEN',
|
||
);
|
||
});
|
||
|
||
it('refuses to overfill the Outbound box', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.outboundBox = [
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'hopper', loaded: true },
|
||
];
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'BOX_FULL',
|
||
);
|
||
});
|
||
|
||
it('refuses to load a facility that only unloads', () => {
|
||
const s = game();
|
||
const coord = withFacility(s);
|
||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||
f.allows = { outbound: false, inbound: true };
|
||
// Give it something to clear, so the Freight Agent option itself remains available.
|
||
f.inboundBox = [{ type: 'hopper', loaded: true }];
|
||
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||
assert.ok(chose.ok);
|
||
assert.equal(
|
||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||
'NO_SUCH_FACILITY',
|
||
);
|
||
});
|
||
|
||
it('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));
|
||
});
|
||
});
|