236 lines
9.6 KiB
TypeScript
236 lines
9.6 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|