Files
station-master/test/sim.test.ts
T

826 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import type { Port } from '../src/engine/track.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 = {
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.
//
// 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 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 across six campaign games');
});
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 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: '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 below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE 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,
});
// Both halves of freight, matching `strategyBuckets`. This filter used to omit `freightUnload`
// — the same omission the summary itself had — so a game that scored only by unloading was
// bucketed but not counted here.
const scored = report.perGame.filter(
(g) =>
g.revenue.freightLoad + g.revenue.freightUnload +
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');
});
});
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 shuttle aimlessly — Moves have to buy something', () => {
// Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
// and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
// and comes back with a different car droppable. Measured before sidings existed the crew
// coupled ~0.4 cars a game; it now couples ~8 and drops ~11.
//
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
const moves = per((g) => g.actions.moves);
const work = per((g) => g.actions.drops + g.actions.couples);
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
assert.ok(
moves / work < 8,
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
);
});
});
describe('the bot builds sidings that are actually sidings (regression)', () => {
/**
* Walk south off one Running Track column and report which OTHER main-line columns the walk can
* climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the
* rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
* of the traversal would quietly invent that join.
*/
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
const main = area.runningRow;
const rejoins = new Set<number>();
const seen = new Set<string>();
const start = area.grid.get(`${main - 1},${col}`);
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
const visited = new Set<string>();
while (stack.length > 0) {
const at = stack.pop()!;
const id = `${at.row},${at.col}:${at.entry}`;
if (visited.has(id)) continue;
visited.add(id);
const card = area.grid.get(`${at.row},${at.col}`);
if (!card) continue;
seen.add(`${at.row},${at.col}`);
for (const exit of exitsFrom(card, at.entry)) {
const n = neighbour({ row: at.row, col: at.col }, exit);
const next = area.grid.get(`${n.row},${n.col}`);
if (!next || !hasPort(next, opposite(exit))) continue;
if (n.row >= main) {
if (n.col !== col) rejoins.add(n.col);
continue;
}
stack.push({ row: n.row, col: n.col, entry: opposite(exit) });
}
}
return { rejoins, seen };
};
const districts = (games: number): OfficeArea[] => {
const out: OfficeArea[] = [];
for (let i = 0; i < games; i++) {
const s = createGame({
id: `sd-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
const area = s.officeAreas.get(0);
if (area) out.push(area);
}
return out;
};
it('closes run-arounds instead of building only dead-end stubs', () => {
// REGRESSION. `bestTrackLay` scored an arc +12 with the comment "close the loop back up to the
// main: the run-around is complete", but only checked that a neighbour ran east-west — never
// that anything above it had a south port to join. Measured: 0 run-arounds in 100 games. Every
// district terminated its siding in an arc pointing north into empty space.
const areas = districts(60);
const closed = areas.filter((area) => {
for (const [k, card] of area.grid) {
const [r, c] = k.split(',').map(Number);
if (r !== area.runningRow || !hasPort(card, 's')) continue;
if (rejoinsFrom(area, c!).rejoins.size > 0) return true;
}
return false;
}).length;
// Measured at 91/100 after the fix, against 0/100 before it. Half is a floor with room for the
// deal to be unkind, not a restatement of the current number.
assert.ok(
closed >= areas.length / 2,
`only ${closed}/${areas.length} districts contain a run-around — sidings are dead-end stubs, ` +
'so a crew cannot run around its own train (§A.5)',
);
});
it('puts industries ON the run-around, not merely near one', () => {
// REGRESSION. Facility placement was `options.find(i => i.placement !== undefined)` — the first
// legal square the generator listed, unscored, while track laying had sixty lines of scoring.
// Once the bot started closing run-arounds in 91 games of 100, facilities sitting on one stayed
// at 0.00 a game: the loops were built and the industries put somewhere else.
//
// A facility on a run-around can be reached from either end and its standing cars passed
// (§A.5); on a stub the crew must shove in and back out.
const areas = districts(60);
let onLoop = 0;
for (const area of areas) {
const loop = new Set<string>();
for (const [k, card] of area.grid) {
const [r, c] = k.split(',').map(Number);
if (r !== area.runningRow || !hasPort(card, 's')) continue;
const walk = rejoinsFrom(area, c!);
if (walk.rejoins.size === 0) continue;
for (const cell of walk.seen) {
if (area.grid.get(cell)?.geometry.kind === 'facility') loop.add(cell);
}
}
onLoop += loop.size;
}
// Measured at 1.08 facilities a game on a run-around, against 0.00 before. A third of the games
// managing it at all is a floor, not a restatement.
assert.ok(
onLoop >= areas.length / 3,
`only ${onLoop} facilities across ${areas.length} districts sit on a run-around — the loops ` +
'are built and the industries are somewhere else, so the run-around buys no freight',
);
});
it('moves the commodities the district needs, not just boxcars', () => {
// REGRESSION. `wantedCars` decided what to put on a train from a hand-written industry -> car
// map that had drifted from the sheet, so three of the six industries never asked for anything.
// Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a
// crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were
// sitting at an industry that wanted a tank.
const dropped = new Set<string>();
for (let i = 0; i < 30; i++) {
const s = createGame({
id: `cs-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
for (const e of r.events) {
if (e.type !== 'carsDropped') continue;
for (const c of e.stock) dropped.add(c.type);
}
}
// Tank is the strict test: it is the one commodity no industry shares, so it can only appear if
// the Refinery is asking for it.
assert.ok(dropped.has('tank'), `no tank car was ever dropped — types seen: ${[...dropped].join(', ')}`);
assert.ok(dropped.has('reefer'), `no reefer was ever dropped — types seen: ${[...dropped].join(', ')}`);
});
it('spends no TRACK east of the last column it could rejoin at', () => {
// REGRESSION, the other half of the same bug. Nothing capped the east-west extension and
// `bestTrackLay` never declined a piece, so once the useful squares were taken it kept laying
// whatever was legal — the siding ran past the end of the Running Track and its closing arc had
// nothing above it. The loop missed by one card in 96 of 100 games.
//
// TRACK specifically: a Modifier out there burns tablespace by design and a Facility is placed
// for its own reasons. The defect is spending one of 26 track pieces on a loop that cannot
// close, so that is what this counts.
const areas = districts(60);
let overshot = 0;
for (const area of areas) {
const main = area.runningRow;
let lastWayUp = -Infinity;
let lastTrack = -Infinity;
for (const [k, card] of area.grid) {
const [r, c] = k.split(',').map(Number);
if (r === main && hasPort(card, 's')) lastWayUp = Math.max(lastWayUp, c!);
if (r === main - 1 && card.geometry.kind === 'track') lastTrack = Math.max(lastTrack, c!);
}
if (lastTrack > lastWayUp) overshot++;
}
assert.equal(
overshot,
0,
`${overshot}/${areas.length} districts lay track east of the last column with a way up, ` +
'which spends the track supply on a loop that cannot close',
);
});
});
describe('the bot does not throw away its own freight (regression)', () => {
it('never unjams a green box that is merely waiting', () => {
// REGRESSION. `facilityUnjammed` from `outbound` splices the load out of the green box and
// pushes it to the classification yard — that load cost a Local Operations action to stock, so
// discarding it is strictly negative. The bot reached it through two fallbacks meeting:
//
// 1. `canStockProductively` did not check the Division Yard, while the engine's own
// `stockOutbound` requires a LOADED car of that commodity to be sitting there. Two rules
// for one act, and only one was true — so the Freight Agent option was chosen believing a
// box could be stocked when none could.
// 2. Having chosen it, the follow-through found nothing stuck, nothing to clear and nothing
// stockable, and fell through to "clear whatever is stuck" with nothing stuck.
//
// Measured at 3.10 discarded loads a game against 2.71 STARTED: the bot destroyed more freight
// than it worked. §6.3's unjam is for a load stranded on MEN|AT|WORK; a green box holding a
// load waiting for a car is not jammed, it is waiting.
let outbound = 0;
let menAtWork = 0;
let started = 0;
const games = 40;
for (let i = 0; i < games; i++) {
const s = createGame({
id: `uj-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
for (const e of r.events) {
if (e.type === 'loadStarted') started++;
if (e.type !== 'facilityUnjammed') continue;
if (e.from === 'outbound') outbound++;
if (e.from === 'menAtWork') menAtWork++;
}
}
assert.equal(
outbound,
0,
`${outbound} loads were unjammed out of a green box across ${games} games ` +
`(${started} loads were started) — the bot is discarding freight it paid to stock`,
);
// The intended use must survive: gutting the fallback entirely would also pass the assertion
// above while leaving genuine jams to block the pipeline forever.
assert.ok(menAtWork > 0, 'no MEN|AT|WORK jam was ever cleared — §6.3 unjam has become dead code');
});
});
describe('Enhancements can reach the board at all (regression)', () => {
it('keeps a straight on the Running Track for an Enhancement to attach to', () => {
// REGRESSION, structural. Interlocking, Water Column and Telegraph all print "any Running Track
// Straight"; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
// chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw` directly),
// so it never needed a straight and laid NONE — 0.00 on the Running Track across 100 games.
// Thirteen of the eighteen Enhancement cards that go on the board were therefore unplayable:
// drawn 3.78 a game, placed 0.64.
let withStraight = 0;
const games = 40;
for (let i = 0; i < games; i++) {
const s = createGame({
id: `st-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
const area = s.officeAreas.get(0);
if (!area) continue;
for (const [k, card] of area.grid) {
const row = Number(k.split(',')[0]);
if (row !== area.runningRow) continue;
if (card.geometry.kind === 'track' && card.geometry.geometry === 'straight') {
withStraight++;
break;
}
}
}
assert.ok(
withStraight >= games / 2,
`only ${withStraight}/${games} districts have a straight on the Running Track — an ` +
'Enhancement that prints "any Running Track Straight" has nowhere to go',
);
});
it('gets Interlocking down, which is the only cure for the only penalty', () => {
// "No free A/D track" is the sole cause of every penalty in the game — 2.70 revenue a game,
// 27% of gross. Interlocking is the designed answer (advance.ts:621): it holds the train at the
// Limits instead of colliding. It had never once been placed.
//
// Measured at 15 games in 60 after the fix, against 0 in 100 before. A quarter of that is a
// floor that tolerates an unkind deal without restating the current number.
let placed = 0;
const games = 60;
for (let i = 0; i < games; i++) {
const s = createGame({
id: `il-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
const area = s.officeAreas.get(0);
if (!area) continue;
for (const card of area.grid.values()) {
if (card.enhancements.includes('interlocking')) {
placed++;
break;
}
}
}
assert.ok(
placed >= games / 8,
`Interlocking reached the board in only ${placed}/${games} games — the district has no ` +
'answer to a full Office but a collision',
);
});
});
describe('rolling stock returns to service (regression)', () => {
it('empties the Classification Yard into the Division Yard when the Division Yard runs out', () => {
// §2 — used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
// straight back to the Division Yard, and the Classification Yard empties ONLY when the
// Division Yard is bare. Then all of it returns at once.
//
// REGRESSION on two counts. The Classification Yard was write-only to begin with — seven
// writers, no readers — so 37% of the stock left the game. The first fix then guessed the wrong
// trigger, returning cars at every DAY boundary, which keeps the yard topped up continuously
// and is far more generous than the rule: worth +2.42 revenue a game that the game does not
// actually grant.
//
// So this asserts the TRIGGER, not a tidy end state. The Division Yard legitimately still holds
// cars at the end of a short game, and the Classification Yard legitimately holds a pile.
const s = createGame({
id: 'refill',
seed: 4,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
// Drain the Division Yard and park a known car in Classification.
const parked = s.yards.divisionYard.splice(0, s.yards.divisionYard.length);
s.yards.classificationYard.push(...parked);
assert.equal(s.yards.divisionYard.length, 0);
const waiting = s.yards.classificationYard.length;
assert.ok(waiting > 0, 'nothing was parked to return');
const moved = refillDivisionYardIfEmpty(s);
assert.ok(moved, 'an empty Division Yard did not pull the Classification Yard back');
assert.equal(moved!.count, waiting);
assert.equal(s.yards.divisionYard.length, waiting, 'the cars did not come back');
assert.equal(s.yards.classificationYard.length, 0, 'the Classification Yard was not emptied');
// And it must NOT fire while the Division Yard still has anything at all.
s.yards.classificationYard.push({ type: 'boxcar', loaded: false });
assert.equal(
refillDivisionYardIfEmpty(s),
null,
'the Classification Yard returned while the Division Yard still had cars',
);
});
});
describe('measurement discipline', () => {
it('the observer sees exactly the events the log records', () => {
// REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
// sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that
// watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the
// Office tier histogram from the same run plainly showed Depots, Stations and Terminals.
//
// A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
// reason to hand-roll the drive loop again.
const seen: string[] = [];
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
assert.ok(out.events.length > 0, 'a finished game must produce events');
assert.deepEqual(seen, out.events.map((e) => e.type));
});
it('records office upgrades where a probe can actually find them', () => {
// The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed.
// Asserted across several seeds because a single game may never draw a Depot card.
let upgrades = 0;
for (let seed = 0; seed < 25; seed++) {
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump);
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
}
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
});
});
describe('the bot does not lay track that cannot work (regression)', () => {
/** Drive a game with the developer bot, reporting every track piece it lays. */
const laysIn = (seed: number, prepare?: (s: GameState) => void): Intent[] => {
const s = createGame({
id: `lay-${seed}`,
seed,
config: { ...config, length: 'standard' },
playerNames: ['Solitaire'],
});
prepare?.(s);
const laid: Intent[] = [];
const spy = {
name: 'spy',
choose(st: GameState, p: 0, opts: Intent[]) {
const pick = developerBot.choose(st, p, opts);
if (pick.type === 'track.lay') laid.push(pick);
return pick;
},
};
playGame(s, spy as never, pump);
return laid;
};
it('will not cut a turnout with no arc left to hang beneath it', () => {
// A turnout is a hole in the Running Track, worth cutting only for what hangs under it. This
// does not happen naturally — measured at 0%, because the bot lays the arc immediately after
// the turnout and never runs the supply dry first — so the situation is constructed. It guards
// against the supply changing rather than against something happening today.
const drained = laysIn(4242, (s) => {
const area = s.officeAreas.get(0)!;
for (const k of ['curved:left', 'curved:right', 'sharpCurved:left', 'sharpCurved:right']) {
area.trackSupply.set(k, 0);
}
});
const turnouts = drained.filter((i) => i.type === 'track.lay' && i.geometry === 'turnout');
assert.equal(
turnouts.length,
0,
`${turnouts.length} turnouts were cut with no arc left to build beneath them`,
);
// And the guard must not be doing this by simply stopping the bot from building.
const normal = laysIn(4242);
assert.ok(normal.length > 5, `the bot laid only ${normal.length} pieces with a full supply`);
});
it('rarely lays rail that butts a card which cannot accept it', () => {
// A port facing an EMPTY square is a promise — something may be built there later. A port
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
// the rail stops dead and always will. Reported from a replay, where an arc was dropped off a
// turnout with its far end jammed into a curve.
//
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
// placements measured better and cut these from 28% of pieces to 7%.
let laid = 0;
let dead = 0;
for (const seed of [1000, 8919, 16838, 24757, 32676]) {
const s = createGame({
id: `dp-${seed}`, seed,
config: { ...config, length: 'standard' },
playerNames: ['Solitaire'],
});
const spy = {
name: 'spy',
choose(st: GameState, p: 0, opts: Intent[]) {
const pick = developerBot.choose(st, p, opts);
if (pick.type === 'track.lay') {
laid++;
const area = st.officeAreas.get(p)!;
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
const probe = {
geometry: {
kind: 'track', geometry: pick.geometry,
...(v?.axis ? { axis: v.axis } : {}),
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
},
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
for (const port of new Set(connectionsFor(probe).flat())) {
const n = neighbour(pick.placement, port);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && !hasPort(nb, opposite(port))) dead++;
}
}
return pick;
},
};
playGame(s, spy as never, pump);
}
assert.ok(laid > 40, `only ${laid} pieces laid across five games`);
const rate = dead / laid;
assert.ok(
rate < 0.15,
`${(rate * 100).toFixed(0)}% of pieces were laid butting a card that cannot accept them`,
);
});
});
describe('the freight figures count both halves (regression)', () => {
it('scores an unload as Revenue earned, not as an unload started', () => {
// REGRESSION, against the instrument rather than the game. `rev.freightUnload` was assigned
// `eventCounts['unloadBegan']` — unloads STARTED, not Revenue EARNED, which differ by every
// unload that never finished — and `grossFreight` then used `freightLoad` alone, so
// `freightShare` reported roughly 60% of the freight actually earned. That is the number the
// "freight is only 13-18% of gross" finding was read from, and it drove a density change.
//
// Asserted against the ENGINE's own events, so the summary cannot drift from what happened.
const report = simulate({
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
let loads = 0;
let unloads = 0;
let begun = 0;
for (const seed of [1000, 8919, 16838]) {
const s = createGame({
id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
for (const e of r.events) {
if (e.type === 'unloadBegan') begun++;
if (e.type !== 'revenueChanged') continue;
if (e.reason === 'freightLoad') loads += e.delta;
if (e.reason === 'freightUnload') unloads += e.delta;
}
}
assert.ok(unloads > 0, 'no unload ever earned Revenue');
// `begun` is what the summary used to count. It is an upper bound on what was EARNED, because
// an unload can be started and never finished — so counting it was wrong whether or not the
// two happen to agree on a given sample.
assert.ok(begun >= unloads, 'more unloads earned Revenue than were ever begun');
// Every game in the report must count both halves toward its freight share.
for (const g of report.perGame) {
const gross = g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain;
if (gross === 0) continue;
const expected = (g.revenue.freightLoad + g.revenue.freightUnload) / gross;
assert.ok(
Math.abs(g.freightShare - expected) < 1e-9,
`freightShare ${g.freightShare.toFixed(3)} omits the unload half (expected ${expected.toFixed(3)})`,
);
}
});
});