import os from 'node:os'; import type { createReservedTurnHandler } from '../../src/turn/reservedTurnHandler.js'; export type ProfiledAction = Parameters< NonNullable[0]['onActionProfiled']> >[0]; type DurationSeries = { durationsNs: number[]; totalNs: number; }; type MonthBucket = { year: number; month: number; generalTurns: number; chiefGeneralTurns: number; ordinaryGeneralTurns: number; totalGeneralTurnNs: number; aiDecisionCount: number; aiDecisionNs: number; commandCount: number; commandExecutionNs: number; activeNationCount: number; generalCount: number; }; const createSeries = (): DurationSeries => ({ durationsNs: [], totalNs: 0 }); const percentile = (values: readonly number[], ratio: number): number => { if (values.length === 0) return 0; const sorted = [...values].sort((left, right) => left - right); const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1)); return sorted[index] ?? 0; }; const maximum = (values: readonly number[]): number => { let result = 0; for (const value of values) result = Math.max(result, value); return result; }; const summarizeSeries = (series: DurationSeries) => ({ count: series.durationsNs.length, totalMs: series.totalNs / 1_000_000, averageMs: series.durationsNs.length > 0 ? series.totalNs / series.durationsNs.length / 1_000_000 : 0, p50Ms: percentile(series.durationsNs, 0.5) / 1_000_000, p95Ms: percentile(series.durationsNs, 0.95) / 1_000_000, p99Ms: percentile(series.durationsNs, 0.99) / 1_000_000, maxMs: maximum(series.durationsNs) / 1_000_000, }); const monthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, '0')}`; export class NpcUnificationTimingProfiler { private readonly commandSeries = new Map(); private readonly commandAiSeries = new Map(); private readonly decisionSeries = new Map<'chief' | 'ordinary', DurationSeries>([ ['chief', createSeries()], ['ordinary', createSeries()], ]); private readonly turnSeries = new Map<'chief' | 'ordinary', DurationSeries>([ ['chief', createSeries()], ['ordinary', createSeries()], ]); private readonly months = new Map(); private readonly monthWallMs = new Map(); private maxHeapUsedBytes = 0; private maxRssBytes = 0; private getMonth(year: number, month: number): MonthBucket { const key = monthKey(year, month); const existing = this.months.get(key); if (existing) return existing; const created: MonthBucket = { year, month, generalTurns: 0, chiefGeneralTurns: 0, ordinaryGeneralTurns: 0, totalGeneralTurnNs: 0, aiDecisionCount: 0, aiDecisionNs: 0, commandCount: 0, commandExecutionNs: 0, activeNationCount: 0, generalCount: 0, }; this.months.set(key, created); return created; } observeAction(payload: ProfiledAction): void { const commandKey = `${payload.kind}:${payload.actionKey}`; const actionDurationNs = Number(payload.actionDurationNs); const command = this.commandSeries.get(commandKey) ?? createSeries(); command.durationsNs.push(actionDurationNs); command.totalNs += actionDurationNs; this.commandSeries.set(commandKey, command); const month = this.getMonth(payload.year, payload.month); month.commandCount += 1; month.commandExecutionNs += actionDurationNs; if (!payload.usedAi) return; const decisionDurationNs = Number(payload.aiDecisionDurationNs); const commandAi = this.commandAiSeries.get(commandKey) ?? createSeries(); commandAi.durationsNs.push(decisionDurationNs); commandAi.totalNs += decisionDurationNs; this.commandAiSeries.set(commandKey, commandAi); const officerGroup = payload.officerLevel >= 5 ? 'chief' : 'ordinary'; const decision = this.decisionSeries.get(officerGroup)!; decision.durationsNs.push(decisionDurationNs); decision.totalNs += decisionDurationNs; month.aiDecisionCount += 1; month.aiDecisionNs += decisionDurationNs; } observeGeneralTurn(input: { year: number; month: number; officerLevel: number; durationNs: bigint }): void { const durationNs = Number(input.durationNs); const officerGroup = input.officerLevel >= 5 ? 'chief' : 'ordinary'; const series = this.turnSeries.get(officerGroup)!; series.durationsNs.push(durationNs); series.totalNs += durationNs; const month = this.getMonth(input.year, input.month); month.generalTurns += 1; month.totalGeneralTurnNs += durationNs; if (officerGroup === 'chief') month.chiefGeneralTurns += 1; else month.ordinaryGeneralTurns += 1; } observeMonth(input: { year: number; month: number; wallDurationMs: number; activeNationCount: number; generalCount: number; }): void { this.monthWallMs.set(monthKey(input.year, input.month), input.wallDurationMs); const month = this.getMonth(input.year, input.month); month.activeNationCount = input.activeNationCount; month.generalCount = input.generalCount; const usage = process.memoryUsage(); this.maxHeapUsedBytes = Math.max(this.maxHeapUsedBytes, usage.heapUsed); this.maxRssBytes = Math.max(this.maxRssBytes, usage.rss); } buildReport(input: { startedAtNs: bigint; scenarioId: number; scenarioTitle: string; hiddenSeed: string; initialGeneralCount: number; initialCityCount: number; startYear: number; startMonth: number; finalYear: number; finalMonth: number; finalGeneralCount: number; foundedNationCount: number; finalNationCount: number; unificationReached: boolean; convergenceAssist: string; discardedDrafts: { logs: number; messages: number; neutralAuctions: number }; }) { const commandKeys = Array.from(this.commandSeries.keys()).sort(); const commands = commandKeys.map((key) => ({ key, execution: summarizeSeries(this.commandSeries.get(key)!), aiDecision: summarizeSeries(this.commandAiSeries.get(key) ?? createSeries()), })); const months = Array.from(this.months.entries()) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, bucket]) => ({ key, year: bucket.year, month: bucket.month, generalTurns: bucket.generalTurns, chiefGeneralTurns: bucket.chiefGeneralTurns, ordinaryGeneralTurns: bucket.ordinaryGeneralTurns, activeNationCount: bucket.activeNationCount, generalCount: bucket.generalCount, wallDurationMs: this.monthWallMs.get(key) ?? 0, totalGeneralTurnMs: bucket.totalGeneralTurnNs / 1_000_000, averageGeneralTurnMs: bucket.generalTurns > 0 ? bucket.totalGeneralTurnNs / bucket.generalTurns / 1_000_000 : 0, aiDecisionCount: bucket.aiDecisionCount, totalAiDecisionMs: bucket.aiDecisionNs / 1_000_000, averageAiDecisionMs: bucket.aiDecisionCount > 0 ? bucket.aiDecisionNs / bucket.aiDecisionCount / 1_000_000 : 0, commandCount: bucket.commandCount, totalCommandExecutionMs: bucket.commandExecutionNs / 1_000_000, averageCommandExecutionMs: bucket.commandCount > 0 ? bucket.commandExecutionNs / bucket.commandCount / 1_000_000 : 0, })); const startIndex = input.startYear * 12 + input.startMonth - 1; const finalIndex = input.finalYear * 12 + input.finalMonth - 1; return { schemaVersion: 1, runtime: { node: process.version, platform: process.platform, arch: process.arch, cpuModel: os.cpus()[0]?.model ?? 'unknown', logicalCpuCount: os.cpus().length, totalMemoryBytes: os.totalmem(), }, scenario: { id: input.scenarioId, title: input.scenarioTitle, hiddenSeed: input.hiddenSeed, initialGeneralCount: input.initialGeneralCount, initialCityCount: input.initialCityCount, startYear: input.startYear, startMonth: input.startMonth, convergenceAssist: input.convergenceAssist, }, result: { unificationReached: input.unificationReached, finalYear: input.finalYear, finalMonth: input.finalMonth, simulatedMonths: finalIndex - startIndex, finalGeneralCount: input.finalGeneralCount, foundedNationCount: input.foundedNationCount, finalNationCount: input.finalNationCount, wallDurationMs: Number(process.hrtime.bigint() - input.startedAtNs) / 1_000_000, discardedDrafts: input.discardedDrafts, }, npcDecisionByOfficerGroup: { chief: summarizeSeries(this.decisionSeries.get('chief')!), ordinary: summarizeSeries(this.decisionSeries.get('ordinary')!), }, generalTurnByOfficerGroup: { chief: summarizeSeries(this.turnSeries.get('chief')!), ordinary: summarizeSeries(this.turnSeries.get('ordinary')!), }, memory: { maxObservedHeapUsedBytes: this.maxHeapUsedBytes, maxObservedRssBytes: this.maxRssBytes, processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024, }, commands, months, }; } }