Add game-simulation package with multi-run balance testing, fix stalled-pipeline trap
Adds a full simulation harness (game-simulation package) with greedy/random strategies, 36-metric diagnostics, multi-run orchestration via child processes, and a statistical interpreter. Includes 2.3x engine performance optimizations (research bonus caching, per-DC dirty tracking, reduced allocations in tick pipeline, single-pass loops). Fixes a critical balance bug where training pipelines stalled on insufficient VRAM would permanently block training slots — the engine never re-checked stalled pipelines, and the greedy strategy didn't pre-check VRAM requirements. This caused 20-25% of seeds to get stuck in Scale-up era. All three fixes (engine un-stalling, strategy VRAM pre-check, stalled pipeline cancellation) bring pass rate from 75% to 100% across 20 random seeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, resetResearchBonusCache } from '@ai-tycoon/game-engine';
|
||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||
import type { Strategy, SimulationMetrics } from './strategies/types';
|
||||
import { collectMetrics } from './analysis/metrics';
|
||||
import { createInitialState } from './initialState';
|
||||
import { createSeededRNG } from './rng';
|
||||
import { resetIds } from './actions/ids';
|
||||
|
||||
export interface SimulationConfig {
|
||||
totalTicks: number;
|
||||
decisionInterval: number;
|
||||
strategy: Strategy;
|
||||
seed?: number;
|
||||
verbose?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export interface EraTransition {
|
||||
from: string;
|
||||
to: string;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
metrics: SimulationMetrics[];
|
||||
notifications: TickNotification[];
|
||||
eraTransitions: EraTransition[];
|
||||
finalState: GameState;
|
||||
wallTimeMs: number;
|
||||
}
|
||||
|
||||
function formatEra(era: string): string {
|
||||
switch (era) {
|
||||
case 'startup': return 'Startup';
|
||||
case 'scaleup': return 'Scale-up';
|
||||
case 'bigtech': return 'Big Tech';
|
||||
case 'agi': return 'AGI';
|
||||
default: return era;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(n: number): string {
|
||||
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
|
||||
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||||
return `$${n.toFixed(0)}`;
|
||||
}
|
||||
|
||||
const isTTY = process.stdout.isTTY ?? false;
|
||||
|
||||
function printProgress(tick: number, total: number, state: GameState, startTime: number): void {
|
||||
const pct = ((tick / total) * 100).toFixed(1);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const ticksPerSec = tick > 0 ? (tick / ((Date.now() - startTime) / 1000)).toFixed(0) : '—';
|
||||
const eta = tick > 0 ? (((total - tick) / (tick / ((Date.now() - startTime) / 1000)))).toFixed(0) : '?';
|
||||
|
||||
const barWidth = 30;
|
||||
const filled = Math.round((tick / total) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
|
||||
const era = formatEra(state.meta.currentEra);
|
||||
const cash = formatMoney(state.economy.money);
|
||||
const rev = formatMoney(state.economy.revenuePerTick);
|
||||
|
||||
const line = ` ${bar} ${pct}% | tick ${tick.toLocaleString().padStart(7)}/${total.toLocaleString()} | ${elapsed}s (${ticksPerSec} t/s, ETA ${eta}s) | ${era} | Cash: ${cash} | Rev/t: ${rev}`;
|
||||
|
||||
if (isTTY) {
|
||||
process.stdout.write(`\r${line} `);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function runSimulation(config: SimulationConfig): SimulationResult {
|
||||
const startTime = Date.now();
|
||||
|
||||
resetIds();
|
||||
resetResearchBonusCache();
|
||||
|
||||
let rng: ReturnType<typeof createSeededRNG> | null = null;
|
||||
if (config.seed !== undefined) {
|
||||
rng = createSeededRNG(config.seed);
|
||||
rng.install();
|
||||
}
|
||||
|
||||
setAchievementDefinitions(ACHIEVEMENT_DEFINITIONS);
|
||||
|
||||
const state = createInitialState('GreedyAI Corp');
|
||||
|
||||
const allMetrics: SimulationMetrics[] = [];
|
||||
const allNotifications: TickNotification[] = [];
|
||||
const eraTransitions: EraTransition[] = [];
|
||||
let lastEra = state.meta.currentEra;
|
||||
|
||||
const progressInterval = isTTY
|
||||
? Math.max(1, Math.floor(config.totalTicks / 200))
|
||||
: Math.max(1, Math.floor(config.totalTicks / 20));
|
||||
|
||||
for (let tick = 0; tick < config.totalTicks; tick++) {
|
||||
if (tick % config.decisionInterval === 0) {
|
||||
config.strategy.decide(state, allMetrics);
|
||||
}
|
||||
|
||||
const result = processTick(state);
|
||||
|
||||
const notifications = (result as Record<string, unknown>)['_notifications'] as TickNotification[] | undefined;
|
||||
if (notifications && notifications.length > 0) {
|
||||
allNotifications.push(...notifications);
|
||||
}
|
||||
|
||||
// Apply tick result directly — keys are known and fixed
|
||||
if (result.meta) state.meta = result.meta;
|
||||
if (result.economy) state.economy = result.economy;
|
||||
if (result.infrastructure) state.infrastructure = result.infrastructure;
|
||||
if (result.compute) state.compute = result.compute;
|
||||
if (result.research) state.research = result.research;
|
||||
if (result.models) state.models = result.models;
|
||||
if (result.market) state.market = result.market;
|
||||
if (result.talent) state.talent = result.talent;
|
||||
if (result.reputation) state.reputation = result.reputation;
|
||||
if (result.data) state.data = result.data;
|
||||
if (result.competitors) state.competitors = result.competitors;
|
||||
if (result.achievements) state.achievements = result.achievements;
|
||||
|
||||
if (state.meta.currentEra !== lastEra) {
|
||||
eraTransitions.push({
|
||||
from: lastEra,
|
||||
to: state.meta.currentEra,
|
||||
tick: state.meta.tickCount,
|
||||
});
|
||||
if (!config.silent && process.stdout.isTTY) {
|
||||
process.stdout.write(`\n >> Era transition: ${formatEra(lastEra)} -> ${formatEra(state.meta.currentEra)} at tick ${state.meta.tickCount.toLocaleString()}\n`);
|
||||
}
|
||||
lastEra = state.meta.currentEra;
|
||||
}
|
||||
|
||||
if (tick % config.decisionInterval === 0) {
|
||||
allMetrics.push(collectMetrics(state));
|
||||
}
|
||||
|
||||
if (!config.silent && tick % progressInterval === 0) {
|
||||
printProgress(tick, config.totalTicks, state, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.silent) {
|
||||
printProgress(config.totalTicks, config.totalTicks, state, startTime);
|
||||
if (isTTY) process.stdout.write('\n');
|
||||
}
|
||||
|
||||
if (rng) rng.uninstall();
|
||||
|
||||
return {
|
||||
metrics: allMetrics,
|
||||
notifications: allNotifications,
|
||||
eraTransitions,
|
||||
finalState: state,
|
||||
wallTimeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user