asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
751 lines
32 KiB
TypeScript
751 lines
32 KiB
TypeScript
/**
|
|
* Enhancement cards (implications.md §7).
|
|
*
|
|
* These are the 18 cards that make up the largest block of the "third of the deck that does
|
|
* nothing". Several change core loops — Small Yard makes the facing-point switching puzzle
|
|
* solvable at all, and ABS Signals amends Gap 2's unconditional collisions.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { advance } from '../src/engine/advance.ts';
|
|
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
|
|
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
|
import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
|
};
|
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
|
|
|
const straight = (): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
|
areaOf(s, 0).grid.set(coordKey(coord), card);
|
|
}
|
|
|
|
/**
|
|
* An Enhancement in hand — minting one if the deck does not deal it.
|
|
*
|
|
* Facing Point Locks, the Water Column and the Overpass answer opponent-directed cards, which are
|
|
* held out of every deck until they are implemented, so those three are no longer dealt either
|
|
* (v0.4.7). Their RULES are still live and still worth testing: they fire the moment their attacker
|
|
* returns, and a rule nobody exercises is a rule that rots. So the card is put in hand directly
|
|
* rather than fished out of a deck that deliberately no longer contains it.
|
|
*/
|
|
function handEnhancement(s: GameState, key: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
const id = `mint-${key}-${s.cards.size}`;
|
|
s.cards.set(id, { id, kind: { kind: 'enhancement', key } } as never);
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
|
|
/** Puts a specific track card in hand and returns its id. */
|
|
function trackInHand(s: GameState, geometry: string, hand: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
const k = card.kind as { kind: string; geometry?: string; hand?: string };
|
|
if (k.kind === 'track' && k.geometry === geometry && k.hand === hand) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
throw new Error(`no track card: ${geometry}/${hand}`);
|
|
}
|
|
|
|
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist, direction: 'east', position: { at: 'grid', seat: 0, coord }, movesUsed: 0,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('enhancement placement', () => {
|
|
it('defines a rule for every enhancement card', () => {
|
|
assert.equal(ENHANCEMENT_RULES.length, 10);
|
|
for (const r of ENHANCEMENT_RULES) assert.ok(enhancementRule(r.key), r.key);
|
|
});
|
|
|
|
it('puts Interlocking on a Running Track straight, not a Secondary one', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
addCard(s, at(-1, 0), straight());
|
|
const id = handEnhancement(s, 'interlocking');
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), null);
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), 'NOT_CONNECTED');
|
|
});
|
|
|
|
it('puts Small Yard on a Secondary Track straight, not the Running Track', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
addCard(s, at(-1, 0), straight());
|
|
const id = handEnhancement(s, 'smallYard');
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), null);
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
});
|
|
|
|
it('stacks Telephone on Telegraph and Radio on Telephone, never bare', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
|
|
const phone = handEnhancement(s, 'telephone');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: phone, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
|
|
const tel = handEnhancement(s, 'telegraph');
|
|
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: tel, placement: at(0, 2) }).ok);
|
|
|
|
const phone2 = handEnhancement(s, 'telephone');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null);
|
|
});
|
|
|
|
it('will not stack two straight-placed enhancements on one card', () => {
|
|
// A straight-placed Enhancement REPLACES the straight — its printed placement is "any Running
|
|
// Track Straight", so the card goes down in place of it and what stands there afterwards is an
|
|
// Interlocking, not a straight carrying one. A second such card has no straight left to take.
|
|
// This was unchecked, because an enhancement only pushes a string onto `enhancements[]` and
|
|
// leaves the geometry alone: one straight could hold Interlocking, Telegraph and a Water Column
|
|
// at once.
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
addCard(s, at(0, 3), straight());
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
|
|
const lock = handEnhancement(s, 'interlocking');
|
|
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) }).ok);
|
|
|
|
for (const key of ['telegraph', 'waterColumn'] as const) {
|
|
const id = handEnhancement(s, key);
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }),
|
|
'NOT_CONNECTED',
|
|
`${key} was stacked on top of an Interlocking`,
|
|
);
|
|
// The bare straight next door is still a legal home for it.
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 3) }), null,
|
|
`${key} should still be playable on a straight that carries nothing`,
|
|
);
|
|
}
|
|
|
|
// And a turnout cannot upgrade it either — the straight it would replace is no longer there.
|
|
assert.equal(
|
|
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at(0, 2), variant: 0 }),
|
|
'UPGRADE_ENHANCED',
|
|
'a turnout was laid over a built Interlocking',
|
|
);
|
|
});
|
|
|
|
it('requires an Interlocking in the district before Facing Point Locks', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
const fpl = handEnhancement(s, 'facingPointLocks');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
|
|
const lock = handEnhancement(s, 'interlocking');
|
|
applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) });
|
|
const fpl2 = handEnhancement(s, 'facingPointLocks');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl2, placement: at(0, 2) }), null);
|
|
assert.ok(isProtectedFromDerail(areaOf(s, 0)) === false, 'not protected until actually played');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Small Yard — the card that makes switching solvable', () => {
|
|
function yardGame() {
|
|
const s = game();
|
|
const yard = at(-1, 0);
|
|
const card = straight();
|
|
card.enhancements.push('smallYard');
|
|
addCard(s, yard, card);
|
|
const tray = placeTray(s, yard, [
|
|
{ type: 'boxcar', loaded: false },
|
|
{ type: 'hopper', loaded: true },
|
|
{ type: 'coach', loaded: false },
|
|
]);
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
|
return { s, tray, yard };
|
|
}
|
|
|
|
it('re-orders a consist, including bringing a buried car to the end', () => {
|
|
// §A.3 forces cars off in seated order, so without this the middle car can never be spotted.
|
|
const { s, tray } = yardGame();
|
|
const r = applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [0, 2, 1] });
|
|
assert.ok(r.ok);
|
|
assert.deepEqual(
|
|
s.trays.get(tray)!.consist.map((c) => c.type),
|
|
['boxcar', 'coach', 'hopper'],
|
|
'the hopper is now on the droppable end',
|
|
);
|
|
});
|
|
|
|
it('costs one Move — "spends one move in the yard"', () => {
|
|
const { s, tray } = yardGame();
|
|
const before = turnOf(s, 0).movesRemaining;
|
|
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
|
|
assert.equal(turnOf(s, 0).movesRemaining, before - 1);
|
|
});
|
|
|
|
it('is refused anywhere without a Small Yard', () => {
|
|
const s = game();
|
|
addCard(s, at(-1, 0), straight());
|
|
const tray = placeTray(s, at(-1, 0), [
|
|
{ type: 'boxcar', loaded: false },
|
|
{ type: 'hopper', loaded: true },
|
|
]);
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [1, 0] }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('refuses an order that is not a permutation of the consist', () => {
|
|
const { s, tray } = yardGame();
|
|
for (const bad of [[0, 1], [0, 1, 1], [0, 1, 5]]) {
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: bad }),
|
|
'CONSIST_ORDER',
|
|
`accepted ${JSON.stringify(bad)}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Interlocking and Yard Office relieve the Office', () => {
|
|
function inbound(s: GameState, consist: TrackCard['standing']) {
|
|
const id = 'inbound';
|
|
s.trays.set(id, {
|
|
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
|
ml.card = 'plains';
|
|
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
|
}
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return id;
|
|
}
|
|
|
|
it('holds a train at the Limits instead of colliding when the Office is full', () => {
|
|
// Gap 2d made a full Office an automatic collision. Interlocking is the designed answer.
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('interlocking');
|
|
addCard(s, at(0, 2), card);
|
|
areaOf(s, 0).adOccupancy = ['blocker'];
|
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
|
|
advance(s);
|
|
assert.equal(s.players[0]!.revenue, 0, 'no collision penalty');
|
|
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
|
|
});
|
|
|
|
it('still collides without an Interlocking', () => {
|
|
const s = game();
|
|
areaOf(s, 0).adOccupancy = ['blocker'];
|
|
inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
advance(s);
|
|
assert.equal(s.players[0]!.revenue, -5);
|
|
});
|
|
|
|
it('diverts a coachless train to the Yard Office', () => {
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('yardOffice');
|
|
addCard(s, at(-1, 0), card);
|
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
|
|
advance(s);
|
|
const pos = s.trays.get(id)!.position;
|
|
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
|
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
|
});
|
|
|
|
it('does not divert a train carrying coaches', () => {
|
|
// "An inbound train with NO COACHES" — passengers must reach the Train Order Office.
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('yardOffice');
|
|
addCard(s, at(-1, 0), card);
|
|
const id = inbound(s, [{ type: 'coach', loaded: true }]);
|
|
|
|
advance(s);
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'a coach train must use the Office');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ABS Signals amend the collision rule', () => {
|
|
it('turns a following-train judgment into a plain hold', () => {
|
|
// "Trains on this card will not rear-end each other. They will stop short of a collision."
|
|
const s = game();
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain — see the meet tests below. Double Track and Uncontrolled Siding let trains
|
|
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
|
ml.card = 'plains';
|
|
ml.absSignals = true;
|
|
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
|
}
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.trays.set('behind', {
|
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
|
|
const r = advance(s);
|
|
assert.equal(r.needsInput, false, 'no clearance decision is needed with signals');
|
|
assert.equal(s.clock.pendingDecision, null);
|
|
assert.deepEqual(s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('train revenue per transit — paid to EVERY player when a train completes its run', () => {
|
|
/**
|
|
* `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against
|
|
* a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These
|
|
* tests are about the rule, so they set the rate; the last one is about the default.
|
|
*/
|
|
const paying = (rate = 1, seed = 5): GameState =>
|
|
createGame({
|
|
id: 'g',
|
|
seed,
|
|
config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } },
|
|
playerNames: ['p'],
|
|
});
|
|
|
|
/** A train sitting on the A/D track, made up and ready to highball. */
|
|
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
|
|
const id = 'leaving';
|
|
s.trays.set(id, {
|
|
id, trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction, position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
|
|
movesUsed: 0,
|
|
});
|
|
areaOf(s, 0).adOccupancy.push(id);
|
|
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return id;
|
|
};
|
|
|
|
it('pays nothing for merely leaving the Office', () => {
|
|
/**
|
|
* THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed, which meant a
|
|
* five-Office railroad paid five times for one train and paid most to whoever it happened to
|
|
* pass first. Departing is now worth nothing on its own.
|
|
*/
|
|
const s = game();
|
|
const before = s.players[0]!.revenue;
|
|
readyToLeave(s, 'east');
|
|
|
|
advance(s);
|
|
|
|
assert.equal(s.players[0]!.revenue, before, 'departing the Office still paid');
|
|
});
|
|
|
|
it('pays every player once the train runs off the end of the Division', () => {
|
|
const s = paying();
|
|
const before = s.players.map((p) => p.revenue);
|
|
readyToLeave(s, 'east');
|
|
|
|
// Run the Mainline Phase until the train has crossed the Division and left it.
|
|
const seen: typeof s.players[number]['revenue'][] = [];
|
|
let completed: { type: string; side?: string } | undefined;
|
|
for (let i = 0; i < 20 && !completed; i++) {
|
|
s.movedThisPhase = new Set();
|
|
s.clock.phase = 'mainline';
|
|
const r = advance(s);
|
|
completed = r.events.find((e) => e.type === 'trainCompleted');
|
|
if (completed) {
|
|
assert.equal(completed.side, 'east', 'an eastbound train left by the wrong Division Point');
|
|
assert.ok(
|
|
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run'),
|
|
'no revenue event was emitted for the completed run',
|
|
);
|
|
}
|
|
seen.push(s.players[0]!.revenue);
|
|
}
|
|
assert.ok(completed, 'the train never left the Division');
|
|
assert.deepEqual(s.players.map((p) => p.revenue), before.map((r) => r + 1), 'not every player was paid');
|
|
|
|
// Paid ONCE — the train is gone, so there is nothing left to pay again.
|
|
const after = s.players.map((p) => p.revenue);
|
|
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
|
assert.deepEqual(s.players.map((p) => p.revenue), after, 'the same run was paid twice');
|
|
});
|
|
|
|
it('pays nothing for a local crew, which is switching rather than running', () => {
|
|
const s = paying();
|
|
const before = s.players[0]!.revenue;
|
|
const id = readyToLeave(s, 'east');
|
|
s.trays.get(id)!.trainNumber = null;
|
|
|
|
for (let i = 0; i < 20; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
|
|
|
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run');
|
|
});
|
|
|
|
it('pays at the rate the game was dealt with, and nothing at the default of zero', () => {
|
|
// Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not
|
|
// fill with "+0 Revenue" for work that did not pay.
|
|
const run = (rate: number): { revenue: number; events: number } => {
|
|
const s = rate === 0 ? game() : paying(rate);
|
|
readyToLeave(s, 'east');
|
|
let events = 0;
|
|
let done = false;
|
|
for (let i = 0; i < 20 && !done; i++) {
|
|
s.movedThisPhase = new Set();
|
|
s.clock.phase = 'mainline';
|
|
const r = advance(s);
|
|
events += r.events.filter(
|
|
(e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run',
|
|
).length;
|
|
done = r.events.some((e) => e.type === 'trainCompleted');
|
|
}
|
|
assert.ok(done, `the train never left the Division at rate ${rate}`);
|
|
return { revenue: s.players[0]!.revenue, events };
|
|
};
|
|
|
|
assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit');
|
|
assert.deepEqual(run(1), { revenue: 1, events: 1 });
|
|
assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('§8.1 — a train that just arrived from the mainline stops for the Stage', () => {
|
|
/**
|
|
* "The train did not just initially arrive from a mainline card at the Office this Stage."
|
|
*
|
|
* This holds today, but only as a SIDE EFFECT: the Mainline Phase visits each train once per Stage
|
|
* in numeric order, so a train that spends its visit arriving has no visit left to depart with.
|
|
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
|
* would break in silence. These pin it.
|
|
*
|
|
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
|
|
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
|
|
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
|
|
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
|
|
*/
|
|
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
|
const id = 'arriving';
|
|
s.trays.set(id, {
|
|
id, trainNumber, trainIsExtra: isExtra, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
ml.card = 'plains';
|
|
// One Stage left to run, so this Stage it rolls off the card and into the Office.
|
|
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
|
}
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return id;
|
|
};
|
|
|
|
it('does not depart the Stage it rolls in', () => {
|
|
const s = game();
|
|
const id = arriving(s, 9);
|
|
assert.notEqual(trainProfile(9, false)?.rules.expedite, true, 'train 9 must NOT be an Expedite train');
|
|
|
|
advance(s);
|
|
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not arrive at the Office at all');
|
|
assert.equal(
|
|
s.trays.get(id)!.position.at, 'grid',
|
|
'the train departed again in the same Stage it arrived from a mainline card',
|
|
);
|
|
});
|
|
|
|
it('an Expedite train follows the same rule now — no exception any more', () => {
|
|
/**
|
|
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
|
|
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
|
|
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
|
|
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
|
|
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
|
|
* exactly like Train 9 above.
|
|
*/
|
|
const s = game();
|
|
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
|
const id = arriving(s, 3);
|
|
|
|
advance(s);
|
|
|
|
assert.equal(
|
|
s.trays.get(id)!.position.at, 'grid',
|
|
'an Expedite train departed again in the same Stage it arrived from a mainline card',
|
|
);
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('§8.1 asks about the next Subdivision, not the next card', () => {
|
|
/**
|
|
* "If there is a train in the next Subdivision moving towards the considered train, the considered
|
|
* train will not depart." This used to inspect only the card being entered, so a train ran headlong
|
|
* into a Subdivision an opposing train was two cards deep in and was stopped only when they met.
|
|
*
|
|
* At game start every Office is a Whistle Post — not a Control Point — so the whole railroad is ONE
|
|
* Subdivision: nodes [1, 2, 3] between the two Division Points. That is what makes early traffic so
|
|
* constrained, and each upgrade to a Control Point splits it and buys capacity back.
|
|
*/
|
|
const facingTrainOn = (s: GameState, index: number): void => {
|
|
// EVERY Mainline card is pinned, not just the one the facing train stands on: Double Track and
|
|
// Uncontrolled Siding print "trains may pass", so terrain anywhere in the Subdivision can clear
|
|
// the departure. Terrain is dealt from the same RNG stream as the cards, so leaving any of it to
|
|
// the seed makes this luck — which is exactly how it broke when the deck composition changed.
|
|
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
|
const ml = s.division.nodes[index];
|
|
if (ml?.kind === 'mainline') {
|
|
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
|
}
|
|
s.trays.set('facing', {
|
|
id: 'facing', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'west', position: { at: 'mainline', index }, movesUsed: 0,
|
|
});
|
|
s.trays.set('considered', {
|
|
id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
};
|
|
|
|
it('holds a train out of a Subdivision an opposing train is deep inside', () => {
|
|
const s = game();
|
|
assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'the fixture assumes one Subdivision of three nodes');
|
|
|
|
// The facing train is on node 3 — NOT the card being entered, which is node 1.
|
|
facingTrainOn(s, 3);
|
|
const r = advance(s);
|
|
|
|
assert.equal(r.needsInput, false, 'an opposing train is an absolute bar, never a judgment call');
|
|
assert.equal(s.clock.pendingDecision, null);
|
|
assert.deepEqual(
|
|
s.trays.get('considered')!.position, { at: 'divisionPoint', side: 'west' },
|
|
'the train departed into a Subdivision already occupied by a train coming the other way',
|
|
);
|
|
});
|
|
|
|
it('ignores a train standing clear on Secondary Track in a Whistle Post district', () => {
|
|
// The last §8.1 condition: "if the Subdivision contains a Whistle Post yet a train is occupying
|
|
// Secondary Track there (i.e. clear of the Running Track), the Superintendent MUST Highball the
|
|
// train." The Office node sits inside this Subdivision, so a scan that counted everything in the
|
|
// group would have blocked on it. Only Mainline occupancy counts.
|
|
const s = game();
|
|
assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'node 2 is the Office, inside the Subdivision');
|
|
|
|
addCard(s, at(-1, 0), straight());
|
|
placeTray(s, at(-1, 0));
|
|
|
|
s.trays.set('considered', {
|
|
id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') ml.card = 'plains';
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
|
|
advance(s);
|
|
|
|
assert.deepEqual(
|
|
s.trays.get('considered')!.position, { at: 'mainline', index: 1 },
|
|
'a train standing clear on Secondary Track held up the whole Subdivision',
|
|
);
|
|
});
|
|
|
|
it('still lets it go once a Control Point splits that Subdivision away', () => {
|
|
// The same position, with the Office upgraded to a Depot: node 2 becomes a boundary, so the
|
|
// facing train on node 3 is in a DIFFERENT Subdivision and no longer bars the departure.
|
|
const s = game();
|
|
areaOf(s, 0).tier = 'depot';
|
|
assert.deepEqual(subdivisions(s), [[1], [3]], 'a Control Point should split the Division in two');
|
|
|
|
facingTrainOn(s, 3);
|
|
advance(s);
|
|
|
|
assert.deepEqual(
|
|
s.trays.get('considered')!.position, { at: 'mainline', index: 1 },
|
|
'a train in another Subdivision must not hold this one at the Division Point',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|
function meet(s: GameState, device?: string) {
|
|
if (device) {
|
|
const card = straight();
|
|
card.enhancements.push(device);
|
|
addCard(s, at(0, 2), card);
|
|
}
|
|
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
|
s.trays.set('oncoming', {
|
|
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain. Mainline types are drawn from the shuffled deck, so deck composition would
|
|
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
|
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
|
ml.card = 'plains';
|
|
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
|
}
|
|
s.trays.set('mine', {
|
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
}
|
|
|
|
it('holds a facing train with no device — §8.1 stands', () => {
|
|
const s = game();
|
|
meet(s);
|
|
advance(s);
|
|
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
|
});
|
|
|
|
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
|
|
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
|
|
// instead of being automatically held against an opposite-direction one also occupying the
|
|
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
|
|
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
|
|
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
|
|
// what creates: a facing train dispatched past another sits on the same card as it.
|
|
const s = game();
|
|
const ml = s.division.nodes[1];
|
|
assert.equal(ml?.kind, 'mainline');
|
|
if (ml?.kind === 'mainline') {
|
|
ml.card = 'plains';
|
|
// SAME direction, entered the transit list FIRST — what the old code asked about.
|
|
s.trays.set('sameDir', {
|
|
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
|
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
|
|
s.trays.set('facing', {
|
|
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
|
}
|
|
s.trays.set('mine', {
|
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
|
|
const r = advance(s);
|
|
|
|
assert.equal(
|
|
s.trays.get('mine')!.position.at, 'divisionPoint',
|
|
'the train entered despite an uncleared opposite-direction occupant on the card',
|
|
);
|
|
assert.ok(
|
|
!r.events.some((e) => e.type === 'clearanceRequested'),
|
|
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
|
|
);
|
|
});
|
|
|
|
it('lets a junior train win the meet with a Telegraph', () => {
|
|
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
|
const s = game();
|
|
meet(s, 'radio');
|
|
advance(s);
|
|
assert.equal(s.trays.get('mine')!.position.at, 'mainline', 'the meet was dispatched');
|
|
assert.ok(areaOf(s, 0).dispatchUsedToday.includes('radio'));
|
|
});
|
|
|
|
it('spends each device only once a Day', () => {
|
|
const s = game();
|
|
meet(s, 'radio');
|
|
advance(s);
|
|
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, ['radio']);
|
|
// Reset happens at the Day boundary.
|
|
s.clock.stage = 12;
|
|
s.clock.phase = 'shiftChange';
|
|
advance(s);
|
|
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, [], 'devices reset each Day');
|
|
});
|
|
|
|
it('gives Radio the largest bonus and Telegraph the smallest', () => {
|
|
assert.equal(enhancementRule('telegraph')!.dispatchBonus, 4);
|
|
assert.equal(enhancementRule('telephone')!.dispatchBonus, 8);
|
|
assert.equal(enhancementRule('radio')!.dispatchBonus, 12);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defensive enhancements', () => {
|
|
it('reports district protection only once actually built', () => {
|
|
// These guard against opponent-directed cards, which a solitaire deck omits entirely (Q6).
|
|
const s = game();
|
|
assert.equal(isProtectedFromDerail(areaOf(s, 0)), false);
|
|
const card = straight();
|
|
card.enhancements.push('facingPointLocks');
|
|
addCard(s, at(0, 2), card);
|
|
assert.equal(isProtectedFromDerail(areaOf(s, 0)), true);
|
|
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
|
});
|
|
});
|