From the first two multiplayer playtests of v0.8.0.9, each traced before fixing. The engine: - A train on a card BEHIND the one departing no longer triggers a clearance ruling or an opposite-direction bar (#26). Reproduced from the exported save: X15 was held over X18 behind it, and X18 then collided into the full Whistle Post. Games in progress holding a ruling the engine no longer asks for will not resume (28 of 40 recorded four-seat games); shipped as is at Jesse's call. - `mainlineModified` carries the card's previous kind, so the log can say what a Realignment converted (#27). The screen: - The turn chart and the Division map name the player whose move is on screen while bot turns replay, not the live actor (#25). - The owning player's name is no longer outlined by the turn arrow's stroke, which made it unreadable (#24). - A Mainline card flashes on the map when a Realignment changes it (#28). - The history is held back with the board and revealed step by step, instead of arriving whole while the board is still catching up (#29). - A ruling made by holding the office reads "Superintendent Player X" (#30), and no line names a player twice (#31). - A seated player can download their own game as a save file: the play page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS action cannot do this — an action result is text only. Closes #24 Closes #25 Closes #26 Closes #27 Closes #28 Closes #29 Closes #30 Closes #31 Closes #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
1585 lines
67 KiB
TypeScript
1585 lines
67 KiB
TypeScript
/**
|
||
* Build order step 4 — "A full solitaire game runs to completion, headless."
|
||
* See architecture/components.md §4. This is the milestone that proves the rules before a single
|
||
* pixel is drawn.
|
||
*/
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { advance, pump } from '../src/engine/advance.ts';
|
||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||
import { legalActions } from '../src/engine/legal.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import { developerBot } from '../src/sim/bot.ts';
|
||
import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
|
||
import { coordKey, railFacingOf } from '../src/engine/state.ts';
|
||
|
||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||
mode: 'solitaire',
|
||
days: 3,
|
||
minCombinedRevenue: 0,
|
||
maxCollisionsPerDay: 0,
|
||
maxCollisionsTotal: 0,
|
||
pvpCardsAllowed: false,
|
||
optionalRules: {
|
||
reducedVisibility: false,
|
||
employeeRotation: false,
|
||
emergencyToolbox: false,
|
||
},
|
||
...over,
|
||
});
|
||
|
||
const game = (seed = 1, over: Partial<GameConfig> = {}): GameState =>
|
||
createGame({ id: 'g', seed, config: baseConfig(over), playerNames: ['Jesse'] });
|
||
|
||
type PlayStats = { turns: number; scheduled: number; collisions: number; cardsPlayed: number };
|
||
|
||
/**
|
||
* A deliberately simple bot — the seed of component 17. It draws, then plays, then ends its turn.
|
||
*
|
||
* The draw-first ordering matters: an earlier version never drew, so it burned its three opening
|
||
* cards and then held an empty hand for the rest of the game. Every seed still "completed", but
|
||
* nothing ever happened — no trains, no revenue. That is why the milestone tests below assert on
|
||
* what the game DID, not merely that it terminated.
|
||
*/
|
||
function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats {
|
||
let rng = seed >>> 0;
|
||
const pick = <T,>(xs: T[]): T => {
|
||
rng = (rng * 1103515245 + 12345) >>> 0;
|
||
return xs[rng % xs.length]!;
|
||
};
|
||
|
||
const stats: PlayStats = { turns: 0, scheduled: 0, collisions: 0, cardsPlayed: 0 };
|
||
const tally = (events: { type: string }[]): void => {
|
||
for (const e of events) {
|
||
if (e.type === 'trainScheduled') stats.scheduled++;
|
||
if (e.type === 'cardPlayed') stats.cardsPlayed++;
|
||
if (e.type === 'revenueChanged' && 'reason' in e && String(e.reason).startsWith('collision')) {
|
||
stats.collisions++;
|
||
}
|
||
}
|
||
};
|
||
|
||
for (; stats.turns < maxTurns; stats.turns++) {
|
||
tally(pump(s));
|
||
if (s.status === 'finished') break;
|
||
|
||
/**
|
||
* §3.3, EXTENDED PLAY (Gitea#11) — this harness plays the timetable it was dealt.
|
||
*
|
||
* It picks at random among legal options, and one of the two options here is "play another
|
||
* Day" — so left alone it would extend the game forever and terminate only on `maxTurns`. That
|
||
* is not a bug in the feature: a game genuinely does not end now until somebody says stop, and
|
||
* `developerBot` says stop for exactly this reason. Said explicitly here rather than folded
|
||
* into the random pick, because "how does this loop terminate" deserves an answer in the loop.
|
||
*/
|
||
if (s.status === 'awaitingExtension') {
|
||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: false });
|
||
assert.ok(r.ok, 'a solitaire player could not decline an extension');
|
||
break;
|
||
}
|
||
|
||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||
if (actor === null) break;
|
||
|
||
const options = legalActions(s, actor);
|
||
if (options.length === 0) break;
|
||
|
||
const draws = options.filter((i) => i.type.startsWith('draw.from'));
|
||
const plays = options.filter((i) => i.type === 'card.play');
|
||
const enders = options.filter(
|
||
(i) =>
|
||
i.type === 'switch.end' ||
|
||
i.type === 'draw.end' ||
|
||
i.type === 'loadUnload.end' ||
|
||
i.type === 'mainline.clearance',
|
||
);
|
||
|
||
const choice =
|
||
draws.length > 0
|
||
? pick(draws)
|
||
: plays.length > 0
|
||
? pick(plays)
|
||
: enders.length > 0
|
||
? pick(enders)
|
||
: pick(options);
|
||
|
||
const r = applyIntent(s, actor, choice);
|
||
assert.ok(r.ok, `bot chose an illegal action: ${choice.type}`);
|
||
tally(r.events);
|
||
}
|
||
return stats;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('phase sequencing (Gap 1)', () => {
|
||
it('waits for the actor in a player-driven phase', () => {
|
||
const s = game();
|
||
const r = advance(s);
|
||
assert.equal(r.needsInput, true);
|
||
assert.equal(s.clock.phase, 'localOps');
|
||
assert.equal(s.clock.currentActor, 0);
|
||
});
|
||
|
||
it('runs the phases in order once the player finishes', () => {
|
||
const s = game();
|
||
const seen: string[] = [];
|
||
for (let i = 0; i < 40 && s.status === 'active'; i++) {
|
||
const r = advance(s);
|
||
seen.push(...r.events.filter((e) => e.type === 'phaseBegan').map(() => s.clock.phase));
|
||
if (r.needsInput) {
|
||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||
// The opening deal is six cards against a limit of three (§6.2), so a turn cannot be ended
|
||
// until the hand is played or discarded down. Only the first turn actually has anything to
|
||
// shed; after that this loop is a no-op.
|
||
while ((s.decks.hands.get(0) ?? []).length > HAND_LIMIT) {
|
||
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 0 });
|
||
}
|
||
applyIntent(s, 0, { type: 'draw.end' });
|
||
}
|
||
}
|
||
// Phase-major: the whole sequence runs per Stage, with no player acting in mainline.
|
||
assert.ok(seen.includes('newTrain'));
|
||
assert.ok(seen.includes('mainline'));
|
||
assert.ok(seen.includes('loadUnload'));
|
||
});
|
||
|
||
it('has no current actor on entering the automatic Mainline Phase', () => {
|
||
// Entering `mainline` nulls the actor — nobody acts in turn order there (Gap 1). With no
|
||
// trains running the phase then completes immediately, so this checks the moment of entry.
|
||
const s = game();
|
||
s.clock.phase = 'newTrain';
|
||
advance(s);
|
||
assert.equal(s.clock.phase, 'mainline');
|
||
assert.equal(s.clock.currentActor, null);
|
||
});
|
||
|
||
it('advances the Stage and resets per-Stage worker usage', () => {
|
||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||
const s = game();
|
||
const office = s.officeAreas.get(0)!.grid.get('0,0')!;
|
||
office.facility!.usedThisStage = { laborers: 2, porters: 1 };
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.deepEqual(office.facility!.usedThisStage, { laborers: 0, porters: 0 });
|
||
assert.equal(s.clock.stage, 2);
|
||
});
|
||
|
||
it('rolls over into a new Day after twelve Stages', () => {
|
||
const s = game();
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.clock.day, 2);
|
||
assert.equal(s.clock.stage, 1);
|
||
});
|
||
|
||
it('passes the Fedora every three Stages', () => {
|
||
// §5 — shift changes at Stages 3, 6, 9 and 12. With one player it returns to them.
|
||
const s = createGame({
|
||
id: 'g',
|
||
seed: 3,
|
||
config: baseConfig({ mode: 'competitive' }),
|
||
playerNames: ['A', 'B', 'C'],
|
||
});
|
||
const before = s.clock.superintendent;
|
||
s.clock.stage = 3;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.clock.superintendent, (before + 1) % 3);
|
||
});
|
||
|
||
it('does not pass the Fedora on a non-shift-change Stage', () => {
|
||
const s = createGame({
|
||
id: 'g',
|
||
seed: 3,
|
||
config: baseConfig({ mode: 'competitive' }),
|
||
playerNames: ['A', 'B', 'C'],
|
||
});
|
||
const before = s.clock.superintendent;
|
||
s.clock.stage = 4;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.clock.superintendent, before);
|
||
});
|
||
|
||
it('resets the collision count each Day', () => {
|
||
// §3.4 — the counter resets at the start of each Day.
|
||
const s = game();
|
||
s.collisionsToday = 2;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.collisionsToday, 0);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Mainline Phase (§8)', () => {
|
||
function scheduleTrain(s: GameState, stage: number, number: number): void {
|
||
s.timetable[stage - 1] = number;
|
||
}
|
||
|
||
it('makes up a train due this Stage at the correct Division Point', () => {
|
||
const s = game();
|
||
scheduleTrain(s, 1, 2); // train 2 is even, therefore eastbound
|
||
s.clock.phase = 'newTrain';
|
||
advance(s);
|
||
const trays = [...s.trays.values()];
|
||
assert.equal(trays.length, 1);
|
||
assert.equal(trays[0]!.trainNumber, 2);
|
||
assert.equal(trays[0]!.direction, 'east');
|
||
// An eastbound train starts in the west.
|
||
assert.deepEqual(trays[0]!.position, { at: 'divisionPoint', side: 'west' });
|
||
});
|
||
|
||
it('holds a train when no Crew Tray is free (§7)', () => {
|
||
const s = game();
|
||
s.freeTrays = [];
|
||
scheduleTrain(s, 1, 2);
|
||
s.clock.phase = 'newTrain';
|
||
advance(s);
|
||
assert.equal(s.trays.size, 0, 'the train is held, not made up');
|
||
assert.equal(s.clock.phase, 'mainline', 'and the Stage moves on');
|
||
});
|
||
|
||
it('moves trains one region per Stage (§8.2)', () => {
|
||
const s = game();
|
||
/**
|
||
* PIN THE TERRAIN. This used to rely on whatever card the seed happened to lay down, and the
|
||
* moment the RNG stream moved — a different opening deal draws a different number of cards
|
||
* before the Division is built — seed 1 dealt a 60 card instead of a 30 and the train crossed in
|
||
* one Stage. What is under test is that crossing takes the card's time, so the card has to be
|
||
* the test's own: Curves is a 30, which is two Stages for a fast train.
|
||
*/
|
||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'curves';
|
||
scheduleTrain(s, 1, 2);
|
||
s.clock.phase = 'newTrain';
|
||
pump(s);
|
||
|
||
const id = [...s.trays.keys()][0]!;
|
||
s.trays.get(id)!.consist = [{ type: 'coach', loaded: false }];
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
const pos = s.trays.get(id)!.position;
|
||
assert.equal(pos.at, 'mainline', 'the train highballed onto the mainline');
|
||
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
// Q1/Q2 — crossing time is counted in Stages by card speed and Fast/Slow class, so the train
|
||
// stays on the card until its transit expires rather than stepping through printed cells.
|
||
const pos2 = s.trays.get(id)!.position;
|
||
assert.equal(pos2.at, 'mainline', 'still crossing');
|
||
});
|
||
|
||
it('orders movement by train number, Timetabled before Extra on a tie (Gap 5)', () => {
|
||
const s = game();
|
||
const order: number[] = [];
|
||
for (const [i, spec] of [
|
||
{ n: 9, extra: true },
|
||
{ n: 9, extra: false },
|
||
{ n: 3, extra: false },
|
||
].entries()) {
|
||
s.trays.set(`t${i}`, {
|
||
id: `t${i}`,
|
||
trainNumber: spec.n,
|
||
trainIsExtra: spec.extra,
|
||
engineAt: 0,
|
||
consist: [],
|
||
direction: 'east',
|
||
position: { at: 'divisionPoint', side: 'west' },
|
||
movesUsed: 0,
|
||
});
|
||
}
|
||
const sorted = [...s.trays.values()].sort((a, b) => {
|
||
const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
|
||
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
||
});
|
||
for (const t of sorted) order.push(t.trainIsExtra ? -t.trainNumber! : t.trainNumber!);
|
||
assert.deepEqual(order, [3, 9, -9], 'train 3, then Timetabled 9, then Extra X9');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('collisions are automatic (Gap 2)', () => {
|
||
it('collides when a train arrives with every A/D track occupied', () => {
|
||
// Gap 2d — no room at the station, the local player's fault, −5 Revenue.
|
||
const s = game();
|
||
const area = s.officeAreas.get(0)!;
|
||
area.adOccupancy = ['blocker']; // a Whistle Post has exactly one A/D track
|
||
|
||
const id = 'inbound';
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: 2,
|
||
trainIsExtra: false,
|
||
engineAt: 0,
|
||
consist: [{ type: 'coach', loaded: true }],
|
||
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();
|
||
advance(s);
|
||
|
||
assert.equal(s.players[0]!.revenue, -5, 'the fault is the local player’s (§10)');
|
||
assert.equal(s.collisionsToday, 1);
|
||
assert.ok(!s.trays.has(id), 'the train is removed');
|
||
});
|
||
|
||
it('does not collide when a coach is legally parked at the Office (v0.5.0 §A.4 exception)', () => {
|
||
// A coach may now be set out at the Office (§A.4's carve-out). It must not become a hazard to
|
||
// the next train in — that would punish exactly the thing the rule was written to allow.
|
||
const s = game();
|
||
const area = s.officeAreas.get(0)!;
|
||
area.adOccupancy = []; // an A/D track is free — only the fouling check is under test
|
||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||
officeCard.standing = [{ type: 'coach', loaded: false }];
|
||
|
||
const id = 'inbound';
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: 2,
|
||
trainIsExtra: false,
|
||
engineAt: 0,
|
||
consist: [{ type: 'coach', loaded: true }],
|
||
direction: 'east',
|
||
position: { at: 'mainline', index: 1 },
|
||
movesUsed: 0,
|
||
});
|
||
const ml = s.division.nodes[1];
|
||
if (ml?.kind === 'mainline') {
|
||
ml.card = 'plains';
|
||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||
}
|
||
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
|
||
assert.equal(s.collisionsToday, 0, 'a legally parked coach is not a hazard to the next arrival');
|
||
assert.ok(s.trays.has(id), 'the train is not written off');
|
||
});
|
||
|
||
it('still collides when a non-coach car is left fouling the Office track', () => {
|
||
// Reachable only by poking state directly — `canDropCarsAt` already refuses every non-coach
|
||
// drop at the Office, so this backstops the collision check itself, not a state a legal game
|
||
// can reach.
|
||
const s = game();
|
||
const area = s.officeAreas.get(0)!;
|
||
area.adOccupancy = [];
|
||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||
officeCard.standing = [{ type: 'boxcar', loaded: false }];
|
||
|
||
const id = 'inbound';
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: 2,
|
||
trainIsExtra: false,
|
||
engineAt: 0,
|
||
consist: [{ type: 'coach', loaded: true }],
|
||
direction: 'east',
|
||
position: { at: 'mainline', index: 1 },
|
||
movesUsed: 0,
|
||
});
|
||
const ml = s.division.nodes[1];
|
||
if (ml?.kind === 'mainline') {
|
||
ml.card = 'plains';
|
||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||
}
|
||
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
|
||
assert.equal(s.collisionsToday, 1);
|
||
assert.ok(!s.trays.has(id), 'the train is removed');
|
||
});
|
||
|
||
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
|
||
const s = game();
|
||
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
|
||
const classBefore = s.yards.classificationYard.length;
|
||
|
||
const mlx = s.division.nodes[1];
|
||
if (mlx?.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.
|
||
mlx.card = 'plains';
|
||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||
}
|
||
s.trays.set('x', {
|
||
id: 'x',
|
||
trainNumber: 8,
|
||
trainIsExtra: false,
|
||
engineAt: 0,
|
||
consist: [
|
||
{ type: 'hopper', loaded: true },
|
||
{ type: 'caboose', loaded: true },
|
||
],
|
||
direction: 'east',
|
||
position: { at: 'mainline', index: 1 },
|
||
movesUsed: 0,
|
||
});
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
|
||
assert.equal(s.yards.classificationYard.length, classBefore + 1, 'the hopper');
|
||
assert.ok(
|
||
s.yards.divisionYard.some((c) => c.type === 'caboose'),
|
||
'the caboose goes straight back to the Division Yard',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||
it('pauses the Mainline Phase for a following-train decision', () => {
|
||
const s = game();
|
||
// A train already on the first Mainline card, moving east.
|
||
s.trays.set('ahead', {
|
||
id: 'ahead',
|
||
trainNumber: 4,
|
||
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: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||
}
|
||
|
||
// A second train at the Western Division Point wanting to follow it.
|
||
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, true, 'the phase must stop and ask');
|
||
assert.notEqual(s.clock.pendingDecision, null);
|
||
assert.equal(s.clock.pendingDecision!.train, 'behind');
|
||
assert.equal((s.clock.pendingDecision as { occupiedBy: string }).occupiedBy, 'ahead');
|
||
});
|
||
|
||
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
|
||
const s = game();
|
||
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 being asserted here.
|
||
ml.card = 'plains';
|
||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||
}
|
||
|
||
s.trays.set('waiting', {
|
||
id: 'waiting',
|
||
trainNumber: 2,
|
||
trainIsExtra: false,
|
||
engineAt: 0,
|
||
consist: [],
|
||
direction: 'east',
|
||
position: { at: 'divisionPoint', side: 'west' },
|
||
movesUsed: 0,
|
||
});
|
||
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
|
||
assert.equal(s.clock.pendingDecision, null, 'no judgment call — the train simply holds');
|
||
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'divisionPoint', side: 'west' });
|
||
});
|
||
|
||
it('clears the decision once the Superintendent rules', () => {
|
||
const s = game();
|
||
s.clock.phase = 'mainline';
|
||
s.clock.pendingDecision = { kind: 'clearance', train: 'a', occupiedBy: 'b' };
|
||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||
assert.ok(r.ok);
|
||
assert.equal(s.clock.pendingDecision, null);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||
it('loses a timed Solitaire game that misses minCombinedRevenue', () => {
|
||
// minCombinedRevenue doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
||
const s = game(1, { minCombinedRevenue: 10 });
|
||
s.clock.day = s.config.days + 1;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
s.players[0]!.revenue = 0;
|
||
advance(s);
|
||
// PAUSES rather than finishes since Gitea#11: a days-based ending offers another Day, and the
|
||
// result is recorded either way. `official` is the frozen answer; `status` is only where play
|
||
// has got to. The two collision tests at the foot of this block are the contrast.
|
||
assert.equal(s.status, 'awaitingExtension');
|
||
assert.equal(s.outcome!.result, 'loss');
|
||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||
assert.equal(s.official!.outcome.reason, 'revenueFloor');
|
||
});
|
||
|
||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||
const s = game(1, { minCombinedRevenue: 10 });
|
||
s.clock.day = s.config.days + 1;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
s.players[0]!.revenue = 10;
|
||
advance(s);
|
||
assert.equal(s.status, 'awaitingExtension');
|
||
assert.equal(s.outcome!.result, 'win');
|
||
});
|
||
|
||
it('minCombinedRevenue = 0 disables the floor — any score wins once days run out', () => {
|
||
const s = game(1, { minCombinedRevenue: 0 });
|
||
s.clock.day = s.config.days + 1;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
s.players[0]!.revenue = 0;
|
||
advance(s);
|
||
assert.equal(s.status, 'awaitingExtension');
|
||
assert.equal(s.outcome!.result, 'win');
|
||
});
|
||
|
||
it('everyone loses in Competitive when the TABLE misses minCombinedRevenue, whoever scored best', () => {
|
||
const s = createGame({
|
||
id: 'g', seed: 2, config: baseConfig({ mode: 'competitive', minCombinedRevenue: 20 }),
|
||
playerNames: ['A', 'B'],
|
||
});
|
||
s.clock.day = s.config.days + 1;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
s.players[0]!.revenue = 5; // best individual score...
|
||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||
advance(s);
|
||
assert.equal(s.status, 'awaitingExtension');
|
||
assert.equal(s.outcome!.result, 'loss');
|
||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||
});
|
||
|
||
it('Co-op wins as one table score (summed Revenue), winner stays null', () => {
|
||
const s = createGame({
|
||
id: 'g', seed: 3, config: baseConfig({ mode: 'coop', minCombinedRevenue: 10 }),
|
||
playerNames: ['A', 'B'],
|
||
});
|
||
s.clock.day = s.config.days + 1;
|
||
s.clock.stage = STAGES_PER_DAY;
|
||
s.clock.phase = 'shiftChange';
|
||
s.players[0]!.revenue = 4;
|
||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||
advance(s);
|
||
assert.equal(s.status, 'awaitingExtension');
|
||
assert.equal(s.outcome!.result, 'win');
|
||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
||
});
|
||
|
||
it('ends immediately, mid-Stage, when collisions reach maxCollisionsPerDay', () => {
|
||
// Competitive/coop only, and immediate — not gated on the day boundary the way the revenue
|
||
// floor is. Stage 1, well short of STAGES_PER_DAY, proves it fires mid-day.
|
||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
|
||
s.collisionsToday = 2;
|
||
s.clock.stage = 1;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.status, 'finished');
|
||
assert.equal(s.outcome!.result, 'loss');
|
||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||
});
|
||
|
||
it('ends immediately when the running total reaches maxCollisionsTotal, even under the per-day cap', () => {
|
||
const s = game(1, { mode: 'coop', maxCollisionsPerDay: 0, maxCollisionsTotal: 3 });
|
||
s.collisionsTotal = 3;
|
||
s.clock.stage = 1;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.status, 'finished');
|
||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||
});
|
||
|
||
it('maxCollisionsPerDay = 0 and maxCollisionsTotal = 0 disable the collision floor entirely', () => {
|
||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||
s.collisionsToday = 99;
|
||
s.collisionsTotal = 99;
|
||
s.clock.stage = 1;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.notEqual(s.status, 'finished');
|
||
});
|
||
|
||
it('solitaire checks the collision floor too, like every other mode', () => {
|
||
/**
|
||
* REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks
|
||
* the collision floor, whatever the counts").
|
||
*
|
||
* The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on
|
||
* screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as
|
||
* live settings, and the text beside them said the game would end in a loss. A solitaire player
|
||
* could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's
|
||
* ruling is that the settings do what they say.
|
||
*/
|
||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||
s.collisionsToday = 99;
|
||
s.collisionsTotal = 99;
|
||
s.clock.stage = 1;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.equal(s.status, 'finished');
|
||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||
});
|
||
|
||
it('still lets a solitaire game switch the collision floor off with 0', () => {
|
||
// The disable path is what a player who does not want the new ending reaches for, so it has to
|
||
// work at one seat exactly as it does at four.
|
||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||
s.collisionsToday = 99;
|
||
s.collisionsTotal = 99;
|
||
s.clock.stage = 1;
|
||
s.clock.phase = 'shiftChange';
|
||
advance(s);
|
||
assert.notEqual(s.status, 'finished');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('MILESTONE: a full solitaire game runs headless', () => {
|
||
it('plays a short game to completion', () => {
|
||
const s = game(42);
|
||
playToCompletion(s, 42);
|
||
assert.equal(s.status, 'finished', 'the game must reach an outcome');
|
||
assert.ok(s.outcome, 'and record one');
|
||
assert.ok(s.clock.day >= 1);
|
||
});
|
||
|
||
it('terminates from many different seeds', () => {
|
||
// The driver must settle regardless of how the deck falls.
|
||
for (const seed of [1, 7, 23, 99, 256, 1013]) {
|
||
const s = game(seed);
|
||
playToCompletion(s, seed);
|
||
assert.equal(s.status, 'finished', `seed ${seed} did not finish`);
|
||
}
|
||
});
|
||
|
||
it('is fully reproducible from a seed', () => {
|
||
const a = game(555);
|
||
const b = game(555);
|
||
playToCompletion(a, 555);
|
||
playToCompletion(b, 555);
|
||
assert.equal(a.clock.day, b.clock.day);
|
||
assert.equal(a.players[0]!.revenue, b.players[0]!.revenue);
|
||
assert.deepEqual(a.outcome, b.outcome);
|
||
});
|
||
|
||
it('never offers the bot an illegal action along the way', () => {
|
||
// playToCompletion asserts this on every step; this test states the guarantee explicitly.
|
||
const s = game(31);
|
||
const stats = playToCompletion(s, 31);
|
||
assert.ok(stats.turns > 0, 'the game should take at least one turn');
|
||
});
|
||
|
||
it('actually plays the game — trains get scheduled and cards get played', () => {
|
||
// GUARD. An earlier bot completed every seed while doing nothing at all: no trains, no
|
||
// revenue, every game a loss. Asserting only on termination hid two real bugs. These assert
|
||
// the game DEVELOPS.
|
||
let scheduled = 0;
|
||
let cardsPlayed = 0;
|
||
for (const seed of [7, 42, 99, 256]) {
|
||
const s = game(seed);
|
||
const stats = playToCompletion(s, seed);
|
||
scheduled += stats.scheduled;
|
||
cardsPlayed += stats.cardsPlayed;
|
||
}
|
||
assert.ok(scheduled > 0, 'no train was ever scheduled across four games');
|
||
assert.ok(cardsPlayed > 4, `only ${cardsPlayed} cards played across four games`);
|
||
});
|
||
|
||
it('terminates even when the Superintendent denies clearance repeatedly', () => {
|
||
// A denied ruling must be CONSUMED. Without that the driver re-evaluates the same train and
|
||
// asks the same question forever — a livelock that only showed up on one seed.
|
||
const s = game(7);
|
||
const stats = playToCompletion(s, 7, 5_000);
|
||
assert.equal(s.status, 'finished');
|
||
assert.ok(stats.turns < 5_000, `took ${stats.turns} turns — probable livelock`);
|
||
});
|
||
|
||
it('conserves rolling stock across a whole game', () => {
|
||
// Nothing may be created or destroyed: every piece in the roster, wherever it sits.
|
||
const s = game(88);
|
||
playToCompletion(s, 88);
|
||
|
||
let count = s.yards.divisionYard.length + s.yards.classificationYard.length;
|
||
for (const tray of s.trays.values()) count += tray.consist.length;
|
||
for (const area of s.officeAreas.values()) {
|
||
for (const card of area.grid.values()) {
|
||
count += card.standing.length;
|
||
if (card.facility) {
|
||
count += card.facility.outboundBox.length;
|
||
count += card.facility.inboundBox.length;
|
||
count += card.facility.industryTrack.cars.length;
|
||
}
|
||
}
|
||
}
|
||
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
|
||
});
|
||
});
|
||
|
||
describe('X18 Circus / X17 Campaign — a point for setting up (Gitea#13)', () => {
|
||
/**
|
||
* REPORTED originally: "Circus train TX18 was stopped on a siding for a full Stage and I did not
|
||
* get my Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||
* NOWHERE, along with eight other special-train rules.
|
||
*
|
||
* REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
|
||
* ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
|
||
* is FULLY LOADED, and only in an Office Area at all.
|
||
*/
|
||
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
|
||
s.clock.phase = 'mainline';
|
||
s.trays.set('circus', {
|
||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||
consist, direction: 'east',
|
||
position: { at: 'grid', seat, coord },
|
||
movesUsed: 0,
|
||
} as never);
|
||
// A card under it, so the crew is somewhere real rather than off the grid.
|
||
areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
|
||
geometry: { kind: 'track', geometry: 'straight' },
|
||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||
} as never);
|
||
};
|
||
|
||
const loaded = [
|
||
{ type: 'boxcar', loaded: true },
|
||
{ type: 'boxcar', loaded: true },
|
||
{ type: 'coach', loaded: true },
|
||
{ type: 'caboose', loaded: true },
|
||
];
|
||
|
||
const runPhase = (s: GameState) => {
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
return pump(s);
|
||
};
|
||
|
||
it('pays a fully loaded Circus for a Stage spent set up', () => {
|
||
const s = game();
|
||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||
const before = s.players[0]!.revenue;
|
||
assert.ok(
|
||
pump(s).some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||
'the Circus Train stood still for a Stage and earned nothing',
|
||
);
|
||
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
|
||
});
|
||
|
||
it('pays once per Office Area, however long it parks there', () => {
|
||
// "Once per stop in an office area" — a train that goes on standing in the same district does
|
||
// not keep earning. This is the half that was already true, for a different reason.
|
||
const s = game();
|
||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||
pump(s);
|
||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||
'the Circus collected twice for the same set-up');
|
||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||
'the Circus collected a third time for the same set-up');
|
||
});
|
||
|
||
it('pays AGAIN in a different district — each player can be visited', () => {
|
||
/**
|
||
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
|
||
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
|
||
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
|
||
*/
|
||
const s = createGame({
|
||
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
|
||
});
|
||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||
pump(s);
|
||
const paidFirst = s.players.map((p) => p.revenue);
|
||
|
||
// The same train, moved into the other player's district.
|
||
const tray = s.trays.get('circus')!;
|
||
areaOf(s, 1 as never).grid.set('-1,0', {
|
||
geometry: { kind: 'track', geometry: 'straight' },
|
||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||
} as never);
|
||
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
|
||
|
||
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||
'the Circus set up in a second district and earned nothing');
|
||
const owner = s.seating[1]!;
|
||
assert.equal(
|
||
s.players[owner]!.revenue,
|
||
paidFirst[owner]! + 1,
|
||
'the point did not go to whoever sits in the district it stopped in',
|
||
);
|
||
});
|
||
|
||
it('pays nothing when the cars are empty — "not much of a circus"', () => {
|
||
const s = game();
|
||
circusAt(s, 0, { row: -1, col: 0 }, [
|
||
{ type: 'boxcar', loaded: false },
|
||
{ type: 'coach', loaded: true },
|
||
{ type: 'caboose', loaded: true },
|
||
]);
|
||
const before = s.players[0]!.revenue;
|
||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||
'an empty car aboard still collected the set-up point');
|
||
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
|
||
});
|
||
|
||
it('pays nothing to a train carrying nothing at all', () => {
|
||
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
|
||
// one a careless test would pay.
|
||
const s = game();
|
||
circusAt(s, 0, { row: -1, col: 0 }, []);
|
||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||
'a Circus carrying nothing was paid for setting up');
|
||
});
|
||
|
||
it('pays nothing for standing out on the Mainline', () => {
|
||
/**
|
||
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
|
||
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
|
||
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
|
||
*/
|
||
const s = game();
|
||
s.clock.phase = 'mainline';
|
||
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||
s.trays.set('circus', {
|
||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||
consist: loaded, direction: 'east',
|
||
position: { at: 'mainline', index },
|
||
movesUsed: 0,
|
||
} as never);
|
||
const before = s.players[0]!.revenue;
|
||
pump(s);
|
||
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
|
||
});
|
||
|
||
it('pays the Campaign Train only when its candidate is aboard', () => {
|
||
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
|
||
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
|
||
const occupied = game();
|
||
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
|
||
occupied.trays.get('circus')!.trainNumber = 17;
|
||
const beforeOccupied = occupied.players[0]!.revenue;
|
||
pump(occupied);
|
||
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
|
||
|
||
const empty = game();
|
||
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
|
||
empty.trays.get('circus')!.trainNumber = 17;
|
||
const beforeEmpty = empty.players[0]!.revenue;
|
||
pump(empty);
|
||
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a train on the Division points the way it is running', () => {
|
||
/**
|
||
* Nothing reset `facing` when a train left a district, so a crew that had been shunted onto a
|
||
* north-south spur carried a compass port — 'n' or 's' — out onto the Division with it.
|
||
*
|
||
* The Division is east-west, and so is every Office card, so that port exists nowhere the train is
|
||
* about to be. `movesFor` explores from `facing` and from its opposite, and a card with neither
|
||
* yields nothing at all: the train arrived at the next Office **unable to switch at all**. It also
|
||
* drew a ▲ on the Division map, where there is no north.
|
||
*
|
||
* A train running the Division has its engine at one end of an east-west railroad, so this is not
|
||
* a repair applied after the fact — it is the only thing `facing` can mean out there.
|
||
*/
|
||
const runningOnTheDivision = (spurFacing: 'n' | 's', direction: 'east' | 'west'): CrewTray => {
|
||
const s = game();
|
||
// Straight onto the Mainline card west of the first Office, as a departure would.
|
||
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||
const node = s.division.nodes[index]!;
|
||
assert.equal(node.kind, 'mainline');
|
||
const id = 'shunted';
|
||
s.trays.set(id, {
|
||
id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [],
|
||
direction,
|
||
// What a Move round a district leaves behind: a real port on the card it was standing on.
|
||
facing: spurFacing,
|
||
railFacing: 'w',
|
||
position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
|
||
movesUsed: 0,
|
||
});
|
||
const tray = s.trays.get(id)!;
|
||
// Depart it: the Mainline Phase's own path onto the card.
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
for (let i = 0; i < 3 && tray.position.at === 'grid'; i++) {
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
}
|
||
assert.notEqual(tray.position.at, 'grid', 'the train never left the district');
|
||
return tray;
|
||
};
|
||
|
||
it('drops the spur port the moment it reaches the Mainline', () => {
|
||
for (const spur of ['n', 's'] as const) {
|
||
for (const direction of ['east', 'west'] as const) {
|
||
const tray = runningOnTheDivision(spur, direction);
|
||
assert.equal(
|
||
tray.facing,
|
||
direction === 'east' ? 'e' : 'w',
|
||
`a ${direction}bound train left the district still facing ${spur}`,
|
||
);
|
||
assert.equal(railFacingOf(tray), direction === 'east' ? 'e' : 'w');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the history says WHY a train moved, and says it truthfully', () => {
|
||
/**
|
||
* REPORTED from play: "some trains seem to be moving before I can switch or do other operations on
|
||
* them — it may be the rules are wrong, or it may be my perception."
|
||
*
|
||
* It was perception, but the log was actively feeding it. Every departure read alike, and the
|
||
* arrival line for an Expedited train said there was "no turn in which to work it" — which was
|
||
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
|
||
* the Cargo turn they did have.
|
||
*
|
||
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
|
||
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
|
||
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
|
||
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
|
||
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
|
||
* change cannot silently bring back the old forced departure.
|
||
*/
|
||
/**
|
||
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
|
||
* it spends standing in a district — `advance` is called many times inside one phase, so a raw
|
||
* count would say nothing. Driven by the developer bot rather than a hand-rolled phase-ender,
|
||
* which deadlocks the moment Local Operations wants an option chosen before it can be ended.
|
||
*/
|
||
const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => {
|
||
const s = game(7, { days: 5 });
|
||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||
/**
|
||
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
|
||
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
|
||
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
|
||
* this test is trying to pin, so clearing the timetable isolates the rule from it.
|
||
*/
|
||
s.timetable = s.timetable.map(() => null);
|
||
const id = 'watched';
|
||
s.trays.set(id, {
|
||
id, trainNumber, trainIsExtra: false, engineAt: 0,
|
||
consist: [{ type: 'coach', loaded: true }] as never,
|
||
direction: 'east', facing: 'e',
|
||
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||
});
|
||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
|
||
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
|
||
|
||
const standingIn = new Set<string>();
|
||
let reachedOffice = false;
|
||
let leftIn = '';
|
||
|
||
for (let i = 0; i < 20_000; i++) {
|
||
const phase = s.clock.phase;
|
||
const onGrid = s.trays.get(id)?.position.at === 'grid';
|
||
if (onGrid) {
|
||
reachedOffice = true;
|
||
standingIn.add(`${s.clock.day}|${s.clock.stage}|${phase}`);
|
||
}
|
||
|
||
const r = advance(s);
|
||
|
||
if (reachedOffice && onGrid && s.trays.get(id)?.position.at !== 'grid' && leftIn === '') {
|
||
leftIn = phase;
|
||
break;
|
||
}
|
||
if (s.status === 'finished') break;
|
||
if (r.needsInput) {
|
||
/**
|
||
* A PLAYER WHO DOES NOTHING. The developer bot plays train cards, and a train card rolls
|
||
* itself onto the Timetable — so clearing the Timetable above achieved nothing while the bot
|
||
* was driving, and the watched train kept meeting traffic it had to be cleared past. This
|
||
* ends every turn without playing anything, which is the only way to isolate one train.
|
||
*/
|
||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||
if (actor === null) break;
|
||
const options = legalActions(s, actor);
|
||
if (options.length === 0) break;
|
||
const pick =
|
||
options.find((x) => x.type === 'mainline.clearance') ??
|
||
options.find(
|
||
(x) =>
|
||
x.type === 'switch.end' ||
|
||
x.type === 'draw.end' ||
|
||
x.type === 'loadUnload.end' ||
|
||
x.type === 'freightAgent.end',
|
||
) ??
|
||
options.find((x) => x.type === 'localOps.choose') ??
|
||
options[0]!;
|
||
if (!applyIntent(s, actor, pick).ok) break;
|
||
}
|
||
}
|
||
|
||
const count = (phase: string): number => [...standingIn].filter((k) => k.endsWith(`|${phase}`)).length;
|
||
assert.ok(reachedOffice, `train ${trainNumber} never reached a district at all`);
|
||
return { localOps: count('localOps'), loadUnload: count('loadUnload'), leftIn };
|
||
};
|
||
|
||
it('gives an ordinary train a Local Operations turn before it goes', () => {
|
||
// Train 12 Drag Freight — no Expedite. Arrives in a Mainline Phase, stands, and the player gets
|
||
// a Local Operations turn with it in the NEXT Stage. This is what the arrival line promises.
|
||
const r = phasesWith(12);
|
||
assert.ok(r.localOps >= 1, `an ordinary train got ${r.localOps} Local Operations turns`);
|
||
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
|
||
});
|
||
|
||
it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
|
||
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
|
||
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
|
||
// Local Operations turn the same as Train 12 above.
|
||
const r = phasesWith(6);
|
||
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
|
||
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
|
||
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
|
||
});
|
||
|
||
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
|
||
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
|
||
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
|
||
// switching, say — it must not still be there when the next Mainline Phase begins.
|
||
const s = game(7, { days: 5 });
|
||
const area = areaOf(s, 0);
|
||
const id = 'expedited';
|
||
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
|
||
s.trays.set(id, {
|
||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||
direction: 'east', facing: 'e',
|
||
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
|
||
});
|
||
|
||
s.clock.phase = 'mainline';
|
||
const before = s.players[0]!.revenue;
|
||
const r = advance(s);
|
||
|
||
assert.ok(
|
||
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
|
||
'no fault was raised for an Expedited train left off the station',
|
||
);
|
||
assert.equal(
|
||
s.players[0]!.revenue,
|
||
before - EXPEDITE_FAULT_PENALTY,
|
||
'the fault did not cost Revenue',
|
||
);
|
||
});
|
||
|
||
it('does not fault an Expedited train legitimately held at the station itself', () => {
|
||
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
|
||
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
|
||
const s = game(7, { days: 5 });
|
||
const area = areaOf(s, 0);
|
||
const id = 'expedited';
|
||
s.trays.set(id, {
|
||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||
direction: 'east', facing: 'e',
|
||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||
});
|
||
area.adOccupancy.push(id);
|
||
|
||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||
// which is the Superintendent's call rather than an absolute bar.
|
||
//
|
||
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
|
||
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
|
||
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
|
||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||
const node = s.division.nodes[ahead];
|
||
assert.equal(node?.kind, 'mainline');
|
||
s.trays.set('ahead', {
|
||
id: 'ahead', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||
});
|
||
if (node?.kind === 'mainline') {
|
||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||
}
|
||
|
||
s.clock.phase = 'mainline';
|
||
const before = s.players[0]!.revenue;
|
||
const r = advance(s);
|
||
|
||
assert.ok(
|
||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||
'the departure was not put to the Superintendent',
|
||
);
|
||
assert.ok(
|
||
!r.events.some((e) => e.type === 'expediteFault'),
|
||
'a train properly parked at the station was faulted anyway',
|
||
);
|
||
assert.equal(
|
||
s.players[0]!.revenue,
|
||
before,
|
||
'Revenue changed for a train legitimately held at the station',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||
/**
|
||
* REPORTED, v0.4.9e: "When extras are played the player doing so may choose where the extra
|
||
* starts. They may choose either division point. And if the interchange mainline card has been
|
||
* played, they may start the extra on that card and choose the direction from there. If there is
|
||
* potential for conflict with other trains in that area the superintendent may hold the extra."
|
||
*
|
||
* This SUPERSEDES the earlier ruling these tests used to assert — "the number decides, like
|
||
* everything else on the timetable" — for Extras only. The number still decides for a timetabled
|
||
* train. The reason the two cannot both hold: an odd (westbound) Extra placed at the WEST end
|
||
* would leave the Division on its first move having crossed nothing, and be paid for the run.
|
||
*
|
||
* So: the start decides the direction. A Division Point runs the train away from itself; in the
|
||
* middle of the railroad — an Interchange, a Control Point — the player says which way.
|
||
*/
|
||
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
|
||
const s = game(11);
|
||
// Queued by seat 0, who is therefore the one §7 lets place it.
|
||
s.pendingExtras = [{ trainNumber, player: 0 }];
|
||
s.timetable = s.timetable.map(() => null);
|
||
if (tier) s.officeAreas.get(0)!.tier = tier;
|
||
s.clock.phase = 'newTrain';
|
||
advance(s);
|
||
return s;
|
||
};
|
||
|
||
const started = (s: GameState): CrewTray => {
|
||
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
|
||
assert.ok(tray, 'the Extra never took a Crew Tray');
|
||
return tray;
|
||
};
|
||
|
||
/** Turn one Mainline card into an Interchange, and say which node it is. */
|
||
const withInterchange = (s: GameState): number => {
|
||
const i = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||
const node = s.division.nodes[i]!;
|
||
if (node.kind === 'mainline') node.card = 'interchange';
|
||
return i;
|
||
};
|
||
|
||
it('stops for the decision instead of launching the train itself', () => {
|
||
const s = pending(17);
|
||
assert.equal(s.clock.phase, 'newTrain');
|
||
assert.equal(s.trays.size, 0, 'the Extra was placed without anyone choosing where');
|
||
assert.ok(
|
||
legalActions(s, s.clock.currentActor ?? 0).some((i) => i.type === 'newTrain.startExtra'),
|
||
'the placement was never offered',
|
||
);
|
||
});
|
||
|
||
it('offers BOTH Division Points, not the one the number would dictate', () => {
|
||
const s = pending(17);
|
||
const sides = legalActions(s, 0)
|
||
.filter((i) => i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint')
|
||
.map((i) => (i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint' ? i.start.side : ''));
|
||
assert.deepEqual([...sides].sort(), ['east', 'west']);
|
||
});
|
||
|
||
it('runs an Extra AWAY from the Division Point it was placed at, whatever its number', () => {
|
||
// X17 is odd. Under the superseded rule it could only ever start at the East end and run west.
|
||
for (const [side, direction] of [['west', 'east'], ['east', 'west']] as const) {
|
||
const s = pending(17);
|
||
const r = applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side },
|
||
});
|
||
assert.ok(r.ok, `the ${side} Division Point was refused: ${r.ok ? '' : r.code}`);
|
||
const tray = started(s);
|
||
assert.equal(tray.direction, direction, `an Extra at the ${side} end must run ${direction}`);
|
||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, side);
|
||
}
|
||
});
|
||
|
||
it('refuses a Whistle Post, which is not a Control Point, at every setting of the house rule', () => {
|
||
for (const extraStart of ['divisionPointsOnly', 'ownOffice', 'anyOffice'] as const) {
|
||
const s = pending(18);
|
||
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
|
||
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
|
||
const code = check(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||
});
|
||
assert.ok(code !== null, `a Whistle Post was allowed under ${extraStart}`);
|
||
}
|
||
});
|
||
|
||
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
|
||
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
|
||
const s = pending(18, 'depot');
|
||
const r = applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'west',
|
||
});
|
||
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
|
||
const tray = started(s);
|
||
const area = s.officeAreas.get(0)!;
|
||
assert.equal(tray.position.at, 'grid');
|
||
assert.deepEqual(
|
||
tray.position.at === 'grid' ? tray.position.coord : null,
|
||
area.officeCoord,
|
||
'the Extra did not start on the Office card',
|
||
);
|
||
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
|
||
// The point of the change: an EVEN Extra running WEST, because the player said so.
|
||
assert.equal(tray.direction, 'west', 'the direction the player chose was not honoured');
|
||
});
|
||
|
||
it('needs a direction anywhere that is not an end of the Division', () => {
|
||
const s = pending(18, 'depot');
|
||
assert.equal(
|
||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 } }),
|
||
'NO_DIRECTION_CHOSEN',
|
||
);
|
||
});
|
||
|
||
it('honours the extraStart house rule for Office starts, and never for the shared ground', () => {
|
||
for (const [extraStart, code] of [
|
||
['divisionPointsOnly', 'OFFICE_STARTS_NOT_ALLOWED'],
|
||
['anyOffice', null],
|
||
] as const) {
|
||
const s = pending(18, 'depot');
|
||
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
|
||
assert.equal(
|
||
check(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||
}),
|
||
code,
|
||
`office start under ${extraStart}`,
|
||
);
|
||
// The Division Points belong to nobody, so no setting ever closes them.
|
||
assert.equal(
|
||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'divisionPoint', side: 'west' } }),
|
||
null,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('starts at an Interchange in the players yard, not out on the running line', () => {
|
||
const s = pending(18);
|
||
const node = withInterchange(s);
|
||
const r = applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||
});
|
||
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
|
||
const tray = started(s);
|
||
const card = s.division.nodes[node]!;
|
||
assert.equal(tray.direction, 'east');
|
||
assert.deepEqual(tray.position, { at: 'mainline', index: node });
|
||
assert.ok(card.kind === 'mainline' && card.holding?.includes(tray.id), 'it is not in the yard');
|
||
assert.equal(card.kind === 'mainline' && card.transits.length, 0, 'it was put on the running line');
|
||
});
|
||
|
||
it('refuses any Mainline card that is not an Interchange', () => {
|
||
const s = pending(18);
|
||
const plains = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||
const node = s.division.nodes[plains]!;
|
||
if (node.kind === 'mainline') node.card = 'plains';
|
||
assert.equal(
|
||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node: plains }, direction: 'east' }),
|
||
'NOT_AN_INTERCHANGE',
|
||
);
|
||
});
|
||
|
||
it('may be started at an Interchange however busy the card is — the yard forces no collision', () => {
|
||
const s = pending(18);
|
||
const node = withInterchange(s);
|
||
const card = s.division.nodes[node]!;
|
||
// Nose to tail with opposing traffic. §7: placing here still must not force a collision.
|
||
if (card.kind === 'mainline') {
|
||
card.transits.push({ tray: 'tray9', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||
}
|
||
assert.equal(
|
||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east' }),
|
||
null,
|
||
);
|
||
});
|
||
|
||
/**
|
||
* §7's last clause, and the reason the Interchange start is modelled as a yard at all: "If there
|
||
* is potential for conflict with other trains in that area the superintendent may hold the extra."
|
||
*
|
||
* Jesse's split: a GUARANTEED collision holds the train at the Interchange for another Stage and
|
||
* it tries again; a POTENTIAL one is the Superintendent's to rule on. Those are exactly §8.1's
|
||
* two answers, so the Extra highballs out of the yard through `evaluateClearance` — the same
|
||
* check a train leaving a Division Point goes through — rather than through anything new.
|
||
*/
|
||
describe('highballing out of the Interchange yard', () => {
|
||
/** A pending X18 sitting in the yard of an Interchange, with the terrain pinned. */
|
||
const inYard = (): { s: GameState; node: number; tray: CrewTray } => {
|
||
const s = pending(18);
|
||
const node = withInterchange(s);
|
||
const r = applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||
});
|
||
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
return { s, node, tray: started(s) };
|
||
};
|
||
|
||
const card = (s: GameState, node: number): Extract<DivisionNode, { kind: 'mainline' }> => {
|
||
const n = s.division.nodes[node]!;
|
||
assert.equal(n.kind, 'mainline');
|
||
return n as Extract<DivisionNode, { kind: 'mainline' }>;
|
||
};
|
||
|
||
it('pulls out onto the card at the next Mainline Phase when the Subdivision is clear', () => {
|
||
const { s, node, tray } = inYard();
|
||
advance(s);
|
||
const n = card(s, node);
|
||
assert.deepEqual(n.holding, [], 'it never left the yard');
|
||
assert.ok(n.transits.some((t) => t.tray === tray.id), 'it is not on the running line');
|
||
});
|
||
|
||
it('is held in the yard by a facing train, and tries again the next Stage', () => {
|
||
const { s, node, tray } = inYard();
|
||
// Westbound, against an eastbound Extra: §8.1 calls that an absolute bar, not a judgment call.
|
||
card(s, node).transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||
s.trays.set('facing', {
|
||
id: 'facing', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [],
|
||
direction: 'west', position: { at: 'mainline', index: node }, movesUsed: 0,
|
||
});
|
||
|
||
const r = advance(s);
|
||
assert.equal(r.needsInput ?? false, false, 'a guaranteed collision is not a question to ask');
|
||
assert.equal(s.clock.pendingDecision, null);
|
||
assert.ok(card(s, node).holding?.includes(tray.id), 'it was not held in the yard');
|
||
assert.ok(
|
||
!card(s, node).transits.some((t) => t.tray === tray.id),
|
||
'it pulled out in front of a train coming the other way',
|
||
);
|
||
assert.ok(s.trays.has(tray.id), 'the Extra was destroyed rather than held');
|
||
|
||
// AND IT TRIES AGAIN. The Extra waits in the yard, not on the pending list, so once the road
|
||
// clears the next Mainline Phase takes it out with no further intervention.
|
||
s.trays.delete('facing');
|
||
card(s, node).transits = [];
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
assert.deepEqual(card(s, node).holding, [], 'it did not try again once the road was clear');
|
||
assert.ok(card(s, node).transits.some((t) => t.tray === tray.id), 'it never pulled out');
|
||
});
|
||
|
||
it('puts a following train to the Superintendent rather than holding it automatically', () => {
|
||
const { s, node, tray } = inYard();
|
||
// Same direction: §8.1's judgment call, which is what "may hold the extra" means.
|
||
card(s, node).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: node }, movesUsed: 0,
|
||
});
|
||
|
||
const r = advance(s);
|
||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||
assert.equal(s.clock.pendingDecision?.train, tray.id);
|
||
assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead');
|
||
|
||
// HOLD keeps it in the yard.
|
||
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
||
advance(s);
|
||
assert.ok(card(s, node).holding?.includes(tray.id), 'the Superintendent held it and it left anyway');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* FOUND BY PLAYING IT, not by the tests above: an Extra started anywhere but a Division Point was
|
||
* never offered a car and ran empty.
|
||
*
|
||
* `isBeingMadeUp` asked only "is this tray standing at a Division Point", which was the whole
|
||
* truth while that was the only place to build a train. The Control Point start has therefore
|
||
* shipped since it was added with a train that could not be loaded, and the Interchange start
|
||
* would have shipped the same way — against Jesse's report, which says an Extra started at the
|
||
* Interchange "would be Loaded with cars".
|
||
*/
|
||
describe('an Extra started away from a Division Point can still be made up', () => {
|
||
const fill = (s: GameState): string[] => {
|
||
for (let i = 0; i < MAX_CONSIST + 1; i++) {
|
||
const options = legalActions(s, s.clock.currentActor ?? 0).filter((a) => a.type === 'newTrain.placeCar');
|
||
if (options.length === 0) break;
|
||
assert.ok(applyIntent(s, s.clock.currentActor ?? 0, options[0]!).ok);
|
||
}
|
||
return started(s).consist.map((c) => c.type);
|
||
};
|
||
|
||
it('takes a consist in the Interchange yard', () => {
|
||
const s = pending(18);
|
||
const node = withInterchange(s);
|
||
assert.ok(applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||
}).ok);
|
||
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
|
||
});
|
||
|
||
it('takes a consist at a Control Point', () => {
|
||
const s = pending(18, 'depot');
|
||
assert.ok(applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||
}).ok);
|
||
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
|
||
});
|
||
|
||
it('stops being made up the moment it starts running', () => {
|
||
// Otherwise a train out on the Mainline could be handed cars from the Division Yard — the
|
||
// "cars appearing on a train nobody was making up" bug `isBeingMadeUp` exists to prevent.
|
||
const s = pending(18);
|
||
const node = withInterchange(s);
|
||
assert.ok(applyIntent(s, 0, {
|
||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||
}).ok);
|
||
const tray = started(s);
|
||
s.clock.phase = 'mainline';
|
||
s.movedThisPhase = new Set();
|
||
advance(s);
|
||
assert.equal(s.trays.get(tray.id)?.beingMadeUp, undefined, 'a running train is still being made up');
|
||
assert.equal(isBeingMadeUp(s.trays.get(tray.id)!), false);
|
||
});
|
||
});
|
||
|
||
it('replays a save written before the choice existed exactly as it meant it', () => {
|
||
/**
|
||
* A save is a seed and a list of intents, so an intent whose meaning moves is a save that
|
||
* quietly replays as a different game. The legacy shape carried only `atSeat`: null meant the
|
||
* Division Point the NUMBER sent it to, running in the number's direction.
|
||
*/
|
||
const s = pending(17);
|
||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||
const tray = started(s);
|
||
assert.equal(tray.direction, 'west', 'the legacy intent stopped meaning what it meant');
|
||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
|
||
});
|
||
|
||
it('takes the Extra off the pending list once, whichever end it started from', () => {
|
||
const s = pending(17);
|
||
const at = { type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side: 'east' } } as const;
|
||
assert.ok(applyIntent(s, 0, at).ok);
|
||
assert.deepEqual(s.pendingExtras, []);
|
||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
|
||
/**
|
||
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
|
||
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
|
||
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
|
||
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
|
||
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
|
||
*/
|
||
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
|
||
const s = game(7, { days: 5 });
|
||
const area = areaOf(s, 0);
|
||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||
s.trays.set('departing', {
|
||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||
direction: 'west', facing: 'w',
|
||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||
});
|
||
area.adOccupancy.push('departing');
|
||
const card = s.division.nodes.findIndex((n, i) =>
|
||
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
|
||
const node = s.division.nodes[card];
|
||
assert.equal(node?.kind, 'mainline');
|
||
s.trays.set('other', {
|
||
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
|
||
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
|
||
position: { at: 'mainline', index: card }, movesUsed: 0,
|
||
});
|
||
if (node?.kind === 'mainline') {
|
||
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
|
||
}
|
||
s.clock.phase = 'mainline';
|
||
return s;
|
||
};
|
||
|
||
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
|
||
const s = setup({ direction: 'west', side: 'east', number: 18 });
|
||
const r = advance(s);
|
||
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
|
||
assert.ok(
|
||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||
'the departing train did not highball with nothing ahead of it',
|
||
);
|
||
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
|
||
});
|
||
|
||
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
|
||
const s = setup({ direction: 'east', side: 'east', number: 18 });
|
||
const r = advance(s);
|
||
assert.ok(
|
||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||
'a train moving away behind it held the departure',
|
||
);
|
||
});
|
||
|
||
it('still puts a same-direction train AHEAD to the Superintendent', () => {
|
||
const s = setup({ direction: 'west', side: 'west', number: 18 });
|
||
const r = advance(s);
|
||
assert.ok(
|
||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
|
||
'a train the departing one would follow was not put to the Superintendent',
|
||
);
|
||
});
|
||
});
|