57a81be769
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>
167 lines
5.5 KiB
TypeScript
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,
|
|
};
|
|
}
|