189 lines
6.3 KiB
TypeScript
189 lines
6.3 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, 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,
|
|
});
|
|
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<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));
|
|
}
|
|
}
|