Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
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, sisterTrains: 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);
|
|
});
|
|
});
|