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

321 lines
14 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 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 = {
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,
});
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');
});
});
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`);
});
});
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');
});
});