Initial commit
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user