Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+12 -8
View File
@@ -239,8 +239,10 @@ describe('Mainline Phase (§8)', () => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
// Q1/Q2 — crossing time is counted in Stages by card speed and Fast/Slow class, so the train
// stays on the card until its transit expires rather than stepping through printed cells.
const pos2 = s.trays.get(id)!.position;
assert.ok(pos2.at === 'mainline' && pos2.region === 1, 'one region per Stage');
assert.equal(pos2.at, 'mainline', 'still crossing');
});
it('orders movement by train number, Timetabled before Extra on a tie (Gap 5)', () => {
@@ -288,11 +290,11 @@ describe('collisions are automatic (Gap 2)', () => {
engineFront: true,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = id;
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
@@ -308,6 +310,8 @@ describe('collisions are automatic (Gap 2)', () => {
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
const classBefore = s.yards.classificationYard.length;
const mlx = s.division.nodes[1];
if (mlx?.kind === 'mainline') mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
s.trays.set('x', {
id: 'x',
trainNumber: 8,
@@ -318,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => {
{ type: 'caboose', loaded: true },
],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
s.clock.phase = 'mainline';
@@ -346,11 +350,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = 'ahead';
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
// A second train at the Western Division Point wanting to follow it.
s.trays.set('behind', {
@@ -383,11 +387,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
engineFront: true,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1, region: 0 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[0]!.occupant = 'oncoming';
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
s.trays.set('waiting', {
id: 'waiting',
+9 -5
View File
@@ -127,7 +127,10 @@ describe('Local Operations: drawing (§6.2)', () => {
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);
// §6.2 — an emptied Department slot is refilled from the Home Office deck immediately, so the
// face-up market never disappears.
assert.notEqual(s.decks.departments[1], null, 'Department slot was not refilled');
assert.notEqual(s.decks.departments[1], target, 'refilled with the same card');
});
it('allows only one draw per Stage', () => {
@@ -200,7 +203,7 @@ describe('Office upgrades (Gap 3b, Gap 8)', () => {
// 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());
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' });
@@ -218,7 +221,8 @@ describe('Office upgrades (Gap 3b, Gap 8)', () => {
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);
// The design gives slots equal to porters, not one more.
assert.equal(office.facility!.capacity.inbound, 1);
});
});
@@ -302,7 +306,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => {
describe('Freight Agent operations (§6.3)', () => {
function withFacility(s: GameState): GridCoord {
const coord = at(1, 0);
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
@@ -398,7 +402,7 @@ describe('Freight Agent operations (§6.3)', () => {
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
function facilityWithLoad(s: GameState): GridCoord {
const coord = at(1, 0);
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
+240
View File
@@ -0,0 +1,240 @@
/**
* Component 16 — replay viewer.
*
* The important test here is narration coverage: adding an event type without narrating it should
* fail the build rather than quietly degrading the replay into "something happened".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** One representative instance of every event type the engine can emit. */
const SAMPLES: GameEvent[] = [
{ type: 'stageBegan', day: 1, stage: 7 },
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
{ type: 'cardDiscarded', player: 0, cardId: 'c1', toSlot: 1 },
{ type: 'deckReshuffled' },
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
];
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
});
it('gives every event a specific, non-empty sentence', () => {
for (const e of SAMPLES) {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
}
});
it('marks revenue gains good and losses bad', () => {
const gain = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 1, reason: 'boarding' });
const loss = narrate({
type: 'revenueChanged', player: 0, delta: -5, total: -5, reason: 'collision: no free A/D track',
});
assert.equal(gain.tone, 'good');
assert.equal(loss.tone, 'bad');
assert.match(loss.text, /COLLISION/i);
});
it('points at the board cell where something happened', () => {
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
assert.deepEqual(n.where, { row: 1, col: 2 });
});
it('names phases in words rather than identifiers', () => {
assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
assert.equal(phaseLabel('newTrain'), 'New Train');
});
it('hides only the events with nothing to show', () => {
assert.equal(isVisible({ type: 'actorChanged', player: 0 }), false);
assert.equal(isVisible({ type: 'loadCompleted', player: 0, at: { row: 0, col: 0 }, carType: 'hopper' }), true);
});
});
describe('impediments', () => {
it('reports nothing blocked on a fresh game', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
assert.deepEqual(impediments(s, 0), []);
});
it('warns when every A/D track is occupied', () => {
// Gap 2d — the next arrival is an automatic collision.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.officeAreas.get(0)!.adOccupancy = ['t0'];
const found = impediments(s, 0);
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
});
it('reports a train held for want of a Crew Tray', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.timetable[s.clock.stage - 1] = 4;
s.freeTrays = [];
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
});
});
describe('replay recording', () => {
const rec = record(1234, 'standard');
it('produces frames', () => {
assert.ok(rec.frames.length > 50, `only ${rec.frames.length} frames`);
});
it('never runs time backwards', () => {
let prev = 0;
for (const f of rec.frames) {
const t = f.day * 100 + f.stage;
assert.ok(t >= prev, `time went backwards at Day ${f.day} Stage ${f.stage}`);
prev = t;
}
});
it('always shows the Office card', () => {
for (const f of rec.frames) {
assert.ok(f.cells.some((c) => c.kind === 'office'), 'a frame lost the Office');
}
});
it('does not drift from the engine', () => {
// A replay that disagrees with the engine is worse than no replay. Replay the same seed
// through the normal bot path and compare the end state.
const s = createGame({ id: 'x', seed: 1234, config, playerNames: ['player'] });
const r = playGame(s, developerBot, pump);
const stats = summarize(1234, r.events, r.intents, s);
const last = rec.frames[rec.frames.length - 1]!;
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
assert.match(rec.outcome, new RegExp(stats.result));
});
it('narrates every frame', () => {
for (const f of rec.frames) {
assert.ok(f.lines.length > 0, 'a frame has no narration');
}
});
});
describe('replay HTML', () => {
const html = renderHtml(record(99, 'short'));
it('is a complete standalone document', () => {
assert.match(html, /^<!doctype html>/i);
assert.match(html, /<\/html>\s*$/);
});
it('embeds no external resources', () => {
// A strict requirement: the file must open anywhere, offline.
const externals = html.match(/(?:src|href)\s*=\s*"(?!#)[^"]+"/g) ?? [];
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
});
it('embeds the frame data', () => {
assert.match(html, /const FRAMES = \[/);
});
it('offers the controls that make it usable', () => {
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash']) {
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
}
});
it('emits a script that actually parses', () => {
// THE GUARD THAT WAS MISSING. A stray apostrophe inside a single-quoted JS string once broke
// the entire inline script, so no button handler ever bound — while every other test passed,
// because they only checked that the markup contained the buttons. Checking the elements exist
// says nothing about whether the page works.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
assert.doesNotThrow(() => new Function(js), 'inline script has a syntax error');
});
it('wires a handler to every control', () => {
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash']) {
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
}
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
});
it('runs its render function against the real frames', () => {
// Executes the page's own JS with a minimal DOM stub. Catches runtime errors in render(),
// not just parse errors — e.g. a field the renderer expects that frames do not carry.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
const els = new Map<string, Record<string, unknown>>();
const stub = {
getElementById: (id: string) => {
if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
return els.get(id);
},
};
const run = new Function('document', 'setInterval', 'clearInterval', js);
assert.doesNotThrow(
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the clock element should have been filled in.
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
});
it('stays a sane size', () => {
const mb = Buffer.byteLength(html) / 1024 / 1024;
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
});
});
+92 -34
View File
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts';
import { MODIFIER_PROFILES, SOLITAIRE_DECK_SIZE, TRACK_PER_PLAYER } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
@@ -48,26 +49,44 @@ const newSolitaireGame = (seed = 1234) =>
// ---------------------------------------------------------------------------
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);
it('composes the 115-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
assert.equal(DECK_SIZE, 115);
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]),
);
it('matches the design 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,
office: 7,
industry: 9,
modifier: 23,
train: 22,
spaceUse: 12,
enhancement: 18,
mainlineModifier: 7,
maneuver: 7,
action: 10,
});
});
it('removes opponent-directed cards from a solitaire deck', () => {
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
// game they would be 22 of 115 draws (19%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 93);
const solo = buildDeck('solitaire');
assert.equal(solo.length, 93);
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
// A competitive deck keeps them.
assert.equal(buildDeck('competitive').length, 115);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
// The single biggest structural change: 26 pieces per player, 104 for four.
assert.equal(TRACK_PER_PLAYER, 26);
assert.ok(!buildDeck().some((c) => c.kind.kind === 'track'), 'track leaked into the deck');
});
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
assert.equal(TIMETABLED_TRAINS.length, 12);
for (const t of TIMETABLED_TRAINS) {
@@ -78,24 +97,38 @@ describe('card catalogue (component 1)', () => {
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`);
const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
assert.ok(slots <= MAX_CONSIST, `${t.name} ${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('numbers Extras X13-X22, all junior to every timetabled train', () => {
// This is why Gap 5's Extra-vs-Timetabled tie can no longer occur.
assert.deepEqual(
EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b),
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
);
assert.ok(EXTRA_TRAINS.every((e) => TIMETABLED_TRAINS.every((t) => t.number < e.number)));
});
it('identifies Freight Houses as the both-direction facilities', () => {
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
it('gives every train a name and a speed class', () => {
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
assert.ok(t.name.length > 2, `train ${t.number} has no name`);
assert.ok(t.speed === 'fast' || t.speed === 'slow', `train ${t.number} has no speed`);
}
});
it('identifies the both-direction industry', () => {
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
assert.deepEqual(houses.sort(), ['freightHouse']);
});
it('keeps every industry track within the four-car limit', () => {
it('starts every industry at one car out and one loader', () => {
// The design is far leaner than the placeholder: capacity grows via modifier cards.
for (const f of FREIGHT_PROFILES) {
assert.ok(f.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
assert.ok(f.baseOut <= 1, `${f.name} baseOut ${f.baseOut}`);
assert.ok(f.baseIn <= 1, `${f.name} baseIn ${f.baseIn}`);
assert.equal(f.baseLoaders, 1, `${f.name} loaders`);
}
});
@@ -103,8 +136,28 @@ describe('card catalogue (component 1)', () => {
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`);
assert.equal(f.baseOut > 0, wantsOut, `${f.name} outbound`);
assert.equal(f.baseIn > 0, wantsIn, `${f.name} inbound`);
}
});
it('records lockouts symmetrically', () => {
// Column E of the sheet. Semantics are still open, but the data must be consistent.
for (const f of FREIGHT_PROFILES) {
for (const other of f.lockouts) {
const o = FREIGHT_PROFILES.find((x) => x.kind === other)!;
assert.ok(o.lockouts.includes(f.kind), `${f.kind} locks out ${other} but not vice versa`);
}
}
});
it('ties every modifier to at least one real host', () => {
for (const m of MODIFIER_PROFILES) {
assert.ok(m.hosts.length > 0, `${m.name} has no host`);
for (const h of m.hosts) {
if (h === 'office') continue;
assert.ok(FREIGHT_PROFILES.some((f) => f.kind === h), `${m.name} names unknown host ${h}`);
}
}
});
@@ -117,17 +170,17 @@ describe('card catalogue (component 1)', () => {
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]);
// The design gives slots EQUAL to porters; the earlier guess was one too generous.
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`);
assert.equal(o.passengerOut, o.porters, `${o.tier} green slots`);
assert.equal(o.passengerIn, o.porters, `${o.tier} red slots`);
}
});
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]);
assert.deepEqual(inDeck, [4, 2, 1]);
});
it('walks the upgrade sequence without skipping', () => {
@@ -147,8 +200,8 @@ describe('card catalogue (component 1)', () => {
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);
const need = (f.baseOut + f.baseIn) * f.copies;
for (const t of f.carTypes) demand.set(t, (demand.get(t) ?? 0) + need);
}
for (const [type, need] of demand) {
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
@@ -245,10 +298,14 @@ describe('game setup (component 2)', () => {
);
});
it('gives every mainline card two regions', () => {
it('deals each Mainline card a terrain type', () => {
// Terrain is what sets crossing time (Q1), so every Mainline node must carry a card kind.
const g = newSolitaireGame();
for (const node of g.division.nodes) {
if (node.kind === 'mainline') assert.equal(node.regions.length, 2);
if (node.kind === 'mainline') {
assert.ok(node.card, 'mainline node has no card type');
assert.deepEqual(node.transits, []);
}
}
});
@@ -283,8 +340,9 @@ describe('game setup (component 2)', () => {
...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');
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
});
it('starts with no trains scheduled and none running', () => {
+67 -13
View File
@@ -11,6 +11,7 @@ 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 { record } from '../src/sim/replay.ts';
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
@@ -152,16 +153,22 @@ describe('the revenue chain works end to end (regression)', () => {
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);
//
// Checked across several seeds: the design's deck holds only 9 industries in 115 cards, so a
// single game may legitimately never draw one. That dilution is itself worth knowing — under
// the old 52-card placeholder it was 10 in 52.
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');
for (const seed of [11, 23, 47, 91, 137, 211]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
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');
assert.ok(placed > 0, 'no facility was placed across six campaign games');
});
it('earns freight revenue, not just passenger revenue', () => {
@@ -180,16 +187,17 @@ describe('the revenue chain works end to end (regression)', () => {
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.
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
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'] });
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, 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');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
});
});
@@ -233,3 +241,49 @@ describe('end-of-game statistics', () => {
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
});
});
describe('switching accomplishes something (regression)', () => {
it('does not shuttle the crew back and forth to no purpose', () => {
// REGRESSION. The bot chose to SWITCH whenever a train stood at the Office, whether or not
// there was anything to switch — then, having nothing useful to do, took the first legal move
// repeatedly and burned all six Moves oscillating between two cells. A passenger train needs
// no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
// Operations action was wasted.
const rec = record(1234, 'standard');
let worstRun = 0;
let run = 0;
let lastFrom: string | null = null;
for (const f of rec.frames) {
const move = f.lines.find((l) => /^CREW moved/.test(l.text));
const didWork = f.lines.some((l) => /Dropped|Coupled/.test(l.text));
if (!move || didWork) {
run = 0;
lastFrom = null;
continue;
}
const m = /\((-?\d+),(-?\d+)\) → \((-?\d+),(-?\d+)\)/.exec(move.text);
if (!m) continue;
const from = `${m[1]},${m[2]}`;
const to = `${m[3]},${m[4]}`;
// An oscillation is a move that lands exactly where the previous move started.
run = lastFrom === to ? run + 1 : 0;
worstRun = Math.max(worstRun, run);
lastFrom = from;
}
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
});
it('does not spend its whole Local Operations budget on motion', () => {
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
// the crew is not burning all six Moves every Stage — the shuttling bug.
const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
});
});
+30 -29
View File
@@ -128,53 +128,54 @@ describe('ports and geometry', () => {
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']);
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
assert.deepEqual(exitsFrom(t, 'e').sort(), ['s', 'w']);
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
assert.deepEqual(exitsFrom(t, 'n'), ['e']);
assert.deepEqual(exitsFrom(t, 's'), ['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');
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
assert.ok(exitsFrom(t, 'e').includes('s'), 'stem to diverging leg');
assert.ok(exitsFrom(t, 's').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.
it('gives the Office card a junction stub BELOW only', () => {
// Q7 — the card art draws the through-track along the top edge with everything diverging
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
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');
for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
assert.ok(exitsFrom(o, 's').includes('e'));
assert.ok(exitsFrom(o, 's').includes('w'));
});
it('gives a run-around both ends of the loop', () => {
// §A.5's facing-point move is impossible without one.
it('joins a curve from one end of the through track downward', () => {
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
// handedness decides which end of the through track it leaves from.
const r: TrackCard = {
geometry: { kind: 'track', geometry: 'runAround' },
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
};
assert.deepEqual(exitsFrom(r, 'n').sort(), ['e', 'w']);
assert.deepEqual(exitsFrom(r, 's'), ['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]
// row 0: [D] [E office ] [F] <- Running Track
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
const basicArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(1, 0))]: nsStraight(),
[coordKey(at(-1, 0))]: nsStraight(),
},
at(0, 0),
);
@@ -190,7 +191,7 @@ describe('Move legality', () => {
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');
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
});
it('never reverses within a single Move', () => {
@@ -209,19 +210,19 @@ describe('Move legality', () => {
});
it('will not stop on a turnout', () => {
// row 1: [C n-s straight]
// row 0: [start] [turnout ]
// row 0: [start] [turnout ]
// row -1: [C n-s straight]
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
[coordKey(at(1, 1))]: nsStraight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[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');
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', () => {
@@ -346,8 +347,8 @@ describe('placement and drop-off', () => {
},
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');
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
});
it('refuses a card whose ports do not meet the neighbour it touches', () => {
@@ -361,7 +362,7 @@ describe('placement and drop-off', () => {
},
at(0, 0),
);
assert.ok(!canPlaceAt(area, at(1, 0), straight()), 'e-w straight cannot meet a n-s stub');
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', () => {