Files
station-master/test/sim.test.ts
T
2026-07-31 07:19:57 -04:00

236 lines
9.6 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 { 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');
});
});