import type { GameState } from '@ai-tycoon/shared'; import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, resetResearchBonusCache, resetFleetCache } 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; onProgress?: (tick: number, totalTicks: number, era: string) => void; } 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(); resetFleetCache(); let rng: ReturnType | 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)['_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 (tick % progressInterval === 0) { if (!config.silent) { printProgress(tick, config.totalTicks, state, startTime); } config.onProgress?.(tick, config.totalTicks, state.meta.currentEra); } } 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, }; }