Initial commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 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 } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'short',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: 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;
|
||||
|
||||
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' });
|
||||
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();
|
||||
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);
|
||||
const pos2 = s.trays.get(id)!.position;
|
||||
assert.ok(pos2.at === 'mainline' && pos2.region === 1, 'one region per Stage');
|
||||
});
|
||||
|
||||
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,
|
||||
engineFront: true,
|
||||
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,
|
||||
engineFront: true,
|
||||
consist: [{ type: 'coach', loaded: true }],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1, region: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = id;
|
||||
|
||||
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('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;
|
||||
|
||||
s.trays.set('x', {
|
||||
id: 'x',
|
||||
trainNumber: 8,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
consist: [
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1, region: 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,
|
||||
engineFront: true,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'mainline', index: 1, region: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = 'ahead';
|
||||
|
||||
// A second train at the Western Division Point wanting to follow it.
|
||||
s.trays.set('behind', {
|
||||
id: 'behind',
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
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!.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,
|
||||
engineFront: true,
|
||||
consist: [],
|
||||
direction: 'west',
|
||||
position: { at: 'mainline', index: 1, region: 0 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[0]!.occupant = 'oncoming';
|
||||
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting',
|
||||
trainNumber: 2,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
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 = { 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)', () => {
|
||||
it('loses a timed Solitaire game that misses the target', () => {
|
||||
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
||||
const s = game(1);
|
||||
s.clock.day = lengthProfile('short').days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('wins a timed Solitaire game that clears the target', () => {
|
||||
const s = game(1);
|
||||
s.clock.day = lengthProfile('short').days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = lengthProfile('short').target;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
it('ends a first-to-target game the moment the target is reached', () => {
|
||||
const s = game(1, { victory: 'firstToTarget' });
|
||||
s.players[0]!.revenue = lengthProfile('short').target;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'targetReached');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: 62 pieces, wherever they sit.
|
||||
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, 62, 'rolling stock leaked or was duplicated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Build order step 3 — "Individual actions apply correctly; illegal ones rejected."
|
||||
* See architecture/components.md §4.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 77): GameState =>
|
||||
createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||||
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
/** Puts a tray on the player's grid so switching intents have something to act on. */
|
||||
function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: null,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
consist,
|
||||
direction: 'east',
|
||||
position: { at: 'grid', owner: 0, coord },
|
||||
movesUsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('turn and phase gating', () => {
|
||||
it('rejects an intent from a player who is not the actor', () => {
|
||||
const s = game();
|
||||
s.clock.currentActor = 1;
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
|
||||
});
|
||||
|
||||
it('rejects an intent belonging to another phase', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'loadUnload';
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||||
});
|
||||
|
||||
it('rejects everything once the game is finished', () => {
|
||||
const s = game();
|
||||
s.status = 'finished';
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: the three-way exclusive choice (§6)', () => {
|
||||
it('accepts a first choice and records it', () => {
|
||||
const s = game();
|
||||
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.turn.option, 'draw');
|
||||
});
|
||||
|
||||
it('forecloses the other two options for the Stage', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'localOps.choose', option: 'switch' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses sub-intents of an option that was not chosen', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: drawing (§6.2)', () => {
|
||||
it('draws from the Home Office deck into the hand', () => {
|
||||
const s = game();
|
||||
const before = s.decks.homeOffice.length;
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.decks.homeOffice.length, before - 1);
|
||||
assert.equal(s.decks.hands.get(0)!.length, 4);
|
||||
});
|
||||
|
||||
it('takes the face-up card from a Department slot', () => {
|
||||
const s = game();
|
||||
const target = s.decks.departments[1]!;
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(s.decks.hands.get(0)!.includes(target));
|
||||
assert.equal(s.decks.departments[1], null);
|
||||
});
|
||||
|
||||
it('allows only one draw per Stage', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN');
|
||||
});
|
||||
|
||||
it('will not end the phase over the hand limit', () => {
|
||||
// §6.2 — "must reduce his hand to no more than three cards".
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1);
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||||
});
|
||||
|
||||
it('discards face up onto a Department slot, restoring the limit', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||||
const spare = s.decks.hands.get(0)![0]!;
|
||||
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.decks.departments[0], spare, 'discards go face up onto a Department');
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||||
});
|
||||
|
||||
it('refuses to play a card that is not in hand', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
|
||||
'CARD_NOT_IN_HAND',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Office upgrades (Gap 3b, Gap 8)', () => {
|
||||
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'office' && card.kind.tier === tier) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error('no such office card');
|
||||
}
|
||||
|
||||
it('upgrades a Whistle Post to a Depot', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(areaOf(s, 0).tier, 'depot');
|
||||
});
|
||||
|
||||
it('refuses to skip a tier', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'station');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
|
||||
});
|
||||
|
||||
it('preserves every grid connection through an upgrade', () => {
|
||||
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
|
||||
// Secondary Track hanging off the Office.
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), straight());
|
||||
const gridBefore = new Map(areaOf(s, 0).grid);
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
|
||||
const gridAfter = areaOf(s, 0).grid;
|
||||
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
|
||||
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
|
||||
});
|
||||
|
||||
it('grants the Depot its Porters and slots on upgrade', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
|
||||
assert.equal(office.facility!.porters, 1);
|
||||
assert.equal(office.facility!.capacity.inbound, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: switching (§6.1, Appendix A)', () => {
|
||||
it('moves a tray and spends a Move', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
|
||||
});
|
||||
|
||||
it('couples standing cars automatically and mandatorily', () => {
|
||||
// §A.4 — "you MUST pick them up".
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
|
||||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||||
});
|
||||
|
||||
it('refuses a move to an unreachable card', () => {
|
||||
const s = game();
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
|
||||
'ILLEGAL_MOVE',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs out of Moves after six', () => {
|
||||
const s = game();
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
s.turn.movesRemaining = 0;
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
|
||||
'NO_MOVES_REMAINING',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops cars from the seated end, in order', () => {
|
||||
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 2), [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: true },
|
||||
]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
|
||||
assert.ok(r.ok);
|
||||
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
|
||||
assert.equal(left.length, 1);
|
||||
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
|
||||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||||
});
|
||||
|
||||
it('refuses to drop cars at the Office', () => {
|
||||
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
||||
const s = game();
|
||||
const office = areaOf(s, 0).officeCoord;
|
||||
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
|
||||
'CANNOT_DROP_HERE',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Freight Agent operations (§6.3)', () => {
|
||||
function withFacility(s: GameState): GridCoord {
|
||||
const coord = at(1, 0);
|
||||
addCard(s, coord, {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
return coord;
|
||||
}
|
||||
|
||||
it('stocks the green Outbound box from the Division Yard', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const before = s.yards.divisionYard.length;
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.yards.divisionYard.length, before - 1);
|
||||
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
|
||||
});
|
||||
|
||||
it('allows only one Freight Agent operation per Stage', () => {
|
||||
// This is the bottleneck the whole economy rests on (card-reference.md §7).
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to overfill the Outbound box', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.outboundBox = [
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'hopper', loaded: true },
|
||||
];
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'BOX_FULL',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to load a facility that only unloads', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.allows = { outbound: false, inbound: true };
|
||||
// Give it something to clear, so the Freight Agent option itself remains available.
|
||||
f.inboundBox = [{ type: 'hopper', loaded: true }];
|
||||
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
assert.ok(chose.ok);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'NO_SUCH_FACILITY',
|
||||
);
|
||||
});
|
||||
|
||||
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
|
||||
// A player with no Facility cannot choose an option that has no possible follow-up.
|
||||
const s = game();
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
|
||||
'NO_SUCH_FACILITY',
|
||||
);
|
||||
});
|
||||
|
||||
it('makes the switch option unavailable with no train to switch', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
function facilityWithLoad(s: GameState): GridCoord {
|
||||
const coord = at(1, 0);
|
||||
addCard(s, coord, {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||||
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
s.clock.phase = 'loadUnload';
|
||||
return coord;
|
||||
}
|
||||
|
||||
it('advances a load one box per Laborer action', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
|
||||
assert.ok(r.ok);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
assert.equal(f.menAtWork[0], null);
|
||||
assert.notEqual(f.menAtWork[1], null);
|
||||
assert.equal(f.usedThisStage.laborers, 1);
|
||||
});
|
||||
|
||||
it('spends each Laborer only once per Stage', () => {
|
||||
// §9.1 — Laborers and Porters may be used once each in a Stage.
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.usedThisStage.laborers = f.laborers;
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
|
||||
'RESOURCE_SPENT',
|
||||
);
|
||||
});
|
||||
|
||||
it('scores exactly one Revenue when a load comes off WORK', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||||
|
||||
assert.equal(s.players[0]!.revenue, 0);
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
|
||||
assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
|
||||
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
|
||||
});
|
||||
|
||||
it('will not complete a load with no empty car spotted', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||||
f.industryTrack.cars = [];
|
||||
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
|
||||
});
|
||||
|
||||
it('takes four Laborer actions in total for one point', () => {
|
||||
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.laborers = 99; // isolate the pipeline from the per-Stage cap
|
||||
|
||||
let actions = 0;
|
||||
for (let box = 0; box < 3; box++) {
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
|
||||
assert.ok(r.ok, `advance from box ${box}`);
|
||||
actions++;
|
||||
}
|
||||
assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
|
||||
assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
it('is refused when no decision is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
|
||||
});
|
||||
|
||||
it('is accepted out of turn order, because it interrupts an automatic phase', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
});
|
||||
|
||||
it('is refused to a player who is not the Superintendent', () => {
|
||||
const s = game();
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.superintendent = 1;
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
it('offers only intents that apply would accept', () => {
|
||||
// The whole discipline of legal.ts in one assertion.
|
||||
const s = game();
|
||||
for (const i of legalActions(s, 0)) {
|
||||
assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
const chooseOptions = (s: GameState) =>
|
||||
legalActions(s, 0)
|
||||
.filter((i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose')
|
||||
.map((i) => i.option)
|
||||
.sort();
|
||||
|
||||
it('offers only "draw" on the opening Stage', () => {
|
||||
// §6 — the other two options have no possible follow-up yet: a player opens with no Facility
|
||||
// and no train. This is why the very first Stages are forced development.
|
||||
assert.deepEqual(chooseOptions(game()), ['draw']);
|
||||
});
|
||||
|
||||
it('offers all three once the player has a facility and a train', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
placeTray(s, at(0, 2));
|
||||
addCard(s, at(1, 0), {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||||
});
|
||||
|
||||
it('narrows to one option once a choice is made', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const kinds = new Set(legalActions(s, 0).map((i) => i.type));
|
||||
assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
|
||||
assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
|
||||
assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
|
||||
});
|
||||
|
||||
it('offers reachable destinations for a placed tray', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
|
||||
assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
|
||||
for (const m of moves) assert.equal(check(s, 0, m), null);
|
||||
assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
|
||||
});
|
||||
|
||||
it('offers nothing illegal after the game ends', () => {
|
||||
const s = game();
|
||||
s.status = 'finished';
|
||||
assert.equal(legalActions(s, 0).length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Build order step 1 — "Card data loads; a game state can be constructed and seeded."
|
||||
* See architecture/components.md §4.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
FREIGHT_PROFILES,
|
||||
LENGTH_PROFILES,
|
||||
MAX_CONSIST,
|
||||
OFFICE_PROFILES,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
TIMETABLED_TRAINS,
|
||||
TOTAL_ROLLING_STOCK,
|
||||
crewTrayCount,
|
||||
deckComposition,
|
||||
isFreightHouse,
|
||||
lengthProfile,
|
||||
mainlineCardCount,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { createRng } from '../src/engine/rng.ts';
|
||||
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import { subdivisions } from '../src/engine/state.ts';
|
||||
|
||||
const solitaireConfig: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const newSolitaireGame = (seed = 1234) =>
|
||||
createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes a 52-card deck', () => {
|
||||
const total = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
assert.equal(total, DECK_SIZE);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches card-reference.md deck composition exactly', () => {
|
||||
const byCategory = Object.fromEntries(
|
||||
deckComposition().map((c) => [c.category, c.count]),
|
||||
);
|
||||
assert.deepEqual(byCategory, {
|
||||
timetabledTrain: 12,
|
||||
extraTrain: 4,
|
||||
office: 9,
|
||||
freightFacility: 10,
|
||||
modifier: 5,
|
||||
track: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
|
||||
assert.equal(TIMETABLED_TRAINS.length, 12);
|
||||
for (const t of TIMETABLED_TRAINS) {
|
||||
assert.equal(t.direction, t.number % 2 === 1 ? 'west' : 'east', `train ${t.number}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets a consist exceed four slots, caboose included', () => {
|
||||
// §A.4 + Gap 4c — the most likely off-by-one in the whole model.
|
||||
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
|
||||
const slots = t.consist.count + (t.consist.requiresCaboose ? 1 : 0);
|
||||
assert.ok(slots <= MAX_CONSIST, `${t.className} ${t.number} uses ${slots} slots`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives Extras low seniority (X9-X12)', () => {
|
||||
assert.deepEqual(EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b), [9, 10, 11, 12]);
|
||||
});
|
||||
|
||||
it('identifies Freight Houses as the both-direction facilities', () => {
|
||||
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
|
||||
});
|
||||
|
||||
it('keeps every industry track within the four-car limit', () => {
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
assert.ok(f.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a facility capacity only in the directions it allows', () => {
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
const wantsOut = f.flow === 'outbound' || f.flow === 'both';
|
||||
const wantsIn = f.flow === 'inbound' || f.flow === 'both';
|
||||
assert.equal(f.outboundCapacity > 0, wantsOut, `${f.name} outbound`);
|
||||
assert.equal(f.inboundCapacity > 0, wantsIn, `${f.name} inbound`);
|
||||
}
|
||||
});
|
||||
|
||||
it('makes only the Whistle Post a non-Control-Point', () => {
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
assert.equal(o.isControlPoint, o.tier !== 'whistlePost', o.tier);
|
||||
assert.equal(o.isPassengerFacility, o.tier !== 'whistlePost', o.tier);
|
||||
}
|
||||
});
|
||||
|
||||
it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
|
||||
assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
if (o.tier === 'whistlePost') continue;
|
||||
assert.ok(o.greenSlots > o.porters, `${o.tier} green slots vs porters`);
|
||||
assert.ok(o.redSlots > o.porters, `${o.tier} red slots vs porters`);
|
||||
}
|
||||
});
|
||||
|
||||
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
||||
// Gap 3b requires more Depots than Stations than Terminals.
|
||||
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
|
||||
assert.deepEqual(inDeck, [4, 3, 2]);
|
||||
});
|
||||
|
||||
it('walks the upgrade sequence without skipping', () => {
|
||||
assert.equal(nextOfficeTier('whistlePost'), 'depot');
|
||||
assert.equal(nextOfficeTier('depot'), 'station');
|
||||
assert.equal(nextOfficeTier('station'), 'terminal');
|
||||
assert.equal(nextOfficeTier('terminal'), null);
|
||||
});
|
||||
|
||||
it('supplies 62 rolling stock pieces', () => {
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 62);
|
||||
assert.equal(buildRollingStock().length, 62);
|
||||
});
|
||||
|
||||
it('never demands more cars than the supply can furnish', () => {
|
||||
// card-reference.md §8 supply check, as an executable assertion.
|
||||
const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
|
||||
const demand = new Map<CarType, number>();
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
const need = (f.outboundCapacity + f.inboundCapacity) * f.copies;
|
||||
demand.set(f.carType, (demand.get(f.carType) ?? 0) + need);
|
||||
}
|
||||
for (const [type, need] of demand) {
|
||||
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the Gap 10e revised victory targets', () => {
|
||||
assert.deepEqual(
|
||||
LENGTH_PROFILES.map((p) => [p.target, p.days]),
|
||||
[[10, 3], [20, 5], [45, 10]],
|
||||
);
|
||||
assert.equal(lengthProfile('standard').target, 20);
|
||||
});
|
||||
|
||||
it('scales fixed supplies with player count', () => {
|
||||
assert.equal(mainlineCardCount(1), 2);
|
||||
assert.equal(mainlineCardCount(3), 4);
|
||||
assert.equal(crewTrayCount(1), 4);
|
||||
assert.equal(crewTrayCount(3), 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('seeded RNG (component 7)', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = createRng(42);
|
||||
const b = createRng(42);
|
||||
const drawsA = Array.from({ length: 50 }, () => a.nextInt(1000));
|
||||
const drawsB = Array.from({ length: 50 }, () => b.nextInt(1000));
|
||||
assert.deepEqual(drawsA, drawsB);
|
||||
});
|
||||
|
||||
it('differs between seeds', () => {
|
||||
const a = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(1)));
|
||||
const b = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(2)));
|
||||
assert.notDeepEqual(a, b);
|
||||
});
|
||||
|
||||
it('rolls a D12 strictly within 1..12', () => {
|
||||
const rng = createRng(7);
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const roll = rng.d12();
|
||||
assert.ok(roll >= 1 && roll <= 12, `roll out of range: ${roll}`);
|
||||
seen.add(roll);
|
||||
}
|
||||
assert.equal(seen.size, 12, 'every face should appear over 5000 rolls');
|
||||
});
|
||||
|
||||
it('shuffles without mutating or losing elements', () => {
|
||||
const rng = createRng(99);
|
||||
const input = Array.from({ length: 52 }, (_, i) => i);
|
||||
const out = rng.shuffle(input);
|
||||
assert.deepEqual(input, Array.from({ length: 52 }, (_, i) => i), 'input mutated');
|
||||
assert.deepEqual([...out].sort((x, y) => x - y), input, 'elements lost');
|
||||
assert.notDeepEqual(out, input, 'shuffle was a no-op');
|
||||
});
|
||||
|
||||
it('rejects an invalid bound', () => {
|
||||
const rng = createRng(1);
|
||||
assert.throws(() => rng.nextInt(0));
|
||||
assert.throws(() => rng.nextInt(-3));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('game setup (component 2)', () => {
|
||||
it('constructs a solitaire game from a seed', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.status, 'active');
|
||||
assert.equal(g.players.length, 1);
|
||||
assert.equal(g.clock.day, 1);
|
||||
assert.equal(g.clock.stage, 1);
|
||||
assert.equal(g.clock.phase, 'localOps');
|
||||
assert.equal(g.clock.superintendent, 0);
|
||||
assert.equal(g.clock.currentActor, 0);
|
||||
});
|
||||
|
||||
it('is reproducible from the same seed and differs on another', () => {
|
||||
assert.deepEqual(newSolitaireGame(5).decks.homeOffice, newSolitaireGame(5).decks.homeOffice);
|
||||
assert.notDeepEqual(
|
||||
newSolitaireGame(5).decks.homeOffice,
|
||||
newSolitaireGame(6).decks.homeOffice,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the MVP division: DP - ML - Office - ML - DP', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.deepEqual(
|
||||
g.division.nodes.map((n) => n.kind),
|
||||
['divisionPoint', 'mainline', 'office', 'mainline', 'divisionPoint'],
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every mainline card two regions', () => {
|
||||
const g = newSolitaireGame();
|
||||
for (const node of g.division.nodes) {
|
||||
if (node.kind === 'mainline') assert.equal(node.regions.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens with the whole railroad as one Subdivision', () => {
|
||||
// §8 — every Office is a Whistle Post, which is not a Control Point.
|
||||
const g = newSolitaireGame();
|
||||
const subs = subdivisions(g);
|
||||
assert.equal(subs.length, 1, 'expected exactly one Subdivision at game start');
|
||||
});
|
||||
|
||||
it('starts each player on a Whistle Post with three cards in a row', () => {
|
||||
const g = newSolitaireGame();
|
||||
const area = g.officeAreas.get(0)!;
|
||||
assert.equal(area.tier, 'whistlePost');
|
||||
assert.equal(area.grid.size, 3, 'Limits, Whistle Post, Limits');
|
||||
assert.equal(officeProfile(area.tier).adTracks, 1);
|
||||
assert.equal(area.adOccupancy.length, 0);
|
||||
});
|
||||
|
||||
it('deals three cards and turns three Department slots face up', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.decks.hands.get(0)!.length, 3);
|
||||
assert.equal(g.decks.departments.length, 3);
|
||||
assert.ok(g.decks.departments.every((c) => c !== null));
|
||||
});
|
||||
|
||||
it('accounts for every card exactly once', () => {
|
||||
const g = newSolitaireGame();
|
||||
const all = [
|
||||
...g.decks.homeOffice,
|
||||
...g.decks.departments.filter((c): c is string => c !== null),
|
||||
...g.decks.salvageYard,
|
||||
...[...g.decks.hands.values()].flat(),
|
||||
];
|
||||
assert.equal(all.length, DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
|
||||
it('starts with no trains scheduled and none running', () => {
|
||||
// §4 — "no trains are officially running yet". This is the Day-1 revenue ramp (Gap 10e).
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.timetable.length, 12);
|
||||
assert.ok(g.timetable.every((slot) => slot === null));
|
||||
assert.equal(g.trays.size, 0);
|
||||
});
|
||||
|
||||
it('provides player-count + 3 crew trays', () => {
|
||||
assert.equal(newSolitaireGame().freeTrays.length, 4);
|
||||
});
|
||||
|
||||
it('puts all 62 rolling stock pieces in the Division Yard', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.yards.divisionYard.length, 62);
|
||||
assert.equal(g.yards.classificationYard.length, 0);
|
||||
});
|
||||
|
||||
it('rejects a solitaire game with more than one player', () => {
|
||||
assert.throws(() =>
|
||||
createGame({ id: 'x', seed: 1, config: solitaireConfig, playerNames: ['a', 'b'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Build order steps 5-6 — bots play legal games, and the harness produces stable numbers.
|
||||
* See architecture/components.md §4.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import { simulate } from '../src/sim/harness.ts';
|
||||
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'short',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed: number): GameState =>
|
||||
createGame({ id: `g${seed}`, seed, config, playerNames: ['bot'] });
|
||||
|
||||
describe('bots (component 17)', () => {
|
||||
it('plays only legal actions — playGame throws otherwise', () => {
|
||||
for (const seed of [1, 2, 3, 5, 8, 13]) {
|
||||
const r = playGame(game(seed), developerBot, pump);
|
||||
assert.ok(r.finished, `seed ${seed} did not finish`);
|
||||
}
|
||||
});
|
||||
|
||||
it('the random control also plays only legal actions', () => {
|
||||
for (const seed of [21, 34, 55]) {
|
||||
const r = playGame(game(seed), randomBot(seed), pump);
|
||||
assert.ok(r.finished, `seed ${seed} did not finish`);
|
||||
}
|
||||
});
|
||||
|
||||
it('develops the railroad rather than idling', () => {
|
||||
// Guards the class of bug where every game "completes" while nothing happens.
|
||||
const r = playGame(game(42), developerBot, pump);
|
||||
assert.ok(r.cardsPlayed > 0, 'no cards played');
|
||||
assert.ok(r.days > 1, 'game did not span a Day');
|
||||
});
|
||||
|
||||
it('is reproducible from a seed', () => {
|
||||
const a = playGame(game(99), developerBot, pump);
|
||||
const b = playGame(game(99), developerBot, pump);
|
||||
assert.deepEqual(a.revenue, b.revenue);
|
||||
assert.deepEqual(a.outcome, b.outcome);
|
||||
assert.equal(a.turns, b.turns);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trains complete their runs (regression)', () => {
|
||||
it('does not leave trains parked at an Office forever', () => {
|
||||
// REGRESSION. `moveTrain` originally handled only Division Point and Mainline positions, so a
|
||||
// train that arrived at an Office never departed. It held its A/D track permanently and every
|
||||
// train behind it collided — 3.7 collisions per game, mean revenue −17, zero wins.
|
||||
//
|
||||
// A run of games should now show collisions as the exception, not the rule.
|
||||
const report = simulate({
|
||||
games: 40,
|
||||
length: 'short',
|
||||
mode: 'solitaire',
|
||||
players: ['bot'],
|
||||
policy: developerBot,
|
||||
});
|
||||
assert.ok(
|
||||
report.collisionsPerGame.median <= 1,
|
||||
`median ${report.collisionsPerGame.median} collisions/game — trains are piling up again`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps A/D occupancy in step with where trains actually are', () => {
|
||||
// The other half of the same bug: adOccupancy was only ever appended to, so an Office looked
|
||||
// permanently full once any train had visited.
|
||||
const s = game(7);
|
||||
playGame(s, developerBot, pump);
|
||||
for (const [owner, area] of s.officeAreas) {
|
||||
for (const id of area.adOccupancy) {
|
||||
const tray = s.trays.get(id);
|
||||
assert.ok(tray, `A/D track holds ${id}, which no longer exists`);
|
||||
assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`);
|
||||
if (tray.position.at === 'grid') {
|
||||
assert.equal(tray.position.owner, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('balance harness (component 18)', () => {
|
||||
it('produces a stable report over many games', () => {
|
||||
const report = simulate({
|
||||
games: 25,
|
||||
length: 'short',
|
||||
mode: 'solitaire',
|
||||
players: ['bot'],
|
||||
policy: developerBot,
|
||||
});
|
||||
assert.equal(report.games, 25);
|
||||
assert.equal(report.finished, 25, 'every game must reach an outcome');
|
||||
assert.ok(report.daysPlayed.mean > 0);
|
||||
});
|
||||
|
||||
it('is deterministic — the same options give the same report', () => {
|
||||
const opts = {
|
||||
games: 10,
|
||||
length: 'short' as const,
|
||||
mode: 'solitaire' as const,
|
||||
players: ['bot'],
|
||||
policy: developerBot,
|
||||
};
|
||||
assert.deepEqual(simulate(opts), simulate(opts));
|
||||
});
|
||||
|
||||
it('shows the heuristic bot outperforming the random control', () => {
|
||||
// If the policy is worth anything it must beat picking uniformly at random.
|
||||
const base = { games: 60, length: 'short' as const, mode: 'solitaire' as const, players: ['bot'] };
|
||||
const smart = simulate({ ...base, policy: developerBot });
|
||||
const dumb = simulate({ ...base, policy: randomBot(4242) });
|
||||
assert.ok(
|
||||
smart.revenuePerPlayer.mean > dumb.revenuePerPlayer.mean,
|
||||
`heuristic ${smart.revenuePerPlayer.mean.toFixed(1)} did not beat random ${dumb.revenuePerPlayer.mean.toFixed(1)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the revenue chain works end to end (regression)', () => {
|
||||
it('spots cars on industry tracks', () => {
|
||||
// REGRESSION. Cars dropped at a facility went into the card's `standing` list, while §9.3's
|
||||
// loading rule looked at `industryTrack`. Across 50 games ZERO cars were ever spotted and
|
||||
// freight revenue was structurally zero. Win rate went 0% -> ~35% when these were unified.
|
||||
let spotted = 0;
|
||||
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
|
||||
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump);
|
||||
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
||||
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
|
||||
}
|
||||
}
|
||||
assert.ok(spotted > 0, 'no car ever reached an industry track across eight games');
|
||||
});
|
||||
|
||||
it('instantiates a real Facility when a freight card is placed', () => {
|
||||
// REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable.
|
||||
const s = createGame({ id: 'g', seed: 11, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump);
|
||||
let placed = 0;
|
||||
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
||||
if (card.geometry.kind !== 'facility') continue;
|
||||
placed++;
|
||||
assert.ok(card.facility, 'a placed facility card has no Facility record');
|
||||
assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
|
||||
}
|
||||
assert.ok(placed > 0, 'no facility was placed at all');
|
||||
});
|
||||
|
||||
it('earns freight revenue, not just passenger revenue', () => {
|
||||
// Asserts the FREIGHT chain specifically. An earlier version asserted `wins > 0`, which passed
|
||||
// only because unloads were mis-scored as completed loads after one Laborer action instead of
|
||||
// four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so
|
||||
// "did anyone win" is no longer a safe proxy for "does freight work".
|
||||
const report = simulate({
|
||||
games: 40,
|
||||
length: 'standard',
|
||||
mode: 'solitaire',
|
||||
players: ['bot'],
|
||||
policy: developerBot,
|
||||
});
|
||||
const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
|
||||
assert.ok(freight > 0, 'no freight load completed across 40 games');
|
||||
});
|
||||
|
||||
it('grows the Office Area in both directions (Gap 11)', () => {
|
||||
// With orientation fixed, grids only ever grew sideways and downward.
|
||||
const rows = new Set<number>();
|
||||
for (const seed of [3, 9, 27, 81]) {
|
||||
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump);
|
||||
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
|
||||
}
|
||||
assert.ok([...rows].some((r) => r > 0), 'nothing was ever built north of the Running Track');
|
||||
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built south of the Running Track');
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-of-game statistics', () => {
|
||||
it('accounts for revenue by source', () => {
|
||||
const report = simulate({
|
||||
games: 20, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
for (const g of report.perGame) {
|
||||
const gross =
|
||||
g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain;
|
||||
assert.ok(gross >= 0, 'gross revenue cannot be negative');
|
||||
assert.ok(g.freightShare >= 0 && g.freightShare <= 1, 'freight share out of range');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports no anomalies — every rule in the engine is reachable', () => {
|
||||
// THE POINT OF THIS MODULE. Every serious bug so far was an unreachable rule: a pipeline with
|
||||
// no entry point, a facility that could not be worked, a car that could not be spotted. If an
|
||||
// expected event stops firing, something has become unreachable again.
|
||||
const report = simulate({
|
||||
games: 60, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
const found = anomalies(report.perGame);
|
||||
const never = found.filter((a) => a.severity === 'never');
|
||||
assert.deepEqual(
|
||||
never.map((a) => a.what),
|
||||
[],
|
||||
`unreachable rules: ${never.map((a) => `${a.what} (${a.detail})`).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('buckets games by strategy without losing any', () => {
|
||||
const report = simulate({
|
||||
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
const scored = report.perGame.filter(
|
||||
(g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
|
||||
);
|
||||
const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
|
||||
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Build order step 2 — "Move legality provable against Appendix A's worked switching examples."
|
||||
* See architecture/components.md §4.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type {
|
||||
GridCoord,
|
||||
OfficeArea,
|
||||
RollingStock,
|
||||
TrackCard,
|
||||
TurnoutOrientation,
|
||||
} from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
||||
import {
|
||||
allReachable,
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
exitsFrom,
|
||||
hasPort,
|
||||
neighbour,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
} from '../src/engine/track.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
||||
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
geometry: o
|
||||
? { kind: 'track', geometry: 'turnout', turnout: o }
|
||||
: { kind: 'track', geometry: 'turnout' },
|
||||
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
const officeCard = (): TrackCard => ({
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
const lockedFacility = (): TrackCard => ({
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
|
||||
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
|
||||
return {
|
||||
owner: 0,
|
||||
tier: 'whistlePost',
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: officeCoord.row,
|
||||
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
||||
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
||||
adOccupancy: [],
|
||||
};
|
||||
}
|
||||
|
||||
const emptyOccupancy: Occupancy = { trayAt: () => null, freeAdTracks: () => 1 };
|
||||
|
||||
function ctxFor(area: OfficeArea, over: Partial<MoveContext> = {}): MoveContext {
|
||||
return { area, occupancy: emptyOccupancy, consistSize: 0, self: 'tray0', ...over };
|
||||
}
|
||||
|
||||
const at = (r: number, c: number): GridCoord => ({ row: r, col: c });
|
||||
const has = (dests: { coord: GridCoord }[], r: number, c: number): boolean =>
|
||||
dests.some((d) => d.coord.row === r && d.coord.col === c);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ports and geometry', () => {
|
||||
it('pairs opposite ports and grid neighbours consistently', () => {
|
||||
for (const p of ['n', 's', 'e', 'w'] as Port[]) {
|
||||
assert.equal(opposite(opposite(p)), p);
|
||||
const there = neighbour(at(0, 0), p);
|
||||
const back = neighbour(there, opposite(p));
|
||||
assert.deepEqual(back, at(0, 0));
|
||||
}
|
||||
});
|
||||
|
||||
it('joins a turnout stem to both legs but never the legs to each other', () => {
|
||||
// §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
|
||||
// reaches only the stem. This is an ABSENT edge, not a one-way edge.
|
||||
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
|
||||
assert.deepEqual(exitsFrom(t, 'e').sort(), ['n', 'w']);
|
||||
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
|
||||
assert.deepEqual(exitsFrom(t, 'n'), ['e']);
|
||||
});
|
||||
|
||||
it('lets a train traverse a turnout in both directions', () => {
|
||||
// A→B and B→A are both legal; only B↔C is missing.
|
||||
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
|
||||
assert.ok(exitsFrom(t, 'e').includes('n'), 'stem to diverging leg');
|
||||
assert.ok(exitsFrom(t, 'n').includes('e'), 'diverging leg back to stem');
|
||||
});
|
||||
|
||||
it('gives the Office card junction stubs above and below', () => {
|
||||
// Gap 8 — plain junctions, so unlike a turnout no pair is missing between track and stub.
|
||||
const o = officeCard();
|
||||
for (const p of ['n', 's', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
|
||||
assert.ok(exitsFrom(o, 'n').includes('e'));
|
||||
assert.ok(exitsFrom(o, 'n').includes('w'));
|
||||
assert.ok(!exitsFrom(o, 'n').includes('s'), 'north must not cross to south');
|
||||
});
|
||||
|
||||
it('gives a run-around both ends of the loop', () => {
|
||||
// §A.5's facing-point move is impossible without one.
|
||||
const r: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'runAround' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
};
|
||||
assert.deepEqual(exitsFrom(r, 'n').sort(), ['e', 'w']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Move legality', () => {
|
||||
// row 1: [B n-s straight] <- must have a SOUTH port to meet the Office stub
|
||||
// row 0: [D] [E office ] [F]
|
||||
const basicArea = (): OfficeArea =>
|
||||
areaFrom(
|
||||
{
|
||||
[coordKey(at(0, -1))]: straight(),
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: straight(),
|
||||
[coordKey(at(1, 0))]: nsStraight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
|
||||
it('travels any distance in a straight line', () => {
|
||||
// §2.4 — "regardless of distance".
|
||||
const area = basicArea();
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
|
||||
assert.ok(has(dests, 0, 0), 'reaches the office');
|
||||
assert.ok(has(dests, 0, -1), 'reaches past it');
|
||||
});
|
||||
|
||||
it('branches onto Secondary Track through the Office stub', () => {
|
||||
const area = basicArea();
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
|
||||
assert.ok(has(dests, 1, 0), 'reaches the card north of the office');
|
||||
});
|
||||
|
||||
it('never reverses within a single Move', () => {
|
||||
// Leaving east from the west end cannot double back to the west end.
|
||||
const area = basicArea();
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
|
||||
assert.ok(!has(dests, 0, -1), 'a Move may not return to its own start');
|
||||
});
|
||||
|
||||
it('separates forward and reverse into different Moves', () => {
|
||||
const area = basicArea();
|
||||
const both = allReachable(ctxFor(area), at(0, 0), 'e');
|
||||
assert.ok(has(both.forward, 0, 1));
|
||||
assert.ok(has(both.reverse, 0, -1));
|
||||
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
|
||||
});
|
||||
|
||||
it('will not stop on a turnout', () => {
|
||||
// row 1: [C n-s straight]
|
||||
// row 0: [start] [turnout ]
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
|
||||
[coordKey(at(1, 1))]: nsStraight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
||||
assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
|
||||
assert.ok(has(dests, 1, 1), 'but a train may pass through it');
|
||||
});
|
||||
|
||||
it('will not stop on a facility whose track is locked by a load', () => {
|
||||
// §9.3 — while any load sits on MEN|AT|WORK the industry track is not Operational Rail.
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: lockedFacility(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
||||
assert.ok(!has(dests, 0, 1), 'locked industry track must not accept a train');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('coupling', () => {
|
||||
const withCars = (n: number): OfficeArea =>
|
||||
areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: straight(Array.from({ length: n }, car)),
|
||||
[coordKey(at(0, 2))]: straight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
|
||||
it('couples cars met along the way, mandatorily', () => {
|
||||
// §A.4 — "you MUST pick them up. You may not go around them."
|
||||
const dests = reachableDestinations(ctxFor(withCars(2)), at(0, 0), 'e');
|
||||
const beyond = dests.find((d) => d.coord.col === 2);
|
||||
assert.ok(beyond, 'should be able to pass the occupied card');
|
||||
assert.equal(beyond.couples.length, 2, 'cars must be collected, not stepped over');
|
||||
});
|
||||
|
||||
it('forbids a Move that would overfill the tray', () => {
|
||||
// §A.4 — max four Rolling Stock, cabooses included. Overfilling makes the move illegal
|
||||
// rather than picking up fewer cars.
|
||||
const ctx = ctxFor(withCars(3), { consistSize: 3 });
|
||||
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
||||
assert.equal(dests.length, 0, '3 in tray + 3 standing exceeds 4');
|
||||
});
|
||||
|
||||
it('permits a Move that exactly fills the tray', () => {
|
||||
const ctx = ctxFor(withCars(2), { consistSize: 2 });
|
||||
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
||||
assert.ok(dests.length > 0, '2 + 2 = 4 is legal');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('occupancy', () => {
|
||||
const twoTrainArea = (): OfficeArea =>
|
||||
areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: straight(),
|
||||
[coordKey(at(0, 2))]: straight(),
|
||||
},
|
||||
at(0, 5), // office elsewhere, so col 1 is ordinary track
|
||||
);
|
||||
|
||||
it('blocks a card another train occupies', () => {
|
||||
// §A.4 — two trains may not share a card or move through each other.
|
||||
const occupancy: Occupancy = {
|
||||
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
||||
freeAdTracks: () => 1,
|
||||
};
|
||||
const dests = reachableDestinations(ctxFor(twoTrainArea(), { occupancy }), at(0, 0), 'e');
|
||||
assert.ok(!has(dests, 0, 1), 'occupied card');
|
||||
assert.ok(!has(dests, 0, 2), 'and no passing through it');
|
||||
});
|
||||
|
||||
it('allows passing through the Office while A/D tracks remain free', () => {
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: officeCard(),
|
||||
[coordKey(at(0, 2))]: straight(),
|
||||
},
|
||||
at(0, 1),
|
||||
);
|
||||
const occupancy: Occupancy = {
|
||||
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
||||
freeAdTracks: () => 1,
|
||||
};
|
||||
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
||||
assert.ok(has(dests, 0, 2), 'the Office is the exception to the blocking rule (§A.4)');
|
||||
});
|
||||
|
||||
it('blocks the Office once every A/D track is taken', () => {
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: officeCard(),
|
||||
[coordKey(at(0, 2))]: straight(),
|
||||
},
|
||||
at(0, 1),
|
||||
);
|
||||
const occupancy: Occupancy = {
|
||||
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
||||
freeAdTracks: () => 0,
|
||||
};
|
||||
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
||||
assert.ok(!has(dests, 0, 2));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('placement and drop-off', () => {
|
||||
it('lets the first Facility attach to the Office stub', () => {
|
||||
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, -1))]: straight(),
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: straight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
assert.ok(canPlaceAt(area, at(1, 0), nsStraight()), 'north of the Office');
|
||||
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'south of the Office');
|
||||
});
|
||||
|
||||
it('refuses a card whose ports do not meet the neighbour it touches', () => {
|
||||
// Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
|
||||
// so it cannot attach to the stub. Orientation is not cosmetic.
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, -1))]: straight(),
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: straight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
assert.ok(!canPlaceAt(area, at(1, 0), straight()), 'e-w straight cannot meet a n-s stub');
|
||||
});
|
||||
|
||||
it('refuses a card that connects to nothing', () => {
|
||||
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
||||
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
|
||||
});
|
||||
|
||||
it('refuses to place on an occupied cell', () => {
|
||||
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
||||
assert.ok(!canPlaceAt(area, at(0, 0), straight()));
|
||||
});
|
||||
|
||||
it('forbids dropping cars at the Office', () => {
|
||||
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: straight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
assert.ok(!canDropCarsAt(area, at(0, 0)), 'Office track');
|
||||
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
|
||||
});
|
||||
|
||||
it('forbids dropping cars on a locked industry track', () => {
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: lockedFacility(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
assert.ok(!canDropCarsAt(area, at(0, 1)));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user