Files
AIHostingTycoon/packages/game-simulation/src/runner.ts
T
josh 57a81be769 Cache serving pipeline fleet to eliminate per-tick rebuilds and reduce GC pressure
Fleet template is now rebuilt only when deploymentVersion changes (~68 times per
28,800-tick run instead of every tick). Reuses module-level Maps, arrays, and
utilization objects instead of allocating new ones each tick. Replaces 4x
Object.values().reduce() with single-pass aggregation and sorts fleet in-place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 19:51:13 -04:00

167 lines
5.5 KiB
TypeScript

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<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 (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,
};
}