Add game-simulation package with multi-run balance testing, fix stalled-pipeline trap
Balance Check / balance-simulation (push) Failing after 11m32s
Balance Check / multi-run-balance (push) Failing after 23m46s
CI / build-and-push (push) Successful in 1m20s

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:
2026-04-26 06:11:26 -04:00
parent 283c7c7932
commit 102e05c8ba
51 changed files with 4294 additions and 132 deletions
@@ -0,0 +1,42 @@
import type { SimulationMetrics } from '../strategies/types';
import type { TickNotification } from '@ai-tycoon/game-engine';
export interface RevenueBreakpoint {
tick: number;
revenueBefore: number;
revenueAfter: number;
percentIncrease: number;
possibleCause: string;
}
export function detectBreakpoints(
metrics: SimulationMetrics[],
notifications: TickNotification[],
threshold = 3.0,
minRevenueFloor = 5000,
): RevenueBreakpoint[] {
const breakpoints: RevenueBreakpoint[] = [];
for (let i = 1; i < metrics.length; i++) {
const prev = metrics[i - 1];
const curr = metrics[i];
if (prev.revenue >= minRevenueFloor && curr.revenue / prev.revenue > threshold) {
const nearby = notifications.filter(n =>
n.type === 'success' && Math.abs(parseInt(String(curr.tick)) - parseInt(String(prev.tick))) < 120,
);
const cause = nearby.length > 0 ? nearby[nearby.length - 1].title : 'Unknown';
breakpoints.push({
tick: curr.tick,
revenueBefore: prev.revenue,
revenueAfter: curr.revenue,
percentIncrease: ((curr.revenue - prev.revenue) / prev.revenue) * 100,
possibleCause: cause,
});
}
}
return breakpoints;
}
@@ -0,0 +1,107 @@
import type { SimulationMetrics } from '../strategies/types';
export interface CashFlowPeriod {
startTick: number;
endTick: number;
durationTicks: number;
averageBurnRate: number;
startingCash: number;
endingCash: number;
}
export interface BankruptcyRisk {
tick: number;
cash: number;
burnRate: number;
ticksToZero: number;
hasRevenueStream: boolean;
}
export interface CashFlowResult {
peakCash: { amount: number; tick: number };
minCash: { amount: number; tick: number };
negativePeriods: CashFlowPeriod[];
bankruptcyRisks: BankruptcyRisk[];
averageBurnRateByEra: Record<string, number>;
}
export function analyzeCashFlow(
metrics: SimulationMetrics[],
bankruptcyThresholdTicks = 300,
): CashFlowResult {
let peakCash = { amount: -Infinity, tick: 0 };
let minCash = { amount: Infinity, tick: 0 };
const negativePeriods: CashFlowPeriod[] = [];
const bankruptcyRisks: BankruptcyRisk[] = [];
const eraBurnSums = new Map<string, { total: number; count: number }>();
let negStart: { tick: number; cash: number; burnSum: number; count: number } | null = null;
for (const m of metrics) {
if (m.money > peakCash.amount) peakCash = { amount: m.money, tick: m.tick };
if (m.money < minCash.amount) minCash = { amount: m.money, tick: m.tick };
const netFlow = m.netCashFlow;
const eraStat = eraBurnSums.get(m.era) ?? { total: 0, count: 0 };
eraStat.total += netFlow;
eraStat.count += 1;
eraBurnSums.set(m.era, eraStat);
if (netFlow < 0) {
if (!negStart) {
negStart = { tick: m.tick, cash: m.money, burnSum: netFlow, count: 1 };
} else {
negStart.burnSum += netFlow;
negStart.count += 1;
}
const burnRate = Math.abs(netFlow);
if (burnRate > 0) {
const ticksToZero = m.money / burnRate;
const hasEverHadRevenue = m.totalRevenue > 0;
if (ticksToZero < bankruptcyThresholdTicks && hasEverHadRevenue) {
bankruptcyRisks.push({
tick: m.tick,
cash: m.money,
burnRate: -burnRate,
ticksToZero: Math.round(ticksToZero),
hasRevenueStream: m.revenue > 0,
});
}
}
} else if (negStart) {
negativePeriods.push({
startTick: negStart.tick,
endTick: m.tick,
durationTicks: m.tick - negStart.tick,
averageBurnRate: negStart.burnSum / negStart.count,
startingCash: negStart.cash,
endingCash: m.money,
});
negStart = null;
}
}
if (negStart) {
const lastTick = metrics[metrics.length - 1]?.tick ?? negStart.tick;
negativePeriods.push({
startTick: negStart.tick,
endTick: lastTick,
durationTicks: lastTick - negStart.tick,
averageBurnRate: negStart.burnSum / negStart.count,
startingCash: negStart.cash,
endingCash: metrics[metrics.length - 1]?.money ?? 0,
});
}
const averageBurnRateByEra: Record<string, number> = {};
for (const [era, stat] of eraBurnSums) {
averageBurnRateByEra[era] = stat.count > 0 ? stat.total / stat.count : 0;
}
if (peakCash.amount === -Infinity) peakCash = { amount: 0, tick: 0 };
if (minCash.amount === Infinity) minCash = { amount: 0, tick: 0 };
return { peakCash, minCash, negativePeriods, bankruptcyRisks, averageBurnRateByEra };
}
@@ -0,0 +1,79 @@
import type { GameState, RackSkuId } from '@ai-tycoon/shared';
import { RACK_SKU_CONFIGS } from '@ai-tycoon/shared';
import type { SimulationMetrics } from '../strategies/types';
export interface DeadZone {
startTick: number;
endTick: number;
durationTicks: number;
description: string;
}
function getCheapestSkuCost(state: GameState): number {
const era = state.meta.currentEra;
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const eraIdx = eraOrder.indexOf(era);
let cheapest = Infinity;
for (const [, sku] of Object.entries(RACK_SKU_CONFIGS)) {
if (eraOrder.indexOf(sku.era) <= eraIdx) {
cheapest = Math.min(cheapest, sku.baseCost);
}
}
return cheapest === Infinity ? 100_000 : cheapest;
}
export function detectDeadZones(
metrics: SimulationMetrics[],
cheapestSkuCost: number,
windowSize = 10,
revenueTolerance = 0.02,
capabilityTolerance = 0.02,
): DeadZone[] {
const zones: DeadZone[] = [];
let zoneStart: number | null = null;
for (let i = windowSize; i < metrics.length; i++) {
const current = metrics[i];
const past = metrics[i - windowSize];
const revFlat = past.revenue > 0
? Math.abs(current.revenue - past.revenue) / past.revenue < revenueTolerance
: current.revenue === 0;
const capFlat = past.bestModelCapability > 0
? Math.abs(current.bestModelCapability - past.bestModelCapability) / past.bestModelCapability < capabilityTolerance
: current.bestModelCapability === 0;
const isStuck = revFlat && capFlat && current.money < cheapestSkuCost * 2;
if (isStuck) {
if (zoneStart === null) zoneStart = past.tick;
} else {
if (zoneStart !== null) {
const endTick = metrics[i - 1].tick;
zones.push({
startTick: zoneStart,
endTick,
durationTicks: endTick - zoneStart,
description: 'revenue flat, no affordable upgrades',
});
zoneStart = null;
}
}
}
if (zoneStart !== null) {
const endTick = metrics[metrics.length - 1].tick;
zones.push({
startTick: zoneStart,
endTick,
durationTicks: endTick - zoneStart,
description: 'revenue flat, no affordable upgrades (ongoing)',
});
}
return zones;
}
export { getCheapestSkuCost };
@@ -0,0 +1,190 @@
import type { SimulationMetrics } from '../strategies/types';
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
export interface ThresholdDistance {
metric: 'revenue' | 'capability' | 'reputation';
current: number;
required: number;
percentComplete: number;
isMet: boolean;
}
export interface EraProximitySnapshot {
tick: number;
targetEra: string;
thresholds: ThresholdDistance[];
bottleneck: string;
reputationComponents?: {
safetyRecord: number;
publicPerception: number;
employeeSatisfaction: number;
regulatoryStanding: number;
lowestComponent: string;
};
}
export interface EraCeiling {
era: string;
metric: string;
stuckAtValue: number;
stuckSinceTick: number;
stuckDurationTicks: number;
requiredValue: number;
}
export interface MathCeiling {
era: string;
theoreticalMax: number;
required: number;
components: Record<string, number>;
}
export interface EraProximityResult {
snapshots: EraProximitySnapshot[];
ceilings: EraCeiling[];
mathCeilings: MathCeiling[];
perEraBottleneck: Record<string, string>;
}
function getNextEra(current: string): string | null {
const idx = ERA_ORDER.indexOf(current as typeof ERA_ORDER[number]);
return idx >= 0 && idx < ERA_ORDER.length - 1 ? ERA_ORDER[idx + 1] : null;
}
function computeThresholds(m: SimulationMetrics, targetEra: string): ThresholdDistance[] {
const thresholds = ERA_THRESHOLDS[targetEra as keyof typeof ERA_THRESHOLDS];
if (!thresholds) return [];
return [
{
metric: 'revenue' as const,
current: m.totalRevenue,
required: thresholds.revenue,
percentComplete: Math.min(100, (m.totalRevenue / thresholds.revenue) * 100),
isMet: m.totalRevenue >= thresholds.revenue,
},
{
metric: 'capability' as const,
current: m.bestModelCapability,
required: thresholds.capability,
percentComplete: Math.min(100, (m.bestModelCapability / thresholds.capability) * 100),
isMet: m.bestModelCapability >= thresholds.capability,
},
{
metric: 'reputation' as const,
current: m.reputation,
required: thresholds.reputation,
percentComplete: Math.min(100, (m.reputation / thresholds.reputation) * 100),
isMet: m.reputation >= thresholds.reputation,
},
];
}
export function analyzeEraProximity(
metrics: SimulationMetrics[],
ceilingWindowTicks = 1800,
): EraProximityResult {
const snapshots: EraProximitySnapshot[] = [];
const perEraBottleneck: Record<string, string> = {};
const ceilingTrackers = new Map<string, { value: number; sinceTick: number }>();
for (const m of metrics) {
const targetEra = getNextEra(m.era);
if (!targetEra) continue;
const thresholds = computeThresholds(m, targetEra);
if (thresholds.length === 0) continue;
const unmet = thresholds.filter(t => !t.isMet);
const bottleneck = unmet.length > 0
? unmet.reduce((a, b) => a.percentComplete < b.percentComplete ? a : b).metric
: 'none';
let reputationComponents: EraProximitySnapshot['reputationComponents'];
if (bottleneck === 'reputation') {
const comps = {
safetyRecord: m.safetyRecord,
publicPerception: m.publicPerception,
employeeSatisfaction: m.employeeSatisfaction,
regulatoryStanding: m.regulatoryStanding,
};
const lowest = (Object.entries(comps) as [string, number][])
.reduce((a, b) => a[1] < b[1] ? a : b);
reputationComponents = { ...comps, lowestComponent: lowest[0] };
}
snapshots.push({ tick: m.tick, targetEra, thresholds, bottleneck, reputationComponents });
perEraBottleneck[targetEra] = bottleneck;
for (const t of thresholds) {
const key = `${targetEra}:${t.metric}`;
if (t.isMet) {
ceilingTrackers.delete(key);
continue;
}
if (t.metric === 'capability' && m.activeTrainingPipelines > 0) continue;
const tracker = ceilingTrackers.get(key);
if (!tracker) {
ceilingTrackers.set(key, { value: t.current, sinceTick: m.tick });
} else {
const improvementPct = tracker.value > 0
? ((t.current - tracker.value) / tracker.value) * 100
: (t.current > 0 ? 100 : 0);
if (improvementPct > 1) {
ceilingTrackers.set(key, { value: t.current, sinceTick: m.tick });
}
}
}
}
const ceilings: EraCeiling[] = [];
for (const [key, tracker] of ceilingTrackers) {
const lastMetric = metrics[metrics.length - 1];
if (!lastMetric) break;
const duration = lastMetric.tick - tracker.sinceTick;
if (duration >= ceilingWindowTicks) {
const [era, metric] = key.split(':');
const thresholds = ERA_THRESHOLDS[era as keyof typeof ERA_THRESHOLDS];
if (!thresholds) continue;
ceilings.push({
era,
metric,
stuckAtValue: tracker.value,
stuckSinceTick: tracker.sinceTick,
stuckDurationTicks: duration,
requiredValue: thresholds[metric as keyof typeof thresholds],
});
}
}
const mathCeilings: MathCeiling[] = [];
const lastMetric = metrics[metrics.length - 1];
if (lastMetric) {
const targetEra = getNextEra(lastMetric.era);
if (targetEra) {
const thresholds = ERA_THRESHOLDS[targetEra as keyof typeof ERA_THRESHOLDS];
if (thresholds) {
const maxSafety = 80;
const maxPublic = 100;
const maxEmployee = 100;
const maxRegulatory = 100;
const theoreticalMax = Math.round(
maxSafety * 0.3 + maxPublic * 0.3 + maxEmployee * 0.2 + maxRegulatory * 0.2,
);
if (theoreticalMax < thresholds.reputation) {
mathCeilings.push({
era: targetEra,
theoreticalMax,
required: thresholds.reputation,
components: { maxSafety, maxPublic, maxEmployee, maxRegulatory },
});
}
}
}
}
return { snapshots, ceilings, mathCeilings, perEraBottleneck };
}
@@ -0,0 +1,171 @@
import type { GameState } from '@ai-tycoon/shared';
import { RACK_SKU_CONFIGS, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import { TECH_TREE } from '@ai-tycoon/game-engine';
export interface FeatureUsage {
name: string;
category: 'research' | 'infrastructure' | 'revenue' | 'talent' | 'model' | 'funding';
used: boolean;
available: boolean;
}
export interface FeatureUtilizationResult {
features: FeatureUsage[];
coverageByCategory: Record<string, { used: number; available: number; percent: number }>;
unusedFeatures: string[];
neverAvailable: string[];
revenueStreamDiversity: number;
}
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'];
export function analyzeFeatureUtilization(state: GameState): FeatureUtilizationResult {
const features: FeatureUsage[] = [];
const currentEraIdx = ERA_ORDER.indexOf(state.meta.currentEra);
const completed = new Set(state.research.completedResearch);
// --- Research nodes ---
for (const node of TECH_TREE) {
const eraIdx = ERA_ORDER.indexOf(node.era);
const available = eraIdx <= currentEraIdx && node.prerequisites.every(p => completed.has(p));
features.push({
name: `research:${node.id}`,
category: 'research',
used: completed.has(node.id),
available: available || completed.has(node.id),
});
}
// --- Infrastructure diversity ---
const deployedSkus = new Set<string>();
const dcTiers = new Set<string>();
const coolingTypes = new Set<string>();
const networkFabrics = new Set<string>();
const locations = new Set<string>();
for (const cluster of state.infrastructure.clusters) {
locations.add(cluster.locationId);
for (const campus of cluster.campuses) {
dcTiers.add(campus.dcTier);
for (const dc of campus.dataCenters) {
coolingTypes.add(dc.coolingType);
networkFabrics.add(dc.networkFabric);
if (dc.rackSkuId) deployedSkus.add(dc.rackSkuId);
}
}
}
for (const [skuId, sku] of Object.entries(RACK_SKU_CONFIGS)) {
const eraIdx = ERA_ORDER.indexOf(sku.era);
const available = eraIdx <= currentEraIdx && sku.requiredResearch.every((r: string) => completed.has(r));
features.push({
name: `rack:${skuId}`,
category: 'infrastructure',
used: deployedSkus.has(skuId),
available: available || deployedSkus.has(skuId),
});
}
features.push({ name: 'cooling:liquid', category: 'infrastructure', used: coolingTypes.has('liquid') || coolingTypes.has('immersion'), available: completed.has('liquid-cooling-tech') });
features.push({ name: 'cooling:immersion', category: 'infrastructure', used: coolingTypes.has('immersion'), available: completed.has('immersion-cooling-tech') });
features.push({ name: 'network:400g', category: 'infrastructure', used: networkFabrics.has('ethernet-400g') || networkFabrics.has('infiniband-ndr') || networkFabrics.has('infiniband-xdr'), available: true });
features.push({ name: 'network:infiniband', category: 'infrastructure', used: networkFabrics.has('infiniband-ndr') || networkFabrics.has('infiniband-xdr'), available: completed.has('infiniband-networking') });
features.push({ name: 'multi-location', category: 'infrastructure', used: locations.size > 1, available: currentEraIdx >= 1 });
// --- Revenue streams ---
const ct = state.market.consumerTiers.tiers;
features.push({ name: 'tier:consumer-free', category: 'revenue', used: ct.free.config.isActive, available: true });
features.push({ name: 'tier:consumer-plus', category: 'revenue', used: ct.plus.config.isActive, available: true });
features.push({ name: 'tier:consumer-pro', category: 'revenue', used: ct.pro.config.isActive, available: true });
features.push({ name: 'tier:consumer-team', category: 'revenue', used: ct.team.config.isActive, available: true });
const at = state.market.apiTiers.tiers;
features.push({ name: 'tier:api-free', category: 'revenue', used: at.free.config.isActive, available: true });
features.push({ name: 'tier:api-payg', category: 'revenue', used: at.payg.config.isActive, available: true });
features.push({ name: 'tier:api-scale', category: 'revenue', used: at.scale.config.isActive, available: true });
features.push({ name: 'tier:api-enterprise', category: 'revenue', used: at['enterprise-api'].config.isActive, available: true });
features.push({ name: 'product:code-assistant', category: 'revenue', used: state.market.codeAssistant.isActive, available: completed.has('code-assistant-product') });
features.push({ name: 'product:agents-platform', category: 'revenue', used: state.market.agentsPlatform.isActive, available: completed.has('agents-platform-product') });
features.push({ name: 'enterprise-contracts', category: 'revenue', used: state.market.enterprise.activeContracts.length > 0, available: completed.has('enterprise-sales') });
features.push({ name: 'open-source-model', category: 'revenue', used: state.market.openSourcedModels.length > 0, available: state.models.baseModels.length > 0 });
// --- Talent ---
const depts = state.talent.departments;
features.push({ name: 'dept:research', category: 'talent', used: depts.research.headcount > 0, available: true });
features.push({ name: 'dept:engineering', category: 'talent', used: depts.engineering.headcount > 0, available: true });
features.push({ name: 'dept:operations', category: 'talent', used: depts.operations.headcount > 0, available: true });
features.push({ name: 'dept:sales', category: 'talent', used: depts.sales.headcount > 0, available: true });
// --- Model training variety ---
const architectures = new Set<string>();
const paramSizes = new Set<number>();
const sftSpecs = new Set<string>();
const alignmentMethods = new Set<string>();
let hasVariants = false;
let hasPointReleases = false;
for (const pipeline of state.models.activeTrainingPipelines) {
architectures.add(pipeline.architecture.type);
paramSizes.add(pipeline.architecture.totalParameters);
for (const spec of pipeline.stages.sft.specializations) sftSpecs.add(spec);
alignmentMethods.add(pipeline.stages.alignment.method);
if (pipeline.isPointRelease) hasPointReleases = true;
}
for (const model of state.models.baseModels) {
architectures.add(model.architecture.type);
paramSizes.add(model.architecture.totalParameters);
for (const spec of model.sftSpecializations) sftSpecs.add(spec);
if (model.alignmentMethod) alignmentMethods.add(model.alignmentMethod);
}
if (state.models.variantJobs.length > 0) hasVariants = true;
features.push({ name: 'model:dense-arch', category: 'model', used: architectures.has('dense'), available: true });
features.push({ name: 'model:moe-arch', category: 'model', used: architectures.has('moe'), available: paramSizes.size > 0 });
features.push({ name: 'model:multiple-sizes', category: 'model', used: paramSizes.size > 1, available: state.models.baseModels.length > 0 });
features.push({ name: 'model:sft-code', category: 'model', used: sftSpecs.has('code'), available: completed.has('code-generation') });
features.push({ name: 'model:sft-math', category: 'model', used: sftSpecs.has('math'), available: completed.has('reasoning-enhancement') });
features.push({ name: 'model:sft-creative', category: 'model', used: sftSpecs.has('creative'), available: completed.has('creative-systems') });
features.push({ name: 'model:alignment-rlhf', category: 'model', used: alignmentMethods.has('rlhf'), available: completed.has('alignment-research') });
features.push({ name: 'model:alignment-constitutional', category: 'model', used: alignmentMethods.has('constitutional'), available: completed.has('constitutional-ai') });
features.push({ name: 'model:quantization', category: 'model', used: hasVariants, available: completed.has('quantization') });
features.push({ name: 'model:point-releases', category: 'model', used: hasPointReleases, available: state.models.baseModels.length > 0 });
// --- Funding ---
const completedRounds = new Set<string>(state.economy.funding.completedRounds.map(r => r.type));
for (const roundType of Object.keys(FUNDING_ROUNDS)) {
features.push({
name: `funding:${roundType}`,
category: 'funding',
used: completedRounds.has(roundType),
available: true,
});
}
// --- Aggregate ---
const coverageByCategory: Record<string, { used: number; available: number; percent: number }> = {};
for (const f of features) {
if (!coverageByCategory[f.category]) {
coverageByCategory[f.category] = { used: 0, available: 0, percent: 0 };
}
if (f.available) {
coverageByCategory[f.category].available++;
if (f.used) coverageByCategory[f.category].used++;
}
}
for (const cat of Object.values(coverageByCategory)) {
cat.percent = cat.available > 0 ? Math.round((cat.used / cat.available) * 100) : 0;
}
const unusedFeatures = features.filter(f => f.available && !f.used).map(f => f.name);
const neverAvailable = features.filter(f => !f.available && !f.used).map(f => f.name);
let revenueStreamDiversity = 0;
if (ct.plus.config.isActive || ct.pro.config.isActive || ct.team.config.isActive) revenueStreamDiversity++;
if (at.payg.config.isActive || at.scale.config.isActive || at['enterprise-api'].config.isActive) revenueStreamDiversity++;
if (state.market.enterprise.activeContracts.length > 0) revenueStreamDiversity++;
if (state.market.codeAssistant.isActive) revenueStreamDiversity++;
if (state.market.agentsPlatform.isActive) revenueStreamDiversity++;
return { features, coverageByCategory, unusedFeatures, neverAvailable, revenueStreamDiversity };
}
@@ -0,0 +1,113 @@
import type { SimulationMetrics } from '../strategies/types';
export type TrackedMetric = 'revenue' | 'subscribers' | 'developers'
| 'bestModelCapability' | 'reputation' | 'totalFlops';
const TRACKED_METRICS: TrackedMetric[] = [
'revenue', 'subscribers', 'developers',
'bestModelCapability', 'reputation', 'totalFlops',
];
export interface StagnationAlert {
metric: TrackedMetric;
startTick: number;
endTick: number;
durationTicks: number;
stuckValue: number;
era: string;
}
export interface ExponentialAlert {
metric: TrackedMetric;
tick: number;
growthRate: number;
consecutiveSamples: number;
}
export interface GrowthRateResult {
stagnations: StagnationAlert[];
exponentialAlerts: ExponentialAlert[];
}
function getMetricValue(m: SimulationMetrics, metric: TrackedMetric): number {
return m[metric] as number;
}
export function analyzeGrowthRates(
metrics: SimulationMetrics[],
stagnationWindowSamples = 20,
stagnationThreshold = 0.01,
): GrowthRateResult {
const stagnations: StagnationAlert[] = [];
const exponentialAlerts: ExponentialAlert[] = [];
for (const metric of TRACKED_METRICS) {
const growthRates: number[] = [];
for (let i = 1; i < metrics.length; i++) {
const prev = getMetricValue(metrics[i - 1], metric);
const curr = getMetricValue(metrics[i], metric);
if (prev > 0) {
growthRates.push((curr - prev) / prev);
} else {
growthRates.push(curr > 0 ? 1 : 0);
}
}
let stagnationStart: number | null = null;
let flatCount = 0;
for (let i = 0; i < growthRates.length; i++) {
if (Math.abs(growthRates[i]) < stagnationThreshold) {
if (stagnationStart === null) stagnationStart = i;
flatCount++;
} else {
if (flatCount >= stagnationWindowSamples && stagnationStart !== null) {
const startIdx = stagnationStart + 1;
const endIdx = i + 1;
stagnations.push({
metric,
startTick: metrics[startIdx]?.tick ?? 0,
endTick: metrics[endIdx]?.tick ?? metrics[metrics.length - 1]?.tick ?? 0,
durationTicks: (metrics[endIdx]?.tick ?? 0) - (metrics[startIdx]?.tick ?? 0),
stuckValue: getMetricValue(metrics[startIdx], metric),
era: metrics[startIdx]?.era ?? 'unknown',
});
}
stagnationStart = null;
flatCount = 0;
}
}
if (flatCount >= stagnationWindowSamples && stagnationStart !== null) {
const startIdx = stagnationStart + 1;
stagnations.push({
metric,
startTick: metrics[startIdx]?.tick ?? 0,
endTick: metrics[metrics.length - 1]?.tick ?? 0,
durationTicks: (metrics[metrics.length - 1]?.tick ?? 0) - (metrics[startIdx]?.tick ?? 0),
stuckValue: getMetricValue(metrics[startIdx], metric),
era: metrics[startIdx]?.era ?? 'unknown',
});
}
let expCount = 0;
for (let i = 0; i < growthRates.length; i++) {
if (growthRates[i] > 0.10) {
expCount++;
if (expCount >= 5) {
exponentialAlerts.push({
metric,
tick: metrics[i + 1]?.tick ?? 0,
growthRate: growthRates[i],
consecutiveSamples: expCount,
});
}
} else {
expCount = 0;
}
}
}
return { stagnations, exponentialAlerts };
}
@@ -0,0 +1,83 @@
import type { GameState } from '@ai-tycoon/shared';
import type { SimulationMetrics } from '../strategies/types';
export function collectMetrics(state: GameState): SimulationMetrics {
const depts = state.talent.departments;
const headcount = depts.research.headcount + depts.engineering.headcount
+ depts.operations.headcount + depts.sales.headcount;
const activePipelines = state.models.activeTrainingPipelines.filter(
p => p.status === 'active' || p.status === 'stalled',
);
let bestPipelineProgress = 0;
for (const p of activePipelines) {
const stage = p.stages[p.currentStage as keyof typeof p.stages];
if (stage) {
const progress = stage.totalTicks > 0 ? stage.progressTicks / stage.totalTicks : 0;
if (progress > bestPipelineProgress) bestPipelineProgress = progress;
}
}
const ct = state.market.consumerTiers.tiers;
const subscriptionRevenue =
(ct.plus.userCount * ct.plus.config.price +
ct.pro.userCount * ct.pro.config.price +
ct.team.userCount * ct.team.config.price) / 86400;
let enterpriseRevenue = 0;
for (const contract of state.market.enterprise.activeContracts) {
enterpriseRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken;
}
const apiTokenRevenue = Math.max(0, state.economy.revenuePerTick - subscriptionRevenue - enterpriseRevenue);
const activeConsumerTiers = Object.values(ct).filter(t => t.config.isActive).length;
const activeApiTiers = Object.values(state.market.apiTiers.tiers).filter(t => t.config.isActive).length;
return {
tick: state.meta.tickCount,
era: state.meta.currentEra,
money: state.economy.money,
revenue: state.economy.revenuePerTick,
totalRevenue: state.economy.totalRevenue,
expensesPerTick: state.economy.expensesPerTick,
bestModelCapability: state.models.bestDeployedModelScore,
reputation: state.reputation.score,
subscribers: state.market.consumerTiers.totalUsers,
developers: state.market.apiTiers.totalDevelopers,
totalFlops: state.infrastructure.totalFlops,
totalTrainingFlops: state.infrastructure.totalTrainingFlops,
researchCount: state.research.completedResearch.length,
headcount,
modelsDeployed: state.models.baseModels.filter(m => m.isDeployed).length,
safetyRecord: state.reputation.safetyRecord,
publicPerception: state.reputation.publicPerception,
employeeSatisfaction: state.reputation.employeeSatisfaction,
regulatoryStanding: state.reputation.regulatoryStanding,
netCashFlow: state.economy.revenuePerTick - state.economy.expensesPerTick,
tokensPerSecondCapacity: state.compute.tokensPerSecondCapacity,
tokensPerSecondDemand: state.compute.tokensPerSecondDemand,
inferenceUtilization: state.compute.inferenceUtilization,
activeTrainingPipelines: activePipelines.length,
bestPipelineProgress,
subscriptionRevenue,
apiTokenRevenue,
enterpriseRevenue,
researchHeadcount: depts.research.headcount,
engineeringHeadcount: depts.engineering.headcount,
operationsHeadcount: depts.operations.headcount,
salesHeadcount: depts.sales.headcount,
completedResearchIds: [...state.research.completedResearch],
activeConsumerTiers,
activeApiTiers,
enterpriseContracts: state.market.enterprise.activeContracts.length,
fundingRoundsCompleted: state.economy.funding.completedRounds.length,
};
}
@@ -0,0 +1,44 @@
import type { SimulationMetrics } from '../strategies/types';
import type { TickNotification } from '@ai-tycoon/game-engine';
export interface Milestone {
name: string;
tick: number;
}
export function extractMilestones(
metrics: SimulationMetrics[],
notifications: TickNotification[],
): Milestone[] {
const milestones: Milestone[] = [];
const found = new Set<string>();
function add(name: string, tick: number) {
if (!found.has(name)) {
found.add(name);
milestones.push({ name, tick });
}
}
for (const m of metrics) {
if (m.bestModelCapability > 0) add('First model trained', m.tick);
if (m.revenue > 0) add('First revenue', m.tick);
if (m.money >= 1_000_000) add('$1M cash', m.tick);
if (m.subscribers >= 100) add('100 subscribers', m.tick);
if (m.subscribers >= 1000) add('1,000 subscribers', m.tick);
if (m.subscribers >= 10_000) add('10,000 subscribers', m.tick);
if (m.totalRevenue >= 1_000_000) add('$1M total revenue', m.tick);
if (m.totalRevenue >= 10_000_000) add('$10M total revenue', m.tick);
if (m.totalRevenue >= 100_000_000) add('$100M total revenue', m.tick);
if (m.developers >= 100) add('100 API developers', m.tick);
if (m.developers >= 1000) add('1,000 API developers', m.tick);
}
for (const n of notifications) {
if (n.title === 'Achievement Unlocked!' && n.message.includes('acqui')) {
add('First acquisition', 0);
}
}
return milestones;
}
@@ -0,0 +1,384 @@
import type { SimulationResult, SimulationConfig } from '../runner';
import { detectDeadZones, getCheapestSkuCost } from './deadZones';
import type { DeadZone } from './deadZones';
import { detectBreakpoints } from './breakpoints';
import type { RevenueBreakpoint } from './breakpoints';
import { extractMilestones } from './milestones';
import type { Milestone } from './milestones';
import { analyzeEraProximity } from './eraProximity';
import type { EraProximityResult } from './eraProximity';
import { analyzeCashFlow } from './cashFlow';
import type { CashFlowResult } from './cashFlow';
import { analyzeGrowthRates } from './growthRates';
import type { GrowthRateResult } from './growthRates';
import { runSanityChecks } from './sanityChecks';
import type { SanityCheckResult } from './sanityChecks';
import { analyzeFeatureUtilization } from './featureUtilization';
import type { FeatureUtilizationResult } from './featureUtilization';
import { analyzeSystemInterconnections } from './systemInterconnections';
import type { InterconnectionResult } from './systemInterconnections';
import type { SimulationMetrics } from '../strategies/types';
function formatDuration(ticks: number): string {
const totalMinutes = Math.floor(ticks / 60);
if (totalMinutes < 60) return `${totalMinutes} min`;
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
return mins > 0 ? `${hours} hr ${mins} min` : `${hours} hr`;
}
function formatEraName(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 pad(s: string, width: number): string {
return s.padEnd(width);
}
function fmtMoney(n: number): string {
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
return `$${n.toFixed(0)}`;
}
interface PerEraSummary {
era: string;
enteredAtTick: number;
exitedAtTick: number | null;
durationTicks: number;
bottleneckAtExit: string | null;
}
function buildPerEraSummary(result: SimulationResult, eraProximity: EraProximityResult): PerEraSummary[] {
const summaries: PerEraSummary[] = [];
const transitions = result.eraTransitions;
const eras: { era: string; enteredAt: number }[] = [{ era: 'startup', enteredAt: 0 }];
for (const t of transitions) {
eras.push({ era: t.to, enteredAt: t.tick });
}
for (let i = 0; i < eras.length; i++) {
const exitTick = i < eras.length - 1 ? eras[i + 1].enteredAt : null;
const duration = exitTick !== null ? exitTick - eras[i].enteredAt : (result.metrics[result.metrics.length - 1]?.tick ?? 0) - eras[i].enteredAt;
summaries.push({
era: eras[i].era,
enteredAtTick: eras[i].enteredAt,
exitedAtTick: exitTick,
durationTicks: duration,
bottleneckAtExit: eraProximity.perEraBottleneck[getNextEra(eras[i].era) ?? ''] ?? null,
});
}
return summaries;
}
function getNextEra(era: string): string | null {
const order = ['startup', 'scaleup', 'bigtech', 'agi'];
const idx = order.indexOf(era);
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null;
}
export function printConsoleReport(result: SimulationResult, config: SimulationConfig, verbose = false): void {
const { metrics, eraTransitions, notifications, finalState } = result;
const cheapestSku = getCheapestSkuCost(finalState);
const deadZones = detectDeadZones(metrics, cheapestSku);
const breakpoints = detectBreakpoints(metrics, notifications);
const milestones = extractMilestones(metrics, notifications);
const eraProximity = analyzeEraProximity(metrics);
const cashFlow = analyzeCashFlow(metrics);
const growthRates = analyzeGrowthRates(metrics);
const sanityChecks = runSanityChecks(metrics);
const featureUtil = analyzeFeatureUtilization(finalState);
const interconnections = analyzeSystemInterconnections(metrics, notifications);
const perEraSummary = buildPerEraSummary(result, eraProximity);
console.log('');
console.log('=== AI Tycoon Balance Simulation ===');
console.log(`Strategy: ${config.strategy.name} | Ticks: ${config.totalTicks.toLocaleString()} | Decision interval: ${config.decisionInterval}`);
console.log(`Wall time: ${(result.wallTimeMs / 1000).toFixed(1)}s${config.seed !== undefined ? ` | Seed: ${config.seed}` : ''}`);
console.log('');
// --- Era Transitions ---
console.log('Era Transitions:');
if (eraTransitions.length === 0) {
console.log(' (none — stuck in Startup)');
} else {
for (const t of eraTransitions) {
const label = `${formatEraName(t.from)} -> ${formatEraName(t.to)}`;
console.log(` ${pad(label, 24)} tick ${t.tick.toLocaleString().padStart(7)} (${formatDuration(t.tick)})`);
}
}
console.log('');
// --- Per-Era Summary ---
console.log('Per-Era Summary:');
for (const es of perEraSummary) {
const dur = formatDuration(es.durationTicks);
const bottleneck = es.bottleneckAtExit ? ` | bottleneck: ${es.bottleneckAtExit}` : '';
const status = es.exitedAtTick === null ? ' (current)' : '';
console.log(` ${pad(formatEraName(es.era), 12)} ${dur.padStart(10)}${bottleneck}${status}`);
}
console.log('');
// --- Key Milestones ---
console.log('Key Milestones:');
if (milestones.length === 0) {
console.log(' (none)');
} else {
for (const m of milestones) {
console.log(` ${pad(m.name, 24)} tick ${m.tick.toLocaleString().padStart(7)} (${formatDuration(m.tick)})`);
}
}
console.log('');
// --- Final State ---
console.log('Final State:');
const fm = metrics[metrics.length - 1];
if (fm) {
console.log(` Era: ${formatEraName(fm.era)} | Cash: ${fmtMoney(fm.money)}`);
console.log(` Revenue/tick: ${fmtMoney(fm.revenue)} | Total Revenue: ${fmtMoney(fm.totalRevenue)}`);
console.log(` Best Model: ${fm.bestModelCapability.toFixed(1)}/100 | Reputation: ${fm.reputation.toFixed(1)}`);
console.log(` Subscribers: ${fm.subscribers.toLocaleString()} | API Devs: ${fm.developers.toLocaleString()}`);
console.log(` Headcount: ${fm.headcount} | Research: ${fm.researchCount} completed`);
console.log(` Total FLOPS: ${fm.totalFlops.toLocaleString()} | Models Deployed: ${fm.modelsDeployed}`);
}
console.log('');
// --- Reputation Breakdown ---
if (fm) {
console.log('Reputation Breakdown:');
const comps = [
{ name: 'Safety Record', value: fm.safetyRecord, weight: 0.3 },
{ name: 'Public Perception', value: fm.publicPerception, weight: 0.3 },
{ name: 'Employee Satisfaction', value: fm.employeeSatisfaction, weight: 0.2 },
{ name: 'Regulatory Standing', value: fm.regulatoryStanding, weight: 0.2 },
];
const lowest = comps.reduce((a, b) => a.value < b.value ? a : b);
for (const c of comps) {
const marker = c === lowest ? ' <-- lowest' : '';
console.log(` ${pad(c.name, 24)} ${c.value.toFixed(1).padStart(6)} (x${c.weight})${marker}`);
}
console.log(` ${pad('Weighted Score', 24)} ${fm.reputation.toFixed(1).padStart(6)}`);
console.log('');
}
// --- Cash Flow Summary ---
console.log('Cash Flow:');
console.log(` Peak cash: ${fmtMoney(cashFlow.peakCash.amount)} at tick ${cashFlow.peakCash.tick.toLocaleString()}`);
console.log(` Min cash: ${fmtMoney(cashFlow.minCash.amount)} at tick ${cashFlow.minCash.tick.toLocaleString()}`);
if (cashFlow.negativePeriods.length > 0) {
console.log(` Negative flow periods: ${cashFlow.negativePeriods.length}`);
for (const np of cashFlow.negativePeriods.slice(0, 3)) {
console.log(` ticks ${np.startTick.toLocaleString()}-${np.endTick.toLocaleString()} (${formatDuration(np.durationTicks)}) avg burn: ${fmtMoney(np.averageBurnRate)}/tick`);
}
if (cashFlow.negativePeriods.length > 3) console.log(` ... and ${cashFlow.negativePeriods.length - 3} more`);
}
if (cashFlow.bankruptcyRisks.length > 0) {
console.log(` [!] Bankruptcy risks: ${cashFlow.bankruptcyRisks.length}`);
for (const br of cashFlow.bankruptcyRisks.slice(0, 3)) {
console.log(` tick ${br.tick.toLocaleString()}: ${br.ticksToZero} ticks to zero, ${br.hasRevenueStream ? 'has revenue' : 'NO revenue'}`);
}
}
console.log('');
// --- Feature Utilization ---
console.log('Feature Utilization:');
for (const [cat, stats] of Object.entries(featureUtil.coverageByCategory)) {
const bar = '#'.repeat(Math.round(stats.percent / 5)) + '-'.repeat(20 - Math.round(stats.percent / 5));
console.log(` ${pad(cat, 16)} [${bar}] ${stats.used}/${stats.available} (${stats.percent}%)`);
}
console.log(` Revenue streams: ${featureUtil.revenueStreamDiversity} active`);
if (featureUtil.unusedFeatures.length > 0) {
console.log(` Unused but available (${featureUtil.unusedFeatures.length}):`);
for (const f of featureUtil.unusedFeatures.slice(0, 10)) {
console.log(` - ${f}`);
}
if (featureUtil.unusedFeatures.length > 10) console.log(` ... and ${featureUtil.unusedFeatures.length - 10} more`);
}
console.log('');
// --- System Interconnections ---
console.log(`System Interconnections (overall: ${interconnections.overallScore.toFixed(1)}/10):`);
for (const c of interconnections.connections) {
const bar = '#'.repeat(c.score) + '-'.repeat(10 - c.score);
console.log(` ${pad(`${c.from} -> ${c.to}`, 30)} [${bar}] ${c.score}/10 (${c.events} events)`);
}
if (interconnections.deadLinks.length > 0) {
console.log(` [!] Dead links (no observed effect):`);
for (const d of interconnections.deadLinks) {
console.log(` ${d.from} -> ${d.to}: ${d.evidence}`);
}
}
console.log('');
// --- Balance Warnings ---
const hasWarnings = deadZones.length > 0 || breakpoints.length > 0 || !sanityChecks.passed
|| eraProximity.ceilings.length > 0 || growthRates.stagnations.length > 0 || cashFlow.bankruptcyRisks.length > 0;
if (hasWarnings) {
console.log('Diagnostics:');
for (const dz of deadZones) {
console.log(` [!] Dead zone: ticks ${dz.startTick.toLocaleString()}-${dz.endTick.toLocaleString()} (${formatDuration(dz.durationTicks)}) -- ${dz.description}`);
}
for (const bp of breakpoints) {
console.log(` [!] Breakpoint: tick ${bp.tick.toLocaleString()} -- revenue jumped ${bp.percentIncrease.toFixed(0)}% after "${bp.possibleCause}"`);
}
for (const c of eraProximity.ceilings) {
console.log(` [!] Ceiling: ${c.metric} stuck at ${c.stuckAtValue.toFixed(1)} for ${formatDuration(c.stuckDurationTicks)} (${c.era} needs ${c.requiredValue})`);
}
for (const mc of eraProximity.mathCeilings) {
console.log(` [!] Math ceiling: theoretical max reputation = ${mc.theoreticalMax}, ${mc.era} needs ${mc.required}`);
}
for (const s of growthRates.stagnations) {
console.log(` [!] Stagnation: ${s.metric} flat at ${s.stuckValue.toFixed(1)} for ${formatDuration(s.durationTicks)} (${formatEraName(s.era)})`);
}
for (const v of sanityChecks.violations) {
console.log(` [${v.severity === 'error' ? '!!' : '!'}] ${v.check}: ${v.message} (tick ${v.tick.toLocaleString()})`);
}
console.log('');
}
if (verbose) {
if (eraProximity.snapshots.length > 0) {
console.log('Era Proximity Timeline:');
for (const s of eraProximity.snapshots.filter((_, i) => i % 10 === 0)) {
const parts = s.thresholds.map(t => `${t.metric}: ${t.percentComplete.toFixed(0)}%`).join(', ');
console.log(` tick ${s.tick.toLocaleString().padStart(7)} -> ${formatEraName(s.targetEra)}: ${parts} | bottleneck: ${s.bottleneck}`);
}
console.log('');
}
if (growthRates.exponentialAlerts.length > 0) {
console.log('Exponential Growth Alerts:');
for (const ea of growthRates.exponentialAlerts) {
console.log(` ${ea.metric} at tick ${ea.tick.toLocaleString()}: ${(ea.growthRate * 100).toFixed(1)}% per interval (${ea.consecutiveSamples} consecutive)`);
}
console.log('');
}
}
}
export interface BalanceReport {
strategy: string;
totalTicks: number;
seed: number | null;
wallTimeMs: number;
eraTransitions: Array<{ from: string; to: string; tick: number; wallTime: string }>;
milestones: Array<{ name: string; tick: number; wallTime: string }>;
deadZones: DeadZone[];
breakpoints: RevenueBreakpoint[];
finalMetrics: SimulationMetrics | null;
passed: boolean;
failureReasons: string[];
eraProximity: EraProximityResult;
cashFlow: CashFlowResult;
growthRates: GrowthRateResult;
sanityChecks: SanityCheckResult;
featureUtilization: FeatureUtilizationResult;
systemInterconnections: InterconnectionResult;
perEraSummary: PerEraSummary[];
}
const ERA_PACING: Record<string, { max: number }> = {
startup: { max: 5_000 },
scaleup: { max: 12_000 },
bigtech: { max: 18_000 },
};
export function generateJsonReport(result: SimulationResult, config: SimulationConfig): BalanceReport {
const { metrics, eraTransitions, notifications, finalState } = result;
const cheapestSku = getCheapestSkuCost(finalState);
const deadZones = detectDeadZones(metrics, cheapestSku);
const breakpoints = detectBreakpoints(metrics, notifications);
const milestones = extractMilestones(metrics, notifications);
const eraProximity = analyzeEraProximity(metrics);
const cashFlow = analyzeCashFlow(metrics);
const growthRates = analyzeGrowthRates(metrics);
const sanityChecks = runSanityChecks(metrics);
const featureUtil = analyzeFeatureUtilization(finalState);
const interconnections = analyzeSystemInterconnections(metrics, notifications);
const perEraSummary = buildPerEraSummary(result, eraProximity);
const failures: string[] = [];
const reachedAgi = eraTransitions.some(t => t.to === 'agi');
if (!reachedAgi) {
failures.push(`AGI era not reached within ${config.totalTicks} ticks`);
}
const longDeadZones = deadZones.filter(dz => dz.durationTicks > 1800);
for (const dz of longDeadZones) {
failures.push(`Dead zone of ${dz.durationTicks} ticks (${formatDuration(dz.durationTicks)}) at tick ${dz.startTick}`);
}
const extremeBreakpoints = breakpoints.filter(bp => bp.percentIncrease > 500);
for (const bp of extremeBreakpoints) {
failures.push(`Revenue breakpoint of ${bp.percentIncrease.toFixed(0)}% at tick ${bp.tick}`);
}
// Era pacing checks
let prevTick = 0;
for (const t of eraTransitions) {
const duration = t.tick - prevTick;
const bounds = ERA_PACING[t.from];
if (bounds && duration > bounds.max) {
failures.push(`${formatEraName(t.from)} era too long: ${duration} ticks (max ${bounds.max})`);
}
prevTick = t.tick;
}
// Sanity check errors
if (!sanityChecks.passed) {
const errors = sanityChecks.violations.filter(v => v.severity === 'error');
for (const e of errors) {
failures.push(`Sanity: ${e.check} at tick ${e.tick}: ${e.message}`);
}
}
// Bankruptcy risk
for (const risk of cashFlow.bankruptcyRisks) {
if (!risk.hasRevenueStream) {
failures.push(`Bankruptcy risk at tick ${risk.tick}: ${risk.ticksToZero} ticks to zero, no revenue`);
}
}
// Ceiling detection — only fail if the era was never actually reached
const reachedEras = new Set(eraTransitions.map(t => t.to));
for (const ceiling of eraProximity.ceilings) {
if (!reachedEras.has(ceiling.era)) {
failures.push(`${ceiling.metric} ceiling for ${ceiling.era}: stuck at ${ceiling.stuckAtValue.toFixed(1)} since tick ${ceiling.stuckSinceTick} (need ${ceiling.requiredValue})`);
}
}
return {
strategy: config.strategy.name,
totalTicks: config.totalTicks,
seed: config.seed ?? null,
wallTimeMs: result.wallTimeMs,
eraTransitions: eraTransitions.map(t => ({
from: t.from, to: t.to, tick: t.tick, wallTime: formatDuration(t.tick),
})),
milestones: milestones.map(m => ({
name: m.name, tick: m.tick, wallTime: formatDuration(m.tick),
})),
deadZones,
breakpoints,
finalMetrics: metrics.length > 0 ? metrics[metrics.length - 1] : null,
passed: failures.length === 0,
failureReasons: failures,
eraProximity,
cashFlow,
growthRates,
sanityChecks,
featureUtilization: featureUtil,
systemInterconnections: interconnections,
perEraSummary,
};
}
@@ -0,0 +1,131 @@
import type { SimulationMetrics } from '../strategies/types';
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
export type SanityCheckSeverity = 'error' | 'warning';
export interface SanityCheckViolation {
tick: number;
check: string;
message: string;
severity: SanityCheckSeverity;
actual: number;
expected: string;
}
export interface SanityCheckResult {
violations: SanityCheckViolation[];
passed: boolean;
}
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'];
function nextEra(current: string): string | null {
const idx = ERA_ORDER.indexOf(current);
return idx >= 0 && idx < ERA_ORDER.length - 1 ? ERA_ORDER[idx + 1] : null;
}
export function runSanityChecks(metrics: SimulationMetrics[]): SanityCheckResult {
const violations: SanityCheckViolation[] = [];
const seen = new Set<string>();
for (const m of metrics) {
for (const [name, value] of [
['safetyRecord', m.safetyRecord],
['publicPerception', m.publicPerception],
['employeeSatisfaction', m.employeeSatisfaction],
['regulatoryStanding', m.regulatoryStanding],
] as [string, number][]) {
if (value < 0 || value > 100) {
const key = `reputation-range:${name}`;
if (!seen.has(key)) {
seen.add(key);
violations.push({
tick: m.tick, check: 'reputation-range',
message: `${name} = ${value.toFixed(2)}, expected 0-100`,
severity: 'error', actual: value, expected: '0-100',
});
}
}
}
const components = [m.safetyRecord, m.publicPerception, m.employeeSatisfaction, m.regulatoryStanding];
const aboveThreshold = components.filter(c => c > 10);
const belowThreshold = components.filter(c => c < 1.0 && c > 0);
if (aboveThreshold.length >= 2 && belowThreshold.length >= 1) {
const key = 'reputation-scale-consistency';
if (!seen.has(key)) {
seen.add(key);
const lowName = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding']
[components.indexOf(belowThreshold[0])];
violations.push({
tick: m.tick, check: key,
message: `${lowName} = ${belowThreshold[0].toFixed(2)} while others are 10+. Likely a scale mismatch (0-1 vs 0-100)`,
severity: 'error', actual: belowThreshold[0], expected: '0-100 scale',
});
}
}
const computed = Math.round(
m.safetyRecord * 0.3 + m.publicPerception * 0.3 +
m.employeeSatisfaction * 0.2 + m.regulatoryStanding * 0.2,
);
if (Math.abs(computed - m.reputation) > 2) {
const key = `reputation-formula:${m.tick}`;
if (!seen.has(key)) {
seen.add(key);
violations.push({
tick: m.tick, check: 'reputation-formula-valid',
message: `Computed reputation ${computed} != reported ${m.reputation.toFixed(1)} (components: SR=${m.safetyRecord.toFixed(1)}, PP=${m.publicPerception.toFixed(1)}, ES=${m.employeeSatisfaction.toFixed(1)}, RS=${m.regulatoryStanding.toFixed(1)})`,
severity: 'error', actual: m.reputation, expected: `~${computed}`,
});
}
}
if (m.money < 0 && !seen.has('money-non-negative')) {
seen.add('money-non-negative');
violations.push({
tick: m.tick, check: 'money-non-negative',
message: `Cash is negative: $${m.money.toFixed(0)}`,
severity: 'warning', actual: m.money, expected: '>= 0',
});
}
if ((m.bestModelCapability < 0 || m.bestModelCapability > 100) && !seen.has('capability-range')) {
seen.add('capability-range');
violations.push({
tick: m.tick, check: 'capability-range',
message: `Model capability ${m.bestModelCapability} outside 0-100`,
severity: 'error', actual: m.bestModelCapability, expected: '0-100',
});
}
}
const last = metrics[metrics.length - 1];
if (last) {
const target = nextEra(last.era);
if (target) {
const thresholds = ERA_THRESHOLDS[target as keyof typeof ERA_THRESHOLDS];
if (thresholds) {
const maxSafety = 80;
const maxPublic = 100;
const maxEmployee = 100;
const maxRegulatory = 100;
const theoreticalMax = Math.round(
maxSafety * 0.3 + maxPublic * 0.3 + maxEmployee * 0.2 + maxRegulatory * 0.2,
);
if (theoreticalMax < thresholds.reputation) {
violations.push({
tick: last.tick, check: 'mathematical-ceiling',
message: `Theoretical max reputation is ${theoreticalMax} (SR_max=80×0.3 + PP_max=100×0.3 + ES_max=100×0.2 + RS_max=100×0.2) but ${target} era requires ${thresholds.reputation}`,
severity: 'warning', actual: theoreticalMax, expected: `>= ${thresholds.reputation}`,
});
}
}
}
}
return {
violations,
passed: violations.every(v => v.severity !== 'error'),
};
}
@@ -0,0 +1,357 @@
import type { SimulationMetrics } from '../strategies/types';
import type { TickNotification } from '@ai-tycoon/game-engine';
export interface SystemConnection {
from: string;
to: string;
score: number;
evidence: string;
events: number;
}
export interface InterconnectionResult {
connections: SystemConnection[];
overallScore: number;
weakLinks: SystemConnection[];
deadLinks: SystemConnection[];
}
function findMetricAtTick(metrics: SimulationMetrics[], tick: number): SimulationMetrics | undefined {
for (let i = metrics.length - 1; i >= 0; i--) {
if (metrics[i].tick <= tick) return metrics[i];
}
return metrics[0];
}
function findMetricAfterTick(metrics: SimulationMetrics[], tick: number, windowTicks: number): SimulationMetrics | undefined {
const target = tick + windowTicks;
for (const m of metrics) {
if (m.tick >= target) return m;
}
return undefined;
}
function measureDelta(
metrics: SimulationMetrics[],
eventTicks: number[],
getter: (m: SimulationMetrics) => number,
windowTicks = 300,
): { totalDelta: number; events: number } {
let totalDelta = 0;
let events = 0;
for (const tick of eventTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, windowTicks);
if (before && after) {
totalDelta += after[getter.name as keyof SimulationMetrics] !== undefined
? getter(after) - getter(before)
: 0;
events++;
}
}
return { totalDelta, events };
}
function scoreFromDelta(totalDelta: number, events: number, scale: number): number {
if (events === 0) return 0;
const avgDelta = totalDelta / events;
return Math.min(10, Math.max(0, Math.round((avgDelta / scale) * 10)));
}
function detectResearchCompletionTicks(metrics: SimulationMetrics[]): number[] {
const ticks: number[] = [];
for (let i = 1; i < metrics.length; i++) {
if (metrics[i].researchCount > metrics[i - 1].researchCount) {
ticks.push(metrics[i].tick);
}
}
return ticks;
}
function detectModelDeploymentTicks(metrics: SimulationMetrics[]): number[] {
const ticks: number[] = [];
for (let i = 1; i < metrics.length; i++) {
if (metrics[i].modelsDeployed > metrics[i - 1].modelsDeployed) {
ticks.push(metrics[i].tick);
}
}
return ticks;
}
function detectFundingTicks(metrics: SimulationMetrics[]): number[] {
const ticks: number[] = [];
for (let i = 1; i < metrics.length; i++) {
if (metrics[i].fundingRoundsCompleted > metrics[i - 1].fundingRoundsCompleted) {
ticks.push(metrics[i].tick);
}
}
return ticks;
}
function detectHiringSpikes(metrics: SimulationMetrics[]): number[] {
const ticks: number[] = [];
for (let i = 1; i < metrics.length; i++) {
if (metrics[i].headcount - metrics[i - 1].headcount >= 3) {
ticks.push(metrics[i].tick);
}
}
return ticks;
}
function detectComputeGrowthTicks(metrics: SimulationMetrics[]): number[] {
const ticks: number[] = [];
for (let i = 1; i < metrics.length; i++) {
const prev = metrics[i - 1].totalFlops;
const curr = metrics[i].totalFlops;
if (prev > 0 && (curr - prev) / prev > 0.1) {
ticks.push(metrics[i].tick);
}
}
return ticks;
}
export function analyzeSystemInterconnections(
metrics: SimulationMetrics[],
_notifications: TickNotification[],
): InterconnectionResult {
const connections: SystemConnection[] = [];
if (metrics.length < 10) {
return { connections: [], overallScore: 0, weakLinks: [], deadLinks: [] };
}
const researchTicks = detectResearchCompletionTicks(metrics);
const deployTicks = detectModelDeploymentTicks(metrics);
const fundingTicks = detectFundingTicks(metrics);
const hiringTicks = detectHiringSpikes(metrics);
const computeGrowthTicks = detectComputeGrowthTicks(metrics);
// Research → Capability
{
let totalDelta = 0;
let events = 0;
for (const tick of researchTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 600);
if (before && after) {
totalDelta += after.bestModelCapability - before.bestModelCapability;
events++;
}
}
const score = events > 0 ? scoreFromDelta(totalDelta, events, 5) : 0;
connections.push({
from: 'Research', to: 'Model Capability', score, events,
evidence: events > 0
? `${events} research completions, avg capability delta: ${(totalDelta / events).toFixed(1)}`
: 'No research completions observed',
});
}
// Research → Infrastructure
{
let totalDelta = 0;
let events = 0;
for (const tick of researchTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 600);
if (before && after) {
totalDelta += after.totalFlops - before.totalFlops;
events++;
}
}
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events / 100) * 10)) : 0;
connections.push({
from: 'Research', to: 'Infrastructure', score, events,
evidence: events > 0
? `${events} research completions, avg FLOPS delta: ${(totalDelta / events).toFixed(0)}`
: 'No research completions observed',
});
}
// Talent → Training (hiring → more training pipelines or faster progress)
{
let totalDelta = 0;
let events = 0;
for (const tick of hiringTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 300);
if (before && after) {
totalDelta += after.bestModelCapability - before.bestModelCapability;
events++;
}
}
const score = events > 0 ? scoreFromDelta(totalDelta, events, 3) : 0;
connections.push({
from: 'Talent', to: 'Training', score, events,
evidence: events > 0
? `${events} hiring spikes, avg capability change: ${(totalDelta / events).toFixed(1)}`
: 'No significant hiring events observed',
});
}
// Talent → Enterprise
{
let totalDelta = 0;
let events = 0;
for (const tick of hiringTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 600);
if (before && after) {
totalDelta += after.enterpriseContracts - before.enterpriseContracts;
events++;
}
}
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events) * 5)) : 0;
connections.push({
from: 'Talent', to: 'Enterprise', score, events,
evidence: events > 0
? `${events} hiring spikes, avg enterprise contract delta: ${(totalDelta / events).toFixed(1)}`
: 'No hiring events observed',
});
}
// Infrastructure → Revenue
{
let totalDelta = 0;
let events = 0;
for (const tick of computeGrowthTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 300);
if (before && after && before.revenue > 0) {
totalDelta += (after.revenue - before.revenue) / before.revenue;
events++;
}
}
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 20))) : 0;
connections.push({
from: 'Infrastructure', to: 'Revenue', score, events,
evidence: events > 0
? `${events} compute growth events, avg revenue growth: ${((totalDelta / events) * 100).toFixed(1)}%`
: 'No compute growth events observed',
});
}
// Models → Revenue
{
let totalDelta = 0;
let events = 0;
for (const tick of deployTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 300);
if (before && after) {
const revDelta = before.revenue > 0
? (after.revenue - before.revenue) / before.revenue
: (after.revenue > 0 ? 1 : 0);
totalDelta += revDelta;
events++;
}
}
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 10))) : 0;
connections.push({
from: 'Models', to: 'Revenue', score, events,
evidence: events > 0
? `${events} model deployments, avg revenue change: ${((totalDelta / events) * 100).toFixed(1)}%`
: 'No model deployments observed',
});
}
// Models → Enterprise
{
let totalDelta = 0;
let events = 0;
for (const tick of deployTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 600);
if (before && after) {
totalDelta += after.enterpriseContracts - before.enterpriseContracts;
events++;
}
}
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events) * 5)) : 0;
connections.push({
from: 'Models', to: 'Enterprise', score, events,
evidence: events > 0
? `${events} deployments, avg enterprise contract delta: ${(totalDelta / events).toFixed(1)}`
: 'No model deployments observed',
});
}
// Reputation → Era gates
{
let score = 0;
const eraChanges = [];
for (let i = 1; i < metrics.length; i++) {
if (metrics[i].era !== metrics[i - 1].era) {
eraChanges.push({ tick: metrics[i].tick, repBefore: metrics[i - 1].reputation });
}
}
if (eraChanges.length > 0) {
let bindingCount = 0;
for (const ec of eraChanges) {
const before = findMetricAtTick(metrics, ec.tick - 120);
if (before && (ec.repBefore - before.reputation) > 1) bindingCount++;
}
score = Math.min(10, Math.round((bindingCount / eraChanges.length) * 10));
}
connections.push({
from: 'Reputation', to: 'Era Gates', score, events: eraChanges.length,
evidence: eraChanges.length > 0
? `${eraChanges.length} era transitions observed`
: 'No era transitions observed',
});
}
// Funding → Growth
{
let totalDelta = 0;
let events = 0;
for (const tick of fundingTicks) {
const before = findMetricAtTick(metrics, tick);
const after = findMetricAfterTick(metrics, tick, 600);
if (before && after) {
const growthBefore = before.revenue;
const growthAfter = after.revenue;
totalDelta += growthBefore > 0
? (growthAfter - growthBefore) / growthBefore
: (growthAfter > 0 ? 1 : 0);
events++;
}
}
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 10))) : 0;
connections.push({
from: 'Funding', to: 'Growth', score, events,
evidence: events > 0
? `${events} funding rounds, avg revenue growth: ${((totalDelta / events) * 100).toFixed(1)}%`
: 'No funding rounds observed',
});
}
// Compute → Serving quality (utilization tracking)
{
let wellUtilizedCount = 0;
let totalSamples = 0;
for (const m of metrics) {
if (m.tokensPerSecondCapacity > 0) {
totalSamples++;
const util = m.inferenceUtilization;
if (util > 0.2 && util < 0.95) wellUtilizedCount++;
}
}
const score = totalSamples > 0 ? Math.min(10, Math.round((wellUtilizedCount / totalSamples) * 10)) : 0;
connections.push({
from: 'Compute', to: 'Serving', score, events: totalSamples,
evidence: totalSamples > 0
? `${wellUtilizedCount}/${totalSamples} samples with healthy utilization (20-95%)`
: 'No compute capacity observed',
});
}
const overallScore = connections.length > 0
? Math.round(connections.reduce((sum, c) => sum + c.score, 0) / connections.length * 10) / 10
: 0;
const weakLinks = connections.filter(c => c.score > 0 && c.score < 3);
const deadLinks = connections.filter(c => c.score === 0);
return { connections, overallScore, weakLinks, deadLinks };
}