Files
station-master/src/sim/harness.ts
T

192 lines
6.6 KiB
TypeScript

/**
* Component 18 — Balance simulation harness.
*
* Dev-side only. Runs many seeded games and reports the numbers that the provisional values in
* docs/rules/card-reference.md were guessed at. See architecture/components.md §2 D.18.
*
* The point is narrow and worth stating: this can tell you whether the ARITHMETIC of the game
* works — whether targets are reachable, whether Crew Trays bottleneck, whether collisions fire at
* a sane rate. It cannot tell you whether the game is any fun. Only a table can do that.
*
* Run with: node src/sim/harness.ts [games] [length]
*/
import { pump } from '../engine/advance.ts';
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
import { createGame } from '../engine/setup.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
import type { BotPolicy, PlayOutcome } from './bot.ts';
import { developerBot, playGame, randomBot } from './bot.ts';
import type { GameStats } from './stats.ts';
import { formatAggregate, makeFunnelProbe, summarize } from './stats.ts';
export type SimOptions = {
games: number;
length: GameLength;
mode: GameMode;
players: string[];
policy: BotPolicy;
};
export type SimReport = {
policy: string;
games: number;
finished: number;
wins: number;
revenuePerPlayer: Stats;
revenuePerPlayerPerDay: Stats;
collisionsPerGame: Stats;
trainsScheduled: Stats;
cardsPlayed: Stats;
daysPlayed: Stats;
outcomeReasons: Record<string, number>;
/** Per-game detail, for end-of-game statistics and anomaly detection. */
perGame: GameStats[];
};
export type Stats = { min: number; max: number; mean: number; median: number };
function statsOf(xs: number[]): Stats {
if (xs.length === 0) return { min: 0, max: 0, mean: 0, median: 0 };
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
min: sorted[0]!,
max: sorted[sorted.length - 1]!,
mean: xs.reduce((a, b) => a + b, 0) / xs.length,
median:
sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!,
};
}
function configFor(mode: GameMode, length: GameLength): GameConfig {
return {
mode,
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
}
export function simulate(opts: SimOptions): SimReport {
const results: PlayOutcome[] = [];
const stats: GameStats[] = [];
for (let i = 0; i < opts.games; i++) {
const seed = 1000 + i * 7919; // a prime stride, so seeds do not correlate
const s = createGame({
id: `sim-${i}`,
seed,
config: configFor(opts.mode, opts.length),
playerNames: opts.players,
});
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
// events, so they cannot be recovered from the log afterwards.
const probe = makeFunnelProbe(0);
const r = playGame(s, opts.policy, pump, 50_000, probe.onEvent, probe.onTurn);
results.push(r);
stats.push(summarize(seed, r.events, r.intents, s, probe.funnel));
}
const finished = results.filter((r) => r.finished);
const perPlayer = finished.flatMap((r) => r.revenue);
const days = finished.map((r) => Math.max(1, r.days - 1));
const perDay = finished.flatMap((r) =>
r.revenue.map((v) => v / Math.max(1, r.days - 1)),
);
const reasons: Record<string, number> = {};
for (const r of finished) {
const key = r.outcome ? `${r.outcome.result}/${r.outcome.reason}` : 'none';
reasons[key] = (reasons[key] ?? 0) + 1;
}
return {
policy: opts.policy.name,
games: opts.games,
finished: finished.length,
wins: finished.filter((r) => r.outcome?.result === 'win').length,
revenuePerPlayer: statsOf(perPlayer),
revenuePerPlayerPerDay: statsOf(perDay),
collisionsPerGame: statsOf(finished.map((r) => r.collisions)),
trainsScheduled: statsOf(finished.map((r) => r.trainsScheduled)),
cardsPlayed: statsOf(finished.map((r) => r.cardsPlayed)),
daysPlayed: statsOf(days),
outcomeReasons: reasons,
perGame: stats,
};
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
const f = (n: number): string => n.toFixed(1).padStart(6);
function line(label: string, s: Stats): string {
return ` ${label.padEnd(26)} ${f(s.mean)} ${f(s.median)} ${f(s.min)} ${f(s.max)}`;
}
export function formatReport(r: SimReport, length: GameLength, players: number): string {
const profile = lengthProfile(length);
const out: string[] = [];
out.push(`\n=== ${r.policy} bot · ${length} · ${players}p · ${r.games} games ===`);
out.push(` finished ${r.finished}/${r.games} wins ${r.wins}/${r.finished}`);
out.push(` ${''.padEnd(26)} mean median min max`);
out.push(line('revenue per player', r.revenuePerPlayer));
out.push(line('revenue per player per Day', r.revenuePerPlayerPerDay));
out.push(line('collisions per game', r.collisionsPerGame));
out.push(line('trains scheduled', r.trainsScheduled));
out.push(line('cards played', r.cardsPlayed));
out.push(line('days played', r.daysPlayed));
out.push('\n outcomes:');
for (const [k, v] of Object.entries(r.outcomeReasons).sort((a, b) => b[1] - a[1])) {
out.push(` ${k.padEnd(28)} ${v}`);
}
// The provisional numbers this exists to test.
out.push('\n against the provisional targets:');
out.push(` target ${profile.target} over ${profile.days} Days`);
out.push(
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
);
out.push(
` collective floor (competitive) ${collectiveRevenueFloor(players, profile.days)}` +
`; observed total mean ${(r.revenuePerPlayer.mean * players).toFixed(1)}`,
);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith('harness.ts') ?? false;
if (isMain) {
const games = Number(process.argv[2] ?? 200);
const length = (process.argv[3] ?? 'standard') as GameLength;
for (const policy of [developerBot, randomBot(12345)]) {
const report = simulate({
games,
length,
mode: 'solitaire',
players: ['bot'],
policy,
});
console.log(formatReport(report, length, 1));
if (policy === developerBot) console.log(formatAggregate(report.perGame));
}
}