/** * 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, 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; /** 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, }); const r = playGame(s, opts.policy, pump); results.push(r); stats.push(summarize(seed, r.events, r.intents, s)); } 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 = {}; 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)); } }