Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+540
View File
@@ -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 players (§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');
});
});
+589
View File
@@ -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);
});
});
+313
View File
@@ -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'] }),
);
});
});
+235
View File
@@ -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');
});
});
+400
View File
@@ -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)));
});
});