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
+1 -1
View File
@@ -2,7 +2,7 @@ export { GameEngine } from './engine';
export { processTick, setAchievementDefinitions } from './tick';
export type { TickNotification } from './tick';
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
export { getResearchBonuses } from './systems/researchBonuses';
export { getResearchBonuses, resetResearchBonusCache } from './systems/researchBonuses';
export type { ResearchBonuses } from './systems/researchBonuses';
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
export { onModelDeployed } from './systems/market/obsolescenceSystem';
@@ -38,17 +38,18 @@ export function processCompetitors(state: GameState): CompetitorState {
const updated = { ...rival };
// Freshness decay each tick
updated.modelFreshness = Math.max(0, updated.modelFreshness - FRESHNESS_DECAY_RATE);
// Developer ecosystem growth based on personality
const ecoGrowth = rival.personality.openSourceTendency * 0.1 + rival.personality.marketingFocus * 0.05;
updated.developerEcosystemScore = Math.min(100,
updated.developerEcosystemScore + ecoGrowth * 0.01,
);
// Catch-up: if any market share < threshold, cut prices
const minShare = Math.min(...Object.values(updated.marketShares));
const shares = Object.values(updated.marketShares);
let minShare = shares[0];
for (let i = 1; i < shares.length; i++) {
if (shares[i] < minShare) minShare = shares[i];
}
if (minShare < COMPETITOR_CATCHUP_SHARE_THRESHOLD) {
updated.pricingStrategy = {
...updated.pricingStrategy,
@@ -61,7 +62,6 @@ export function processCompetitors(state: GameState): CompetitorState {
return updated;
}
// Milestone reached — capability jump + model release
const { personality } = rival;
const capGrowth = (2 + personality.researchFocus * 5 + personality.riskTolerance * 3) *
(1 + tick * 0.00005);
@@ -84,7 +84,6 @@ export function processCompetitors(state: GameState): CompetitorState {
const modelIdx = Math.floor(updated.estimatedCapability / 10);
updated.latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
// Model release resets freshness
updated.modelFreshness = 1.0;
updated.lastModelReleaseTick = tick;
@@ -96,11 +95,12 @@ export function processCompetitors(state: GameState): CompetitorState {
return updated;
});
const allCaps = [
...rivals.filter(r => r.status === 'active').map(r => r.estimatedCapability),
state.models.bestDeployedModelScore,
];
const industryBenchmark = allCaps.length > 0 ? Math.max(...allCaps) : 0;
let industryBenchmark = state.models.bestDeployedModelScore;
for (const r of rivals) {
if (r.status === 'active' && r.estimatedCapability > industryBenchmark) {
industryBenchmark = r.estimatedCapability;
}
}
return { rivals, industryBenchmark };
}
@@ -358,22 +358,19 @@ function processNetworkTick(
repairSpeedBonus: number,
hotStandbyTicks: number,
redundancyBonus: number,
): { switchRepairCosts: number; notifications: TickNotification[]; dirty: boolean } {
): { switchRepairCosts: number; notifications: TickNotification[]; dirtyDCs: Set<string> } {
const notifications: TickNotification[] = [];
let switchRepairCosts = 0;
let dirty = false;
const dirtyDCs = new Set<string>();
const healthyByTier: Partial<Record<SwitchTier, NetworkSwitch[]>> = {};
const repairing: NetworkSwitch[] = [];
const failed: NetworkSwitch[] = [];
for (const sw of Object.values(registry)) {
if (sw.status === 'healthy') {
(healthyByTier[sw.tier] ??= []).push(sw);
} else if (sw.status === 'repairing') {
repairing.push(sw);
} else if (sw.status === 'failed') {
failed.push(sw);
}
}
@@ -397,9 +394,9 @@ function processNetworkTick(
sw.repairProgress = 0;
sw.repairTotal = repairTime;
newlyFailed.push(sw);
if (sw.dcId) dirtyDCs.add(sw.dcId);
switchRepairCosts += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION;
}
dirty = true;
}
}
@@ -409,13 +406,14 @@ function processNetworkTick(
sw.status = 'healthy';
sw.repairProgress = 0;
sw.repairTotal = 0;
dirty = true;
if (sw.dcId) dirtyDCs.add(sw.dcId);
}
}
if (dirty) {
if (dirtyDCs.size > 0) {
for (const sw of Object.values(registry)) {
if (sw.uplinkIds.length === 0) continue;
if (sw.dcId && !dirtyDCs.has(sw.dcId)) continue;
let active = 0;
for (const upId of sw.uplinkIds) {
if (registry[upId]?.status === 'healthy') active++;
@@ -435,7 +433,7 @@ function processNetworkTick(
}
}
return { switchRepairCosts, notifications, dirty };
return { switchRepairCosts, notifications, dirtyDCs };
}
// --- Interconnect Training Multiplier ---
@@ -478,16 +476,13 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0;
const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0;
// Clone switch registry for mutable operations this tick
const registry: Record<string, NetworkSwitch> = {};
for (const [id, sw] of Object.entries(state.infrastructure.switchRegistry)) {
registry[id] = { ...sw, uplinkIds: [...sw.uplinkIds], downlinkIds: [...sw.downlinkIds] };
}
// Mutate registry in-place — infrastructure returns a new state anyway
const registry = state.infrastructure.switchRegistry;
// Process network failures/repairs globally
const netResult = processNetworkTick(registry, networkResearchBonus, opsEff, repairSpeedBonus, hotStandbyTicks, redundancyBonus);
repairCosts += netResult.switchRepairCosts;
notifications.push(...netResult.notifications);
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
let totalFlops = 0;
let totalTrainingFlops = 0;
@@ -671,8 +666,8 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
repairCosts += dcRepairCosts;
// Recompute DC network summary after failures/repairs
if (netResult.dirty && networkSummary.switchIds.length > 0) {
// Recompute DC network summary after failures/repairs (only if this DC's switches changed)
if (netResult.dirtyDCs.has(dc.id) && networkSummary.switchIds.length > 0) {
networkSummary = buildDCSummary(
networkSummary.switchIds, networkSummary.networkRackCount, registry,
);
@@ -70,7 +70,7 @@ export function processEnterprisePipeline(
const activeContracts = [...ent.activeContracts];
const effectiveSales = salesHeadcount > 0
? Math.min(1, salesHeadcount * salesEffectiveness / Math.max(1, pipeline.length))
? Math.min(2, salesHeadcount * salesEffectiveness * 0.2)
: 0;
// --- Lead generation ---
@@ -129,7 +129,8 @@ export function processEnterprisePipeline(
let transitionProb = baseRate * effectiveSales;
if (lead.stage === 'qualification') {
transitionProb *= modelCapability >= lead.requiredCapability ? 1 : 0.1;
const capRatio = Math.min(2, modelCapability / Math.max(1, lead.requiredCapability));
transitionProb *= capRatio > 1 ? capRatio : capRatio * 0.3;
} else if (lead.stage === 'poc') {
const entDemand = enterpriseServingMetrics.demandTokens;
const entRejected = enterpriseServingMetrics.rejectedTokens;
@@ -68,13 +68,18 @@ function buildModelFleet(
): ModelServingSlot[] {
const slots: ModelServingSlot[] = [];
const deployedBases = modelsState.baseModels.filter(m => m.isDeployed);
const deployedVariants: { variant: ModelVariant; baseModel: BaseModel }[] = [];
const deployedBases: BaseModel[] = [];
const baseModelById = new Map<string, BaseModel>();
for (const m of modelsState.baseModels) {
if (m.isDeployed) deployedBases.push(m);
baseModelById.set(m.id, m);
}
const deployedVariants: { variant: ModelVariant; baseModel: BaseModel }[] = [];
for (const family of modelsState.families) {
for (const variant of family.variants) {
if (!variant.isDeployed) continue;
const base = modelsState.baseModels.find(m => m.id === variant.baseModelId);
const base = baseModelById.get(variant.baseModelId);
if (base) deployedVariants.push({ variant, baseModel: base });
}
}
@@ -173,7 +178,9 @@ function serveFromFleet(
let degraded = 0;
let qualityWeightedSum = 0;
const bestQuality = fleet.length > 0 ? Math.max(...fleet.map(s => s.qualityScore)) : 1;
let bestQuality = 0;
for (const s of fleet) { if (s.qualityScore > bestQuality) bestQuality = s.qualityScore; }
if (bestQuality === 0) bestQuality = 1;
const degradationActive = policy.autoDegradation.enabled && overallUtilization > policy.autoDegradation.triggerThreshold;
for (const slot of fleet) {
@@ -92,13 +92,6 @@ function computeAttractiveness(
return Math.max(0.01, score);
}
function softmaxShares(scores: number[]): number[] {
const maxScore = Math.max(...scores);
const exps = scores.map(s => Math.exp((s - maxScore) * SHARE_TEMPERATURE));
const sumExp = exps.reduce((a, b) => a + b, 0);
return exps.map(e => e / sumExp);
}
export function computeMarketShares(
tam: TotalAddressableMarket,
participants: ParticipantProfile[],
@@ -106,30 +99,48 @@ export function computeMarketShares(
): TotalAddressableMarket {
const segments = { ...tam.segments };
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
const n = participants.length;
const scores = new Array<number>(n);
const targetShares = new Array<number>(n);
for (const segId of segmentIds) {
const seg = segments[segId];
const scores = participants.map(p => computeAttractiveness(p, segId, qualityBaseline));
const targetShares = softmaxShares(scores);
for (let i = 0; i < n; i++) {
scores[i] = computeAttractiveness(participants[i], segId, qualityBaseline);
}
// Inline softmax
let maxScore = scores[0];
for (let i = 1; i < n; i++) { if (scores[i] > maxScore) maxScore = scores[i]; }
let sumExp = 0;
for (let i = 0; i < n; i++) {
targetShares[i] = Math.exp((scores[i] - maxScore) * SHARE_TEMPERATURE);
sumExp += targetShares[i];
}
for (let i = 0; i < n; i++) { targetShares[i] /= sumExp; }
const oldShareMap = new Map<string, MarketShareEntry>();
for (const entry of seg.shares) {
oldShareMap.set(entry.playerId, entry);
}
const newShares: MarketShareEntry[] = participants.map((p, i) => {
const newShares: MarketShareEntry[] = new Array(n);
let totalShare = 0;
for (let i = 0; i < n; i++) {
const p = participants[i];
const old = oldShareMap.get(p.id);
const oldShare = old?.sharePercent ?? 0;
const migratedShare = oldShare + (targetShares[i] - oldShare) * SHARE_MIGRATION_SPEED;
return {
totalShare += migratedShare;
newShares[i] = {
playerId: p.id,
sharePercent: migratedShare,
customers: Math.floor(migratedShare * seg.totalSize),
customers: 0,
attractivenessScore: scores[i],
};
});
}
const totalShare = newShares.reduce((s, e) => s + e.sharePercent, 0);
if (totalShare > 0) {
for (const entry of newShares) {
entry.sharePercent /= totalShare;
@@ -152,10 +163,7 @@ export function updateTAMGrowth(tam: TotalAddressableMarket, era: Era): TotalAdd
const seg = segments[segId];
const base = baseSizes[segId];
const grown = seg.totalSize + seg.totalSize * TAM_GROWTH_PER_TICK;
segments[segId] = {
...seg,
totalSize: Math.max(base, grown),
};
segments[segId] = { ...seg, totalSize: Math.max(base, grown) };
}
return { segments };
+21 -15
View File
@@ -18,6 +18,7 @@ import {
QUANTIZATION_CONFIGS,
POINT_RELEASE_CAPABILITY_GAIN,
SIZE_TIER_LABELS,
MODEL_BASE_SAFETY,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
@@ -44,12 +45,12 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
const engineerBoost = state.talent.departments.engineering.headcount *
state.talent.departments.engineering.effectiveness;
const trainingResearchBonus = researchBonuses?.trainingSpeedBonus ?? 0;
const speedMultiplier = (1 + (researcherBoost + engineerBoost) * 0.05) * (1 + trainingResearchBonus);
const speedMultiplier = (1 + (researcherBoost + engineerBoost) * 0.15) * (1 + trainingResearchBonus);
const updatedPipelines: TrainingPipeline[] = [];
for (const pipeline of state.models.activeTrainingPipelines) {
if (pipeline.status !== 'active') {
if (pipeline.status !== 'active' && pipeline.status !== 'stalled') {
updatedPipelines.push(pipeline);
continue;
}
@@ -58,12 +59,12 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
const moeVramMultiplier = pipeline.architecture.type === 'moe' ? 1.5 : 1.0;
const requiredVram = (VRAM_REQUIREMENTS_BY_GENERATION[generation] ?? 0) * moeVramMultiplier;
if (requiredVram > 0 && state.compute.totalVramGB < requiredVram) {
updatedPipelines.push({ ...pipeline, status: 'stalled' });
updatedPipelines.push(pipeline.status === 'stalled' ? pipeline : { ...pipeline, status: 'stalled' });
continue;
}
const effectiveFlops = totalTrainingFlops * pipeline.allocatedComputeFraction;
let updated = { ...pipeline, events: [...pipeline.events] };
let updated = { ...pipeline, status: 'active' as TrainingPipeline['status'], events: [...pipeline.events] };
if (pipeline.currentStage === 'pretraining') {
const stage = { ...pipeline.stages.pretraining };
@@ -155,16 +156,21 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
const updatedEvalJobs = processEvalJobs(state);
const allDeployed = [
...baseModels.filter(m => m.isDeployed),
...families.flatMap(f => f.variants.filter(v => v.isDeployed)),
];
const bestDeployedModelScore = allDeployed.reduce((best, m) =>
Math.max(best, 'rawCapability' in m ? m.rawCapability : computeVariantScore(m)), 0);
const bestDeployedSafetyScore = allDeployed.reduce((best, m) =>
Math.max(best, m.safetyProfile.overallSafety), 0);
let bestDeployedModelScore = 0;
let bestDeployedSafetyScore = 0;
for (const m of baseModels) {
if (!m.isDeployed) continue;
if (m.rawCapability > bestDeployedModelScore) bestDeployedModelScore = m.rawCapability;
if (m.safetyProfile.overallSafety > bestDeployedSafetyScore) bestDeployedSafetyScore = m.safetyProfile.overallSafety;
}
for (const f of families) {
for (const v of f.variants) {
if (!v.isDeployed) continue;
const score = computeVariantScore(v);
if (score > bestDeployedModelScore) bestDeployedModelScore = score;
if (v.safetyProfile.overallSafety > bestDeployedSafetyScore) bestDeployedSafetyScore = v.safetyProfile.overallSafety;
}
}
return {
modelsState: {
@@ -375,7 +381,7 @@ function createBaseModel(
}
const safetyResearchBonus = researchBonuses?.safetyBonus ?? 0;
let overallSafety = Math.min(100, 30 + safetyResearchBonus + Math.random() * 10);
let overallSafety = Math.min(100, MODEL_BASE_SAFETY + safetyResearchBonus + Math.random() * 10);
let refusalRate = overallSafety > 60 ? 0.1 : 0.03;
if (pipeline.stages.alignment.isComplete) {
@@ -4,6 +4,8 @@ import {
SAFETY_INCIDENT_PROBABILITY_BASE,
SAFETY_INCIDENT_REPUTATION_HIT,
LOW_SAFETY_THRESHOLD,
SAFETY_RECORD_RECOVERY_RATE,
PUBLIC_PERCEPTION_GROWTH_RATE,
} from '@ai-tycoon/shared';
import type { ResearchBonuses } from './researchBonuses';
@@ -28,6 +30,10 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
}
}
if (state.models.bestDeployedSafetyScore >= LOW_SAFETY_THRESHOLD && !safetyIncident) {
safetyRecord = Math.min(80, safetyRecord + SAFETY_RECORD_RECOVERY_RATE);
}
const eraIdx = ['startup', 'scaleup', 'bigtech', 'agi'].indexOf(state.meta.currentEra);
const regulatoryPressure = eraIdx * 5;
const safetyResearchCount = state.research.completedResearch
@@ -39,10 +45,10 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
const talentMorale = Object.values(state.talent.departments)
.reduce((sum, d) => sum + d.morale, 0) / 4;
employeeSatisfaction = talentMorale;
employeeSatisfaction = talentMorale * 100;
const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0;
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * 0.1);
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * PUBLIC_PERCEPTION_GROWTH_RATE);
const score = Math.round(
safetyRecord * 0.3 +
@@ -21,7 +21,16 @@ export interface ResearchBonuses {
autoScalingBonus: number;
}
const techTreeById = new Map(TECH_TREE.map(n => [n.id, n]));
let _cachedBonuses: ResearchBonuses | null = null;
let _cachedResearchCount = -1;
export function getResearchBonuses(completedResearch: string[]): ResearchBonuses {
if (_cachedBonuses && completedResearch.length === _cachedResearchCount) {
return _cachedBonuses;
}
const bonuses: ResearchBonuses = {
energyCostReduction: 0,
pipelineSpeedBonus: 0,
@@ -42,7 +51,7 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
};
for (const id of completedResearch) {
const node = TECH_TREE.find(n => n.id === id);
const node = techTreeById.get(id);
if (!node) continue;
for (const effect of node.effects) {
@@ -79,5 +88,12 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
}
}
_cachedBonuses = bonuses;
_cachedResearchCount = completedResearch.length;
return bonuses;
}
export function resetResearchBonusCache(): void {
_cachedBonuses = null;
_cachedResearchCount = -1;
}
+42 -30
View File
@@ -13,6 +13,7 @@ import { checkEraTransition } from './systems/eraSystem';
import { processAchievements } from './systems/achievementSystem';
import { computeValuation } from './systems/fundingSystem';
import { getResearchBonuses } from './systems/researchBonuses';
import { TECH_TREE } from './data/techTree';
export interface TickResult {
state: Partial<GameState>;
@@ -38,10 +39,11 @@ export function processTick(state: GameState): Partial<GameState> {
const infraResult = processInfrastructure(state, researchBonuses);
const infrastructure = infraResult.infrastructure;
notifications.push(...infraResult.notifications);
if (infraResult.notifications.length > 0) notifications.push(...infraResult.notifications);
const stateWithInfra = { ...state, infrastructure };
const modelResult = processModels(stateWithInfra, researchBonuses);
// Build a mutable snapshot that accumulates updates through the tick
const snap: GameState = { ...state, infrastructure };
const modelResult = processModels(snap, researchBonuses);
for (const completed of modelResult.completedModels) {
notifications.push({
@@ -51,17 +53,17 @@ export function processTick(state: GameState): Partial<GameState> {
action: { label: 'Go to Families', page: 'models', modelsTab: 'models' },
});
}
notifications.push(...modelResult.notifications);
if (modelResult.notifications.length > 0) notifications.push(...modelResult.notifications);
const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState };
snap.models = modelResult.modelsState;
const capacity = computeCapacity(state, infrastructure, researchBonuses);
const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses);
const market = processMarket(snap, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses);
const compute = finalizeCompute(capacity, market.totalTokenDemand, state.compute.computeHistory, state.meta.tickCount);
const talent = processTalent(stateWithModels);
const stateWithTalent = { ...stateWithModels, talent };
const researchResult = processResearch(stateWithTalent, compute);
const talent = processTalent(snap);
snap.talent = talent;
const researchResult = processResearch(snap, compute);
if (researchResult.researchCompleted) {
notifications.push({
@@ -69,9 +71,22 @@ export function processTick(state: GameState): Partial<GameState> {
message: `${researchResult.researchCompleted} has been unlocked!`,
type: 'success',
});
const completedNode = TECH_TREE.find(n => n.id === researchResult.researchCompleted);
if (completedNode) {
for (const effect of completedNode.effects) {
if (effect.type === 'unlock_product_line') {
if (effect.target === 'code-assistant') {
market.marketState.codeAssistant = { ...market.marketState.codeAssistant, isUnlocked: true };
} else if (effect.target === 'agents-platform') {
market.marketState.agentsPlatform = { ...market.marketState.agentsPlatform, isUnlocked: true };
}
}
}
}
}
const reputationResult = processReputation(stateWithTalent, researchBonuses);
const reputationResult = processReputation(snap, researchBonuses);
const { _safetyIncident, ...reputation } = reputationResult;
if (_safetyIncident) {
notifications.push({
@@ -90,9 +105,9 @@ export function processTick(state: GameState): Partial<GameState> {
);
}
const extraCosts = infraResult.repairCosts + modelResult.legalCosts;
const economy = processEconomy(stateWithTalent, market, infrastructure, extraCosts);
const data = processData(stateWithTalent);
const competitors = processCompetitors(stateWithTalent);
const economy = processEconomy(snap, market, infrastructure, extraCosts);
const data = processData(snap);
const competitors = processCompetitors(snap);
const tickCount = state.meta.tickCount + 1;
@@ -103,7 +118,11 @@ export function processTick(state: GameState): Partial<GameState> {
totalPlayTime: state.meta.totalPlayTime + 1,
};
const newEra = checkEraTransition({ ...stateWithTalent, economy, reputation, research: researchResult.research });
snap.economy = economy;
snap.reputation = reputation;
snap.research = researchResult.research;
const newEra = checkEraTransition(snap);
if (newEra) {
meta = { ...meta, currentEra: newEra };
notifications.push({
@@ -113,29 +132,22 @@ export function processTick(state: GameState): Partial<GameState> {
});
}
const valuation = computeValuation({ ...stateWithTalent, economy, reputation, research: researchResult.research });
const valuation = computeValuation(snap);
const updatedEconomy = {
...economy,
funding: { ...economy.funding, valuation },
};
const stateForAchievements: GameState = {
...stateWithTalent,
meta,
economy: updatedEconomy,
infrastructure,
compute,
research: researchResult.research,
models: modelResult.modelsState,
market: market.marketState,
reputation,
data,
competitors,
achievements: state.achievements,
};
snap.meta = meta;
snap.economy = updatedEconomy;
snap.compute = compute;
snap.models = modelResult.modelsState;
snap.market = market.marketState;
snap.data = data;
snap.competitors = competitors;
const achievementResult = cachedAchievementDefs
? processAchievements(stateForAchievements, cachedAchievementDefs)
? processAchievements(snap, cachedAchievementDefs)
: { achievements: state.achievements, newAchievements: [] };
for (const name of achievementResult.newAchievements) {
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@ai-tycoon/game-simulation",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "./src/simulate.ts",
"types": "./src/simulate.ts",
"scripts": {
"simulate": "tsx src/simulate.ts",
"simulate:greedy": "tsx src/simulate.ts --strategy greedy --ticks 28800",
"simulate:random": "tsx src/simulate.ts --strategy random --ticks 28800",
"simulate:ci": "tsx src/simulate.ts --strategy greedy --ticks 28800 --json --csv --seed 42",
"multirun": "tsx src/multirun.ts",
"multirun:quick": "tsx src/multirun.ts --runs 5 --parallel 4 --strategy greedy --ticks 28800",
"multirun:full": "tsx src/multirun.ts --runs 20 --parallel 4 --strategy greedy --ticks 28800",
"interpret": "tsx src/interpret.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*",
"@ai-tycoon/game-engine": "workspace:*"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@types/node": "^22.0.0",
"tsx": "^4.19.4",
"typescript": "^5.8.0"
}
}
@@ -0,0 +1,20 @@
import type { GameState } from '@ai-tycoon/shared';
export function acquireCompetitor(state: GameState, competitorId: string): boolean {
const rival = state.competitors.rivals.find(r => r.id === competitorId);
if (!rival || rival.status === 'acquired') return false;
const cost = rival.estimatedRevenue * 50 + rival.estimatedCapability * 20_000;
if (state.economy.money < cost) return false;
const rpGain = Math.floor(rival.estimatedCapability / 15);
state.economy.money -= cost;
rival.status = 'acquired';
state.talent.departments.research.headcount += 5;
state.talent.departments.engineering.headcount += 5;
state.talent.departments.sales.headcount += 3;
state.research.researchPoints += rpGain;
state.reputation.publicPerception = Math.min(100, state.reputation.publicPerception + 5);
return true;
}
@@ -0,0 +1,10 @@
import type { GameState, OwnedDataset } from '@ai-tycoon/shared';
export function purchaseDataset(state: GameState, dataset: OwnedDataset, cost: number): boolean {
if (state.economy.money < cost) return false;
state.economy.money -= cost;
state.data.ownedDatasets.push(dataset);
state.data.totalTrainingTokens += dataset.sizeTokens;
return true;
}
@@ -0,0 +1,28 @@
import type { GameState, FundingRoundType } from '@ai-tycoon/shared';
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
import { canRaiseFunding, getNextFundingRound } from '@ai-tycoon/game-engine';
export function raiseFunding(state: GameState, roundType: FundingRoundType): boolean {
const config = FUNDING_ROUNDS[roundType];
if (!config) return false;
const { canRaise } = canRaiseFunding(state);
if (!canRaise) return false;
const next = getNextFundingRound(state.economy.funding);
if (next !== roundType) return false;
state.economy.money += config.amount;
state.economy.funding.totalRaised += config.amount;
state.economy.funding.founderEquity *= (1 - config.dilution);
state.economy.funding.completedRounds.push({
type: roundType,
amount: config.amount,
dilution: config.dilution,
completedAtTick: state.meta.tickCount,
});
if (roundType === 'ipo') {
state.economy.funding.isPublic = true;
}
return true;
}
@@ -0,0 +1,9 @@
let counter = 0;
export function simId(): string {
return `sim-${++counter}`;
}
export function resetIds(): void {
counter = 0;
}
@@ -0,0 +1,9 @@
export * from './ids';
export * from './infrastructure';
export * from './models';
export * from './research';
export * from './talent';
export * from './funding';
export * from './market';
export * from './data';
export * from './competitors';
@@ -0,0 +1,343 @@
import type {
GameState, Era, LocationId, DCTier, RackSkuId,
Cluster, Campus, DataCenter, DeploymentCohort,
CoolingType, NetworkFabric, PipelineStage,
} from '@ai-tycoon/shared';
import {
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
CLUSTER_COST_CONFIG, CAMPUS_TIER_COSTS, FIRST_CAMPUS_BUILD_TICKS,
PIPELINE_ORDER_BASE_TICKS, COHORT_SCALE_FACTOR,
COOLING_ORDER, COOLING_TYPE_CONFIGS,
FABRIC_ORDER, NETWORK_FABRIC_CONFIGS,
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
estimateNetworkSlots, maxComputeRacks,
} from '@ai-tycoon/shared';
import {
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
} from '@ai-tycoon/game-engine';
import { simId } from './ids';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
function eraIndex(era: Era): number {
return ERA_ORDER.indexOf(era);
}
// --- Lookup helpers ---
function findCluster(state: GameState, clusterId: string): Cluster | undefined {
return state.infrastructure.clusters.find(c => c.id === clusterId);
}
function findCampus(state: GameState, campusId: string): { cluster: Cluster; campus: Campus } | undefined {
for (const cluster of state.infrastructure.clusters) {
const campus = cluster.campuses.find(c => c.id === campusId);
if (campus) return { cluster, campus };
}
return undefined;
}
function findDC(state: GameState, dcId: string): { cluster: Cluster; campus: Campus; dc: DataCenter } | undefined {
for (const cluster of state.infrastructure.clusters) {
for (const campus of cluster.campuses) {
const dc = campus.dataCenters.find(d => d.id === dcId);
if (dc) return { cluster, campus, dc };
}
}
return undefined;
}
function pipelineCount(dc: DataCenter): number {
return dc.deploymentCohorts
.filter(c => c.stage !== 'decommission')
.reduce((sum, c) => sum + c.count, 0);
}
function emptyDCDefaults() {
return {
networkSummary: emptyDCNetworkSummary(),
effectiveComputeRacks: 0,
usedSlots: 0,
usedPowerKW: 0,
energyCostPerTick: 0,
maintenanceCostPerTick: 0,
currentUptime: 1,
};
}
// --- Actions ---
export function buildCluster(state: GameState, name: string, locationId: LocationId): boolean {
const loc = LOCATION_CONFIGS[locationId];
if (eraIndex(state.meta.currentEra) < eraIndex(loc.availableAt)) return false;
if (state.infrastructure.clusters.find(c => c.locationId === locationId)) return false;
const isFirst = state.infrastructure.clusters.length === 0;
const cost = isFirst ? 0 : CLUSTER_COST_CONFIG.baseCost;
if (state.economy.money < cost) return false;
const cluster: Cluster = {
id: simId(),
name,
locationId,
campuses: [],
status: isFirst ? 'operational' : 'constructing',
constructionProgress: 0,
constructionTotal: isFirst ? 0 : CLUSTER_COST_CONFIG.buildTimeTicks,
networkSummary: emptyClusterNetworkSummary(),
};
state.economy.money -= cost;
state.infrastructure.clusters.push(cluster);
return true;
}
export function buildCampus(state: GameState, name: string, clusterId: string, dcTier: DCTier): boolean {
const cluster = findCluster(state, clusterId);
if (!cluster || cluster.status !== 'operational') return false;
const tierConfig = DC_TIER_CONFIGS[dcTier];
if (eraIndex(state.meta.currentEra) < eraIndex(tierConfig.requiredEra)) return false;
if (tierConfig.requiredResearch && !state.research.completedResearch.includes(tierConfig.requiredResearch)) return false;
const isFirstCampus = state.infrastructure.clusters.every(c => c.campuses.length === 0);
const campusCost = CAMPUS_TIER_COSTS[dcTier];
const cost = isFirstCampus ? 0 : campusCost.baseCost;
if (state.economy.money < cost) return false;
const buildTime = isFirstCampus ? FIRST_CAMPUS_BUILD_TICKS : campusCost.buildTimeTicks;
const campus: Campus = {
id: simId(),
name,
clusterId,
dcTier,
dataCenters: [],
status: 'constructing',
constructionProgress: 0,
constructionTotal: buildTime,
retrofitQueue: null,
networkSummary: emptyCampusNetworkSummary(),
};
state.economy.money -= cost;
cluster.campuses.push(campus);
return true;
}
export function buildDataCenter(state: GameState, name: string, campusId: string): boolean {
const found = findCampus(state, campusId);
if (!found || found.campus.status !== 'operational') return false;
const tierConfig = DC_TIER_CONFIGS[found.campus.dcTier];
if (state.economy.money < tierConfig.baseCost) return false;
const isFirstDC = state.infrastructure.clusters.every(cl =>
cl.campuses.every(ca => ca.dataCenters.length === 0),
);
const buildTime = isFirstDC ? tierConfig.firstBuildTimeTicks : tierConfig.buildTimeTicks;
const dc: DataCenter = {
id: simId(),
name,
campusId,
tier: found.campus.dcTier,
status: 'constructing',
constructionProgress: 0,
constructionTotal: buildTime,
rackSkuId: null,
computeRacksOnline: 0,
computeRacksFailed: 0,
...emptyDCDefaults(),
deploymentCohorts: [],
retrofitState: null,
coolingLevel: 0,
redundancyLevel: 0,
coolingType: 'air' as CoolingType,
networkFabric: 'ethernet-100g' as NetworkFabric,
dcTrainingFlops: 0,
dcInferenceFlops: 0,
dcTotalVramGB: 0,
};
state.economy.money -= tierConfig.baseCost;
found.campus.dataCenters.push(dc);
return true;
}
export function deployRacks(state: GameState, dataCenterId: string, skuId: RackSkuId, quantity: number): boolean {
if (quantity <= 0) return false;
const found = findDC(state, dataCenterId);
if (!found || found.dc.status !== 'operational') return false;
const dc = found.dc;
if (dc.rackSkuId !== null && dc.rackSkuId !== skuId) return false;
const sku = RACK_SKU_CONFIGS[skuId];
if (eraIndex(state.meta.currentEra) < eraIndex(sku.era)) return false;
if (sku.requiredResearch.length > 0 && !sku.requiredResearch.every(r => state.research.completedResearch.includes(r))) return false;
const coolingOk = COOLING_ORDER.indexOf(sku.requiredCooling) <= COOLING_ORDER.indexOf(dc.coolingType);
if (!coolingOk) return false;
const tierConfig = DC_TIER_CONFIGS[dc.tier];
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
const existing = dc.computeRacksOnline + pipelineCount(dc);
const available = maxCompute - existing;
const actualQty = Math.min(quantity, available);
if (actualQty <= 0) return false;
const totalNetSlots = estimateNetworkSlots(existing + actualQty, dc.tier);
if (existing + actualQty + totalNetSlots > tierConfig.rackSlots) return false;
const powerNeeded = (existing + actualQty) * sku.powerDrawKW;
if (powerNeeded > tierConfig.powerBudgetKW) return false;
const totalCost = sku.baseCost * actualQty;
if (state.economy.money < totalCost) return false;
const scaledTicks = Math.ceil(PIPELINE_ORDER_BASE_TICKS * (1 + COHORT_SCALE_FACTOR * actualQty));
const cohort: DeploymentCohort = {
id: simId(),
count: actualQty,
skuId,
stage: 'ordered' as PipelineStage,
stageProgress: 0,
stageTotal: scaledTicks,
repairCount: 0,
};
state.economy.money -= totalCost;
dc.rackSkuId = skuId;
dc.deploymentCohorts.push(cohort);
return true;
}
export function fillDCToCapacity(state: GameState, dataCenterId: string, skuId: RackSkuId): boolean {
const found = findDC(state, dataCenterId);
if (!found || found.dc.status !== 'operational') return false;
const dc = found.dc;
if (dc.rackSkuId !== null && dc.rackSkuId !== skuId) return false;
const sku = RACK_SKU_CONFIGS[skuId];
const coolingOk = COOLING_ORDER.indexOf(sku.requiredCooling) <= COOLING_ORDER.indexOf(dc.coolingType);
if (!coolingOk) return false;
const tierConfig = DC_TIER_CONFIGS[dc.tier];
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
const existing = dc.computeRacksOnline + pipelineCount(dc);
const available = maxCompute - existing;
if (available <= 0) return false;
const affordableQty = Math.floor(state.economy.money / sku.baseCost);
const powerLimit = Math.floor((tierConfig.powerBudgetKW - dc.computeRacksOnline * sku.powerDrawKW) / sku.powerDrawKW);
const qty = Math.min(available, affordableQty, Math.max(0, powerLimit));
if (qty <= 0) return false;
return deployRacks(state, dataCenterId, skuId, qty);
}
export function addDCsToCampus(state: GameState, campusId: string, count: number): boolean {
if (count <= 0) return false;
const found = findCampus(state, campusId);
if (!found || found.campus.status !== 'operational') return false;
const tierConfig = DC_TIER_CONFIGS[found.campus.dcTier];
const totalCost = tierConfig.baseCost * count;
if (state.economy.money < totalCost) return false;
state.economy.money -= totalCost;
for (let i = 0; i < count; i++) {
const dc: DataCenter = {
id: simId(),
name: `${found.campus.name}-DC-${found.campus.dataCenters.length + i + 1}`,
campusId,
tier: found.campus.dcTier,
status: 'constructing',
constructionProgress: 0,
constructionTotal: tierConfig.buildTimeTicks,
rackSkuId: null,
computeRacksOnline: 0,
computeRacksFailed: 0,
...emptyDCDefaults(),
deploymentCohorts: [],
retrofitState: null,
coolingLevel: 0,
redundancyLevel: 0,
coolingType: 'air' as CoolingType,
networkFabric: 'ethernet-100g' as NetworkFabric,
dcTrainingFlops: 0,
dcInferenceFlops: 0,
dcTotalVramGB: 0,
};
found.campus.dataCenters.push(dc);
}
return true;
}
export function upgradeCoolingType(state: GameState, dataCenterId: string, targetCooling: CoolingType): boolean {
const found = findDC(state, dataCenterId);
if (!found || found.dc.status !== 'operational') return false;
const currentIdx = COOLING_ORDER.indexOf(found.dc.coolingType);
const targetIdx = COOLING_ORDER.indexOf(targetCooling);
if (targetIdx <= currentIdx) return false;
if (targetCooling === 'liquid' && !state.research.completedResearch.includes('liquid-cooling-tech')) return false;
if (targetCooling === 'immersion' && !state.research.completedResearch.includes('immersion-cooling-tech')) return false;
const cost = COOLING_TYPE_CONFIGS[targetCooling].upgradeCost[found.dc.tier];
if (state.economy.money < cost) return false;
state.economy.money -= cost;
found.dc.coolingType = targetCooling;
return true;
}
export function upgradeNetworkFabric(state: GameState, dataCenterId: string, targetFabric: NetworkFabric): boolean {
const found = findDC(state, dataCenterId);
if (!found || found.dc.status !== 'operational') return false;
const currentIdx = FABRIC_ORDER.indexOf(found.dc.networkFabric);
const targetIdx = FABRIC_ORDER.indexOf(targetFabric);
if (targetIdx <= currentIdx) return false;
if ((targetFabric === 'infiniband-ndr' || targetFabric === 'infiniband-xdr')
&& !state.research.completedResearch.includes('infiniband-networking')) return false;
const cost = NETWORK_FABRIC_CONFIGS[targetFabric].upgradeCost[found.dc.tier];
if (state.economy.money < cost) return false;
state.economy.money -= cost;
found.dc.networkFabric = targetFabric;
return true;
}
export function upgradeDataCenter(state: GameState, dataCenterId: string, upgrade: 'cooling' | 'redundancy'): boolean {
const found = findDC(state, dataCenterId);
if (!found || found.dc.status !== 'operational') return false;
const tierConfig = DC_TIER_CONFIGS[found.dc.tier];
const cost = tierConfig.baseCost * DC_UPGRADE_COST_FRACTION;
if (state.economy.money < cost) return false;
const currentLevel = upgrade === 'cooling' ? found.dc.coolingLevel : found.dc.redundancyLevel;
if (currentLevel >= 1.0) return false;
state.economy.money -= cost;
if (upgrade === 'cooling') {
found.dc.coolingLevel = Math.min(1.0, currentLevel + DC_UPGRADE_INCREMENT);
} else {
found.dc.redundancyLevel = Math.min(1.0, currentLevel + DC_UPGRADE_INCREMENT);
}
return true;
}
export { findCluster, findCampus, findDC, pipelineCount };
@@ -0,0 +1,52 @@
import type { GameState, ConsumerTierId, ApiTierId } from '@ai-tycoon/shared';
export function setTrainingAllocation(state: GameState, ratio: number): void {
state.compute.trainingAllocation = ratio;
state.compute.inferenceAllocation = 1 - ratio;
}
export function toggleConsumerTier(state: GameState, tierId: ConsumerTierId): void {
const tier = state.market.consumerTiers.tiers[tierId];
tier.config.isActive = !tier.config.isActive;
}
export function setConsumerTierPrice(state: GameState, tierId: ConsumerTierId, price: number): void {
state.market.consumerTiers.tiers[tierId].config.price = price;
}
export function toggleApiTier(state: GameState, tierId: ApiTierId): void {
const tier = state.market.apiTiers.tiers[tierId];
tier.config.isActive = !tier.config.isActive;
}
export function setApiTierPrice(
state: GameState,
tierId: ApiTierId,
field: 'monthlyFee' | 'inputTokenPrice' | 'outputTokenPrice',
value: number,
): void {
const config = state.market.apiTiers.tiers[tierId].config;
if (field === 'monthlyFee') config.monthlyFee = value;
else if (field === 'inputTokenPrice') config.inputTokenPrice = value;
else if (field === 'outputTokenPrice') config.outputTokenPrice = value;
}
export function setDevRelSpending(state: GameState, amount: number): void {
state.market.developerEcosystem.devRelSpending = amount;
}
export function setCodeAssistantPrice(state: GameState, price: number): void {
state.market.codeAssistant.pricePerSeat = price;
}
export function toggleCodeAssistant(state: GameState): void {
state.market.codeAssistant.isActive = !state.market.codeAssistant.isActive;
}
export function setAgentsPlatformPrice(state: GameState, price: number): void {
state.market.agentsPlatform.pricePerSeat = price;
}
export function toggleAgentsPlatform(state: GameState): void {
state.market.agentsPlatform.isActive = !state.market.agentsPlatform.isActive;
}
@@ -0,0 +1,171 @@
import type {
GameState, ModelFamily, TrainingPipeline, VariantCreationJob,
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod, SizeTier,
QuantizationLevel,
} from '@ai-tycoon/shared';
import {
MAX_CONCURRENT_TRAINING, SIZE_TIER_MAP, SIZE_TIER_LABELS,
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
POINT_RELEASE_TIME_FRACTION, QUANTIZATION_TICKS,
OPEN_SOURCE_REPUTATION_BOOST,
} from '@ai-tycoon/shared';
import { onModelDeployed } from '@ai-tycoon/game-engine';
import { simId } from './ids';
export interface TrainingConfig {
familyId?: string;
familyName?: string;
architecture: ModelArchitecture;
dataMix: DataMixAllocation;
allocatedComputeFraction: number;
targetTokens: number;
totalTicks: number;
sftSpecializations: SFTSpecialization[];
alignmentMethod: AlignmentMethod;
alignmentSafetyWeight: number;
isPointRelease?: boolean;
sourceModelId?: string;
}
export function startTrainingPipeline(state: GameState, config: TrainingConfig): boolean {
const activeCount = state.models.activeTrainingPipelines.filter(
p => p.status === 'active' || p.status === 'stalled',
).length;
const maxSlots = MAX_CONCURRENT_TRAINING[state.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return false;
let familyId: string;
if (config.familyId) {
familyId = config.familyId;
} else {
familyId = simId();
const generation = state.models.families.length + 1;
const family: ModelFamily = {
id: familyId,
name: config.familyName ?? 'Model',
generation,
baseModelIds: [],
variants: [],
createdAtTick: state.meta.tickCount,
};
state.models.families.push(family);
}
const sizeTier: SizeTier = SIZE_TIER_MAP[config.architecture.totalParameters] ?? 'small';
const familyName = config.familyName
?? state.models.families.find(f => f.id === familyId)?.name
?? 'Model';
let version = 1.0;
if (config.isPointRelease && config.sourceModelId) {
const src = state.models.baseModels.find(m => m.id === config.sourceModelId);
if (src) version = Math.round((src.version + 0.1) * 10) / 10;
}
const modelName = `${familyName} ${SIZE_TIER_LABELS[sizeTier]} v${version.toFixed(1)}`;
const baseTotalTicks = config.isPointRelease
? Math.ceil(config.totalTicks * POINT_RELEASE_TIME_FRACTION)
: config.totalTicks;
const pipeline: TrainingPipeline = {
id: simId(),
familyId,
modelName,
architecture: config.architecture,
dataMix: config.dataMix,
currentStage: 'pretraining',
stages: {
pretraining: {
targetTokens: config.targetTokens,
processedTokens: 0,
computeAllocated: 0,
progressTicks: 0,
totalTicks: baseTotalTicks,
lossValue: 10,
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
isComplete: false,
},
sft: {
specializations: config.sftSpecializations,
progressTicks: 0,
totalTicks: Math.ceil(baseTotalTicks * SFT_TIME_FRACTION),
isComplete: false,
},
alignment: {
method: config.alignmentMethod,
safetyWeight: config.alignmentSafetyWeight,
helpfulnessWeight: 1 - config.alignmentSafetyWeight,
progressTicks: 0,
totalTicks: Math.ceil(baseTotalTicks * ALIGNMENT_TIME_FRACTION),
isComplete: false,
},
},
status: 'active',
allocatedComputeFraction: config.allocatedComputeFraction,
events: [],
startedAtTick: state.meta.tickCount,
sizeTier,
isPointRelease: config.isPointRelease ?? false,
sourceModelId: config.sourceModelId ?? null,
};
state.models.activeTrainingPipelines.push(pipeline);
return true;
}
export function deployModel(state: GameState, modelId: string): boolean {
const model = state.models.baseModels.find(m => m.id === modelId);
if (!model) return false;
model.isDeployed = true;
for (const pl of state.models.productLines) {
pl.modelId = modelId;
pl.isActive = true;
}
state.market.obsolescence = onModelDeployed(state.market.obsolescence, state.meta.tickCount);
if (model.rawCapability > state.models.bestDeployedModelScore) {
state.models.bestDeployedModelScore = model.rawCapability;
}
if (model.safetyProfile.overallSafety > state.models.bestDeployedSafetyScore) {
state.models.bestDeployedSafetyScore = model.safetyProfile.overallSafety;
}
return true;
}
export function createQuantization(
state: GameState,
baseModelId: string,
level: QuantizationLevel,
variantName: string,
): boolean {
const base = state.models.baseModels.find(m => m.id === baseModelId);
if (!base) return false;
const job: VariantCreationJob = {
id: simId(),
familyId: base.familyId,
baseModelId,
jobType: 'quantization',
config: { level, variantName },
progressTicks: 0,
totalTicks: QUANTIZATION_TICKS,
allocatedComputeFraction: 0,
status: 'active',
};
state.models.variantJobs.push(job);
return true;
}
export function openSourceModel(state: GameState, modelId: string): boolean {
if (state.market.openSourcedModels.includes(modelId)) return false;
state.market.openSourcedModels.push(modelId);
state.reputation.score = Math.min(100, state.reputation.score + OPEN_SOURCE_REPUTATION_BOOST);
state.reputation.publicPerception = Math.min(100, state.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST);
return true;
}
@@ -0,0 +1,16 @@
import type { GameState, ActiveResearch } from '@ai-tycoon/shared';
import { TECH_TREE } from '@ai-tycoon/game-engine';
export function startResearch(state: GameState, research: ActiveResearch): boolean {
if (state.research.activeResearch) return false;
const node = TECH_TREE.find(n => n.id === research.researchId);
if (!node) return false;
const rpCost = node.cost.researchPoints ?? 0;
if (rpCost > state.research.researchPoints) return false;
state.research.activeResearch = research;
state.research.researchPoints -= rpCost;
return true;
}
@@ -0,0 +1,14 @@
import type { GameState } from '@ai-tycoon/shared';
export type DepartmentId = 'research' | 'engineering' | 'operations' | 'sales';
const COST_PER_HIRE = 2000;
export function hireDepartment(state: GameState, departmentId: DepartmentId, count: number): boolean {
const totalCost = COST_PER_HIRE * count;
if (state.economy.money < totalCost) return false;
state.economy.money -= totalCost;
state.talent.departments[departmentId].headcount += count;
return true;
}
@@ -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 };
}
@@ -0,0 +1,37 @@
import type { GameState } from '@ai-tycoon/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
INITIAL_TALENT, INITIAL_DATA,
INITIAL_REPUTATION, INITIAL_ACHIEVEMENTS,
} from '@ai-tycoon/shared';
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
export function createInitialState(companyName = 'SimCorp'): GameState {
return {
meta: {
saveVersion: SAVE_VERSION,
companyName,
currentEra: 'startup',
tickCount: 0,
lastTickTimestamp: Date.now(),
gameSpeed: 1,
isPaused: false,
createdAt: Date.now(),
totalPlayTime: 0,
settings: { ...INITIAL_SETTINGS },
},
economy: structuredClone(INITIAL_ECONOMY),
infrastructure: structuredClone(INITIAL_INFRASTRUCTURE),
compute: structuredClone(INITIAL_COMPUTE),
research: structuredClone(INITIAL_RESEARCH),
models: structuredClone(INITIAL_MODELS),
market: structuredClone(INITIAL_MARKET),
competitors: { rivals: structuredClone(INITIAL_RIVALS), industryBenchmark: 0 },
talent: structuredClone(INITIAL_TALENT),
data: structuredClone(INITIAL_DATA),
reputation: structuredClone(INITIAL_REPUTATION),
achievements: structuredClone(INITIAL_ACHIEVEMENTS),
};
}
+383
View File
@@ -0,0 +1,383 @@
import { readFileSync } from 'node:fs';
import { writeFileSync } from 'node:fs';
const args = process.argv.slice(2);
function getArg(name: string, defaultValue: string): string {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
}
const summaryPath = getArg('summary', '');
const outPath = getArg('out', '');
if (!summaryPath) {
console.error('Usage: interpret --summary <path-to-multirun-summary.csv> [--out <path>]');
process.exit(1);
}
interface SummaryRow {
runId: number;
seed: number;
passed: boolean;
wallTimeMs: number;
finalEra: string;
finalMoney: number;
finalRevenue: number;
finalTotalRevenue: number;
finalCapability: number;
finalReputation: number;
finalSubscribers: number;
finalDevelopers: number;
finalHeadcount: number;
finalResearchCount: number;
finalModelsDeployed: number;
revenueStreamDiversity: number;
featureUtilization: Record<string, number>;
interconnectionOverall: number;
interconnections: Record<string, number>;
eraTransition_scaleup: number | null;
eraTransition_bigtech: number | null;
eraTransition_agi: number | null;
bankruptcyRisks: number;
sanityErrors: number;
failureReasons: string;
}
function parseSummaryCsv(content: string): SummaryRow[] {
const lines = content.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',');
const rows: SummaryRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
const get = (name: string): string => values[headers.indexOf(name)] ?? '';
const num = (name: string): number => { const v = get(name); return v === '' ? 0 : Number(v); };
const fuCategories: Record<string, number> = {};
const icLinks: Record<string, number> = {};
for (let h = 0; h < headers.length; h++) {
if (headers[h].startsWith('featureUtilization_')) {
fuCategories[headers[h].replace('featureUtilization_', '')] = Number(values[h]) || 0;
}
if (headers[h].startsWith('interconnection_') && headers[h] !== 'interconnection_overall') {
icLinks[headers[h].replace('interconnection_', '')] = Number(values[h]) || 0;
}
}
rows.push({
runId: num('runId'),
seed: num('seed'),
passed: num('passed') === 1,
wallTimeMs: num('wallTimeMs'),
finalEra: get('finalEra'),
finalMoney: num('finalMoney'),
finalRevenue: num('finalRevenue'),
finalTotalRevenue: num('finalTotalRevenue'),
finalCapability: num('finalCapability'),
finalReputation: num('finalReputation'),
finalSubscribers: num('finalSubscribers'),
finalDevelopers: num('finalDevelopers'),
finalHeadcount: num('finalHeadcount'),
finalResearchCount: num('finalResearchCount'),
finalModelsDeployed: num('finalModelsDeployed'),
revenueStreamDiversity: num('revenueStreamDiversity'),
featureUtilization: fuCategories,
interconnectionOverall: num('interconnection_overall'),
interconnections: icLinks,
eraTransition_scaleup: get('eraTransition_scaleup') ? num('eraTransition_scaleup') : null,
eraTransition_bigtech: get('eraTransition_bigtech') ? num('eraTransition_bigtech') : null,
eraTransition_agi: get('eraTransition_agi') ? num('eraTransition_agi') : null,
bankruptcyRisks: num('bankruptcyRisks'),
sanityErrors: num('sanityErrors'),
failureReasons: get('failureReasons').replace(/^"|"$/g, ''),
});
}
return rows;
}
function parseCSVLine(line: string): string[] {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
values.push(current);
current = '';
} else {
current += ch;
}
}
}
values.push(current);
return values;
}
interface Stats {
mean: number;
median: number;
stddev: number;
min: number;
max: number;
p5: number;
p95: number;
cv: number;
}
function computeStats(values: number[]): Stats {
if (values.length === 0) return { mean: 0, median: 0, stddev: 0, min: 0, max: 0, p5: 0, p95: 0, cv: 0 };
const sorted = [...values].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)];
const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n;
const stddev = Math.sqrt(variance);
const min = sorted[0];
const max = sorted[n - 1];
const p5 = sorted[Math.floor(n * 0.05)] ?? min;
const p95 = sorted[Math.min(Math.floor(n * 0.95), n - 1)] ?? max;
const cv = mean !== 0 ? stddev / Math.abs(mean) : 0;
return { mean, median, stddev, min, max, p5, p95, cv };
}
function fmtNum(n: number, decimals = 1): string {
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(decimals)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(decimals)}M`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(decimals)}K`;
return n.toFixed(decimals);
}
function pad(s: string, w: number): string {
return s.padEnd(w);
}
function formatDuration(ticks: number): string {
const totalMinutes = Math.floor(ticks / 60);
if (totalMinutes < 60) return `${totalMinutes}m`;
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
function statsLine(label: string, s: Stats, formatter: (n: number) => string = n => fmtNum(n)): string {
const cvFlag = s.cv > 0.3 ? ' [HIGH VARIANCE]' : '';
return ` ${pad(label, 22)} mean=${pad(formatter(s.mean), 10)} median=${pad(formatter(s.median), 10)} stddev=${pad(formatter(s.stddev), 10)} range=[${formatter(s.min)}, ${formatter(s.max)}] p5=${formatter(s.p5)} p95=${formatter(s.p95)} CV=${s.cv.toFixed(2)}${cvFlag}`;
}
function generateReport(rows: SummaryRow[]): string {
const lines: string[] = [];
const n = rows.length;
lines.push('=== Multi-Run Interpretation Report ===');
lines.push('');
// 1. Run Overview
const passCount = rows.filter(r => r.passed).length;
const totalWallTime = rows.reduce((s, r) => s + r.wallTimeMs, 0);
lines.push('1. RUN OVERVIEW');
lines.push(` Total runs: ${n}`);
lines.push(` Pass rate: ${passCount}/${n} (${((passCount / n) * 100).toFixed(0)}%)`);
lines.push(` Total wall time: ${(totalWallTime / 1000).toFixed(0)}s (avg ${(totalWallTime / n / 1000).toFixed(1)}s per run)`);
const failedRuns = rows.filter(r => !r.passed);
if (failedRuns.length > 0) {
lines.push(` Failed seeds: ${failedRuns.map(r => r.seed).join(', ')}`);
}
lines.push('');
// 2. Statistical Summaries
lines.push('2. KEY METRICS');
const metricDefs: Array<{ label: string; getter: (r: SummaryRow) => number; fmt?: (n: number) => string }> = [
{ label: 'Final Money', getter: r => r.finalMoney, fmt: n => `$${fmtNum(n)}` },
{ label: 'Final Revenue/tick', getter: r => r.finalRevenue, fmt: n => `$${fmtNum(n)}` },
{ label: 'Total Revenue', getter: r => r.finalTotalRevenue, fmt: n => `$${fmtNum(n)}` },
{ label: 'Capability', getter: r => r.finalCapability, fmt: n => n.toFixed(1) },
{ label: 'Reputation', getter: r => r.finalReputation, fmt: n => n.toFixed(1) },
{ label: 'Subscribers', getter: r => r.finalSubscribers, fmt: n => fmtNum(n, 0) },
{ label: 'API Developers', getter: r => r.finalDevelopers, fmt: n => fmtNum(n, 0) },
{ label: 'Headcount', getter: r => r.finalHeadcount, fmt: n => String(Math.round(n)) },
{ label: 'Research Count', getter: r => r.finalResearchCount, fmt: n => String(Math.round(n)) },
{ label: 'Models Deployed', getter: r => r.finalModelsDeployed, fmt: n => String(Math.round(n)) },
{ label: 'Revenue Streams', getter: r => r.revenueStreamDiversity, fmt: n => String(Math.round(n)) },
];
const highVarianceMetrics: string[] = [];
for (const def of metricDefs) {
const values = rows.map(def.getter);
const s = computeStats(values);
lines.push(statsLine(def.label, s, def.fmt));
if (s.cv > 0.3) highVarianceMetrics.push(def.label);
}
lines.push('');
// 3. Era Transition Timing
lines.push('3. ERA TRANSITION TIMING');
const eraTransitions: Array<{ label: string; getter: (r: SummaryRow) => number | null }> = [
{ label: 'Startup → Scale-up', getter: r => r.eraTransition_scaleup },
{ label: 'Scale-up → Big Tech', getter: r => r.eraTransition_bigtech },
{ label: 'Big Tech → AGI', getter: r => r.eraTransition_agi },
];
const inconsistentEras: string[] = [];
for (const et of eraTransitions) {
const values = rows.map(et.getter).filter((v): v is number => v !== null);
const reached = values.length;
if (reached === 0) {
lines.push(` ${pad(et.label, 24)} never reached`);
continue;
}
const s = computeStats(values);
const cvFlag = s.cv > 0.25 ? ' [INCONSISTENT]' : '';
if (s.cv > 0.25) inconsistentEras.push(et.label);
lines.push(` ${pad(et.label, 24)} ${reached}/${n} reached | mean=${formatDuration(s.mean).padStart(6)} median=${formatDuration(s.median).padStart(6)} stddev=${Math.round(s.stddev).toString().padStart(5)}t range=[${formatDuration(s.min)}, ${formatDuration(s.max)}] CV=${s.cv.toFixed(2)}${cvFlag}`);
}
lines.push('');
// 4. Feature Utilization Consistency
lines.push('4. FEATURE UTILIZATION');
const fuCategories = Object.keys(rows[0]?.featureUtilization ?? {});
const consistentlyLow: string[] = [];
for (const cat of fuCategories) {
const values = rows.map(r => r.featureUtilization[cat] ?? 0);
const s = computeStats(values);
const bar = '#'.repeat(Math.round(s.mean / 5)) + '-'.repeat(20 - Math.round(s.mean / 5));
const flag = s.mean < 50 ? ' [LOW]' : '';
if (s.mean < 50) consistentlyLow.push(cat);
lines.push(` ${pad(cat, 16)} [${bar}] mean=${s.mean.toFixed(0)}% stddev=${s.stddev.toFixed(1)}${flag}`);
}
lines.push('');
// 5. System Interconnections
lines.push('5. SYSTEM INTERCONNECTIONS');
const icKeys = Object.keys(rows[0]?.interconnections ?? {});
const weakLinks: string[] = [];
const deadLinks: string[] = [];
const inconsistentLinks: string[] = [];
{
const overallValues = rows.map(r => r.interconnectionOverall);
const overallStats = computeStats(overallValues);
lines.push(` Overall score: mean=${overallStats.mean.toFixed(1)} stddev=${overallStats.stddev.toFixed(1)} range=[${overallStats.min.toFixed(1)}, ${overallStats.max.toFixed(1)}]`);
}
for (const key of icKeys) {
const values = rows.map(r => r.interconnections[key] ?? 0);
const s = computeStats(values);
const label = key.replace(/_/g, ' → ').replace(/([a-z])([A-Z])/g, '$1 $2');
const bar = '#'.repeat(Math.round(s.mean)) + '-'.repeat(10 - Math.round(s.mean));
let flag = '';
if (s.mean === 0) { flag = ' [DEAD]'; deadLinks.push(label); }
else if (s.mean < 3) { flag = ' [WEAK]'; weakLinks.push(label); }
if (s.stddev > 3) { flag += ' [INCONSISTENT]'; inconsistentLinks.push(label); }
lines.push(` ${pad(label, 30)} [${bar}] mean=${s.mean.toFixed(1)} stddev=${s.stddev.toFixed(1)} min=${s.min}${flag}`);
}
lines.push('');
// 6. Failure Analysis
lines.push('6. FAILURE ANALYSIS');
const failureFreq: Record<string, number> = {};
for (const r of rows) {
if (!r.failureReasons) continue;
const seen = new Set<string>();
for (const reason of r.failureReasons.split('; ').filter(Boolean)) {
const normalized = reason.replace(/tick \d+/g, 'tick N').replace(/\d+ ticks/g, 'N ticks');
seen.add(normalized);
}
for (const normalized of seen) {
failureFreq[normalized] = (failureFreq[normalized] ?? 0) + 1;
}
}
const sortedFailures = Object.entries(failureFreq).sort((a, b) => b[1] - a[1]);
if (sortedFailures.length === 0) {
lines.push(' No failures detected across all runs.');
} else {
for (const [reason, count] of sortedFailures.slice(0, 10)) {
lines.push(` ${((count / n) * 100).toFixed(0).padStart(3)}% (${count}/${n}) ${reason}`);
}
}
const bankruptcyRuns = rows.filter(r => r.bankruptcyRisks > 0).length;
if (bankruptcyRuns > 0) {
lines.push(` Bankruptcy risk: ${bankruptcyRuns}/${n} runs (${((bankruptcyRuns / n) * 100).toFixed(0)}%)`);
}
const sanityFailRuns = rows.filter(r => r.sanityErrors > 0).length;
if (sanityFailRuns > 0) {
lines.push(` Sanity errors: ${sanityFailRuns}/${n} runs (${((sanityFailRuns / n) * 100).toFixed(0)}%)`);
}
lines.push('');
// 7. Actionable Recommendations
lines.push('7. RECOMMENDATIONS');
const recs: string[] = [];
if (passCount / n < 0.8) {
const topFailure = sortedFailures[0];
if (topFailure) {
recs.push(`Balance is unstable — "${topFailure[0]}" occurs in ${((topFailure[1] / n) * 100).toFixed(0)}% of runs. This is the top priority fix.`);
}
}
for (const cat of consistentlyLow) {
recs.push(`Feature category "${cat}" has <50% utilization on average — review whether ${cat} features are reachable and worthwhile for the strategy.`);
}
for (const link of deadLinks) {
recs.push(`"${link}" has no measurable effect in any run — investment in the source doesn't translate to improvement in the target.`);
}
for (const link of weakLinks) {
recs.push(`"${link}" is consistently weak (mean <3/10) — the connection exists but is too faint to drive strategy.`);
}
for (const metric of highVarianceMetrics) {
const values = rows.map(metricDefs.find(d => d.label === metric)!.getter);
const s = computeStats(values);
recs.push(`"${metric}" is highly seed-dependent (CV=${s.cv.toFixed(2)}) — outcome is more luck than strategy. Consider tighter guardrails.`);
}
for (const era of inconsistentEras) {
recs.push(`"${era}" transition timing is inconsistent (CV>0.25) — suggests a fragile threshold crossing that depends on RNG luck.`);
}
if (passCount === n && recs.length === 0) {
recs.push('All runs passed with consistent results. Balance looks stable across seeds.');
}
for (let i = 0; i < recs.length; i++) {
lines.push(` ${i + 1}. ${recs[i]}`);
}
lines.push('');
return lines.join('\n');
}
const csvContent = readFileSync(summaryPath, 'utf-8');
const rows = parseSummaryCsv(csvContent);
if (rows.length === 0) {
console.error('No data found in summary CSV.');
process.exit(1);
}
const report = generateReport(rows);
if (outPath) {
writeFileSync(outPath, report);
console.log(`Report written to ${outPath}`);
} else {
console.log(report);
}
+243
View File
@@ -0,0 +1,243 @@
import { exec } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { resolve as pathResolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { cpus } from 'node:os';
import type { SimulationMetrics } from './strategies/types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
function getArg(name: string, defaultValue: string): string {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
}
function hasFlag(name: string): boolean {
return args.includes(`--${name}`);
}
const totalRuns = parseInt(getArg('runs', '0'), 10);
const parallel = Math.min(totalRuns, parseInt(getArg('parallel', String(Math.max(1, cpus().length - 1))), 10));
const strategyName = getArg('strategy', 'greedy');
const totalTicks = parseInt(getArg('ticks', '28800'), 10);
const outDir = pathResolve(getArg('out', pathResolve(__dirname, '..')));
const baseSeedStr = getArg('seed', '');
const baseSeed = baseSeedStr ? parseInt(baseSeedStr, 10) : null;
const noTimeseries = hasFlag('no-timeseries');
if (totalRuns <= 0) {
console.error('Usage: multirun --runs <N> [--parallel <P>] [--strategy <name>] [--ticks <N>] [--out <dir>] [--seed <N>] [--no-timeseries]');
process.exit(1);
}
function deriveSeeds(count: number, base: number | null): number[] {
if (base === null) {
return Array.from({ length: count }, () => randomInt(1, 2_147_483_647));
}
// Deterministic derivation from base seed
const seeds: number[] = [];
for (let i = 0; i < count; i++) {
let h = (base + i * 0x9E3779B9) | 0;
h = Math.imul(h ^ (h >>> 16), 0x45D9F3B);
h = Math.imul(h ^ (h >>> 13), 0x45D9F3B);
h = (h ^ (h >>> 16)) >>> 0;
seeds.push(h || 1);
}
return seeds;
}
interface WorkerResult {
runId: number;
seed: number;
passed: boolean;
failureReasons: string[];
wallTimeMs: number;
eraTransitions: Array<{ from: string; to: string; tick: number; wallTime: string }>;
finalMetrics: SimulationMetrics | null;
featureUtilization: {
coverageByCategory: Record<string, { used: number; available: number; percent: number }>;
unusedFeatures: string[];
revenueStreamDiversity: number;
};
systemInterconnections: {
connections: Array<{ from: string; to: string; score: number; evidence: string; events: number }>;
overallScore: number;
};
cashFlow: { bankruptcyRisks: number };
sanityChecks: { passed: boolean; errorCount: number };
metrics: SimulationMetrics[];
}
function spawnWorker(runId: number, seed: number): Promise<WorkerResult> {
return new Promise((resolve, reject) => {
const workerPath = new URL('./worker.ts', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
const cmd = `npx tsx "${workerPath}" --strategy ${strategyName} --ticks ${totalTicks} --seed ${seed} --run-id ${runId}`;
exec(cmd, { maxBuffer: 200 * 1024 * 1024 }, (error, stdout, stderr) => {
if (stderr) process.stderr.write(stderr);
if (error) {
reject(new Error(`Run #${runId} (seed ${seed}) failed: ${error.message}`));
return;
}
try {
const result = JSON.parse(stdout) as WorkerResult;
resolve(result);
} catch (e) {
reject(new Error(`Run #${runId} (seed ${seed}) produced invalid JSON: ${(e as Error).message}`));
}
});
});
}
function buildSummaryCsv(results: WorkerResult[]): string {
const interconnectionKeys = results[0]?.systemInterconnections.connections.map(
c => `interconnection_${c.from}_${c.to}`.replace(/\s+/g, ''),
) ?? [];
const headers = [
'runId', 'seed', 'passed', 'wallTimeMs',
'finalEra', 'finalMoney', 'finalRevenue', 'finalTotalRevenue',
'finalCapability', 'finalReputation', 'finalSubscribers', 'finalDevelopers',
'finalHeadcount', 'finalResearchCount', 'finalModelsDeployed',
'revenueStreamDiversity',
'featureUtilization_research', 'featureUtilization_infrastructure',
'featureUtilization_revenue', 'featureUtilization_talent',
'featureUtilization_model', 'featureUtilization_funding',
'interconnection_overall',
...interconnectionKeys,
'eraTransition_scaleup', 'eraTransition_bigtech', 'eraTransition_agi',
'bankruptcyRisks', 'sanityErrors', 'failureReasons',
];
const rows = results.map(r => {
const fm = r.finalMetrics;
const fu = r.featureUtilization;
const ic = r.systemInterconnections;
const eraMap: Record<string, number | ''> = {
scaleup: '', bigtech: '', agi: '',
};
for (const t of r.eraTransitions) {
if (t.to === 'scaleup') eraMap.scaleup = t.tick;
if (t.to === 'bigtech') eraMap.bigtech = t.tick;
if (t.to === 'agi') eraMap.agi = t.tick;
}
const icScores = ic.connections.map(c => c.score);
return [
r.runId, r.seed, r.passed ? 1 : 0, r.wallTimeMs,
fm?.era ?? '', fm?.money ?? '', fm?.revenue ?? '', fm?.totalRevenue ?? '',
fm?.bestModelCapability ?? '', fm?.reputation ?? '', fm?.subscribers ?? '', fm?.developers ?? '',
fm?.headcount ?? '', fm?.researchCount ?? '', fm?.modelsDeployed ?? '',
fu.revenueStreamDiversity,
fu.coverageByCategory['research']?.percent ?? '',
fu.coverageByCategory['infrastructure']?.percent ?? '',
fu.coverageByCategory['revenue']?.percent ?? '',
fu.coverageByCategory['talent']?.percent ?? '',
fu.coverageByCategory['model']?.percent ?? '',
fu.coverageByCategory['funding']?.percent ?? '',
ic.overallScore,
...icScores,
eraMap.scaleup, eraMap.bigtech, eraMap.agi,
r.cashFlow.bankruptcyRisks,
r.sanityChecks.errorCount,
`"${r.failureReasons.join('; ').replace(/"/g, '""')}"`,
].join(',');
});
return [headers.join(','), ...rows].join('\n');
}
function buildTimeseriesCsv(results: WorkerResult[]): string {
if (results.length === 0 || results[0].metrics.length === 0) return '';
const sampleKeys = Object.keys(results[0].metrics[0]).filter(k => k !== 'completedResearchIds') as (keyof SimulationMetrics)[];
const headers = ['runId', 'seed', ...sampleKeys];
const rows: string[] = [];
for (const r of results) {
for (const m of r.metrics) {
const values = sampleKeys.map(k => {
const v = m[k];
return typeof v === 'number' ? v : String(v);
});
rows.push([r.runId, r.seed, ...values].join(','));
}
}
return [headers.join(','), ...rows].join('\n');
}
async function main() {
const seeds = deriveSeeds(totalRuns, baseSeed);
const startTime = Date.now();
console.log(`=== Multi-Run Simulation ===`);
console.log(`Runs: ${totalRuns} | Parallel: ${parallel} | Strategy: ${strategyName} | Ticks: ${totalTicks.toLocaleString()}`);
console.log(`Seeds: [${seeds.join(', ')}]`);
if (baseSeed !== null) console.log(`Base seed: ${baseSeed} (deterministic)`);
console.log('');
const results: WorkerResult[] = [];
let completed = 0;
const pending = seeds.map((seed, i) => ({ runId: i + 1, seed }));
const active = new Set<Promise<void>>();
async function runOne(runId: number, seed: number): Promise<void> {
try {
const result = await spawnWorker(runId, seed);
results.push(result);
completed++;
const status = result.passed ? 'PASSED' : 'FAILED';
const reasons = !result.passed && result.failureReasons.length > 0
? ` [${result.failureReasons.join('; ')}]`
: '';
console.log(`[${completed}/${totalRuns}] Run #${runId} (seed ${seed}) completed in ${(result.wallTimeMs / 1000).toFixed(1)}s — ${status}${reasons}`);
} catch (e) {
completed++;
console.error(`[${completed}/${totalRuns}] Run #${runId} (seed ${seed}) ERROR: ${(e as Error).message}`);
}
}
for (const job of pending) {
if (active.size >= parallel) {
await Promise.race(active);
}
const p = runOne(job.runId, job.seed).then(() => { active.delete(p); });
active.add(p);
}
await Promise.all(active);
results.sort((a, b) => a.runId - b.runId);
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
const passCount = results.filter(r => r.passed).length;
console.log('');
console.log(`=== Complete ===`);
console.log(`${passCount}/${results.length} passed | Total wall time: ${totalTime}s`);
const summaryCsv = buildSummaryCsv(results);
const summaryPath = pathResolve(outDir, 'multirun-summary.csv');
writeFileSync(summaryPath, summaryCsv);
console.log(`Summary CSV: ${summaryPath}`);
if (!noTimeseries) {
const tsCsv = buildTimeseriesCsv(results);
const tsPath = pathResolve(outDir, 'multirun-timeseries.csv');
writeFileSync(tsPath, tsCsv);
console.log(`Time-series CSV: ${tsPath}`);
}
const failedSeeds = results.filter(r => !r.passed).map(r => r.seed);
if (failedSeeds.length > 0) {
console.log(`\nFailed seeds (for reproduction): ${failedSeeds.join(', ')}`);
}
}
main().catch(e => {
console.error(e);
process.exit(1);
});
+23
View File
@@ -0,0 +1,23 @@
export interface SeededRNG {
random(): number;
install(): void;
uninstall(): void;
}
export function createSeededRNG(seed: number): SeededRNG {
let state = seed | 0;
const originalRandom = Math.random;
function random(): number {
state = (state + 0x6D2B79F5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
return {
random,
install() { Math.random = random; },
uninstall() { Math.random = originalRandom; },
};
}
+161
View File
@@ -0,0 +1,161 @@
import type { GameState } from '@ai-tycoon/shared';
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, resetResearchBonusCache } 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;
}
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();
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 (!config.silent && tick % progressInterval === 0) {
printProgress(tick, config.totalTicks, state, startTime);
}
}
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,
};
}
+76
View File
@@ -0,0 +1,76 @@
import { runSimulation } from './runner';
import { GreedyStrategy } from './strategies/greedy';
import { RandomStrategy } from './strategies/random';
import { printConsoleReport, generateJsonReport } from './analysis/report';
import { writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { SimulationMetrics } from './strategies/types';
const args = process.argv.slice(2);
function getArg(name: string, defaultValue: string): string {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
}
function hasFlag(name: string): boolean {
return args.includes(`--${name}`);
}
const strategyName = getArg('strategy', 'greedy');
const totalTicks = parseInt(getArg('ticks', '28800'), 10);
const decisionInterval = parseInt(getArg('interval', '60'), 10);
const seedStr = getArg('seed', '');
const seed = seedStr ? parseInt(seedStr, 10) : undefined;
const jsonOutput = hasFlag('json');
const verbose = hasFlag('verbose');
const csvOutput = hasFlag('csv');
const strategy = strategyName === 'random' ? new RandomStrategy() : new GreedyStrategy();
console.log(`Running ${strategyName} simulation: ${totalTicks.toLocaleString()} ticks, interval ${decisionInterval}${seed !== undefined ? `, seed ${seed}` : ''}...`);
const result = runSimulation({ totalTicks, decisionInterval, strategy, seed, verbose });
const __dirname = dirname(fileURLToPath(import.meta.url));
if (csvOutput) {
const allMetrics = result.metrics;
if (allMetrics.length > 0) {
const scalarKeys = Object.keys(allMetrics[0]).filter(k => k !== 'completedResearchIds') as (keyof SimulationMetrics)[];
const headers = scalarKeys.join(',');
const rows = allMetrics.map(m =>
scalarKeys.map(k => {
const v = m[k];
return typeof v === 'number' ? v : String(v);
}).join(','),
);
const csv = [headers, ...rows].join('\n');
const csvPath = resolve(__dirname, '..', 'balance-metrics.csv');
writeFileSync(csvPath, csv);
console.log(`Metrics CSV written to ${csvPath}`);
}
}
if (jsonOutput) {
const report = generateJsonReport(result, { totalTicks, decisionInterval, strategy, seed });
const outPath = resolve(__dirname, '..', 'balance-report.json');
writeFileSync(outPath, JSON.stringify(report, null, 2));
console.log(`Report written to ${outPath}`);
printConsoleReport(result, { totalTicks, decisionInterval, strategy, seed }, verbose);
if (!report.passed) {
console.log('FAILED:');
for (const reason of report.failureReasons) {
console.log(` - ${reason}`);
}
process.exit(1);
} else {
console.log('PASSED: All balance checks within thresholds.');
}
} else {
printConsoleReport(result, { totalTicks, decisionInterval, strategy, seed }, verbose);
}
@@ -0,0 +1,436 @@
import type { GameState, Era, RackSkuId } from '@ai-tycoon/shared';
import {
RACK_SKU_CONFIGS, DC_TIER_CONFIGS, COOLING_ORDER,
PARAMETER_OPTIONS, DEFAULT_DATA_MIX,
MAX_CONCURRENT_TRAINING, PRETRAINING_BASE_TICKS,
CLUSTER_COST_CONFIG, LOCATION_CONFIGS, maxComputeRacks,
VRAM_REQUIREMENTS_BY_GENERATION,
} from '@ai-tycoon/shared';
import {
canRaiseFunding, getNextFundingRound, getAvailableResearch, TECH_TREE,
} from '@ai-tycoon/game-engine';
import * as actions from '../actions';
import type { Strategy, SimulationMetrics } from './types';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
const TALENT_TARGETS: Record<Era, Record<string, number>> = {
startup: { research: 5, engineering: 5, operations: 2, sales: 3 },
scaleup: { research: 10, engineering: 10, operations: 4, sales: 8 },
bigtech: { research: 20, engineering: 20, operations: 8, sales: 12 },
agi: { research: 40, engineering: 40, operations: 16, sales: 20 },
};
const RESEARCH_PRIORITY: Record<string, number> = {
'advanced-cooling': 200,
'dc-engineering-ii': 190,
'advanced-gpu-arch': 180,
'alignment-research': 250,
'transformer-v2': 165,
'quantization': 160,
'data-pipeline': 155,
'developer-relations': 150,
'enterprise-sales': 175,
'redundancy-protocols': 140,
'quality-assurance': 130,
'liquid-cooling-tech': 120,
'next-gen-gpu': 115,
'distributed-training': 110,
'inference-optimization': 105,
'dc-engineering-iii': 100,
'code-generation': 170,
'reasoning-enhancement': 90,
'amd-ecosystem': 85,
'infiniband-networking': 80,
'distillation': 75,
'inference-specialization': 70,
'sdk-platform': 65,
'request-batching': 60,
'request-routing': 55,
'code-assistant-product': 168,
'creative-systems': 45,
'multimodal-fusion': 40,
'network-engineering-i': 35,
'rapid-deployment': 30,
'priority-queues': 25,
'interpretability': 180,
'immersion-cooling-tech': 18,
'frontier-compute': 16,
'dc-engineering-iv': 14,
'network-engineering-ii': 12,
'agentic-architecture': 88,
'constitutional-ai': 160,
'network-redundancy': 6,
'auto-scaling': 5,
'agents-platform-product': 86,
'network-fast-repair': 3,
'rack-scale-compute': 2,
'custom-silicon': 1,
'network-hot-standby': 0,
};
function cashSafe(state: GameState, cost: number, runway = 100): boolean {
return state.economy.money - cost > state.economy.expensesPerTick * runway;
}
function getBestAffordableSku(state: GameState): RackSkuId | null {
const era = state.meta.currentEra;
const completed = state.research.completedResearch;
const eligible = (Object.entries(RACK_SKU_CONFIGS) as [RackSkuId, typeof RACK_SKU_CONFIGS[RackSkuId]][])
.filter(([, sku]) => {
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(sku.era)) return false;
if (sku.requiredResearch.length > 0 && !sku.requiredResearch.every(r => completed.includes(r))) return false;
if (state.economy.money < sku.baseCost) return false;
return true;
})
.sort((a, b) => (b[1].trainingFlops / b[1].baseCost) - (a[1].trainingFlops / a[1].baseCost));
return eligible.length > 0 ? eligible[0][0] : null;
}
function getOperationalDCs(state: GameState) {
const results: { dcId: string; coolingType: string; rackSkuId: string | null }[] = [];
for (const cluster of state.infrastructure.clusters) {
for (const campus of cluster.campuses) {
for (const dc of campus.dataCenters) {
if (dc.status === 'operational') {
results.push({ dcId: dc.id, coolingType: dc.coolingType, rackSkuId: dc.rackSkuId });
}
}
}
}
return results;
}
function pickModelParams(state: GameState): number {
const vram = state.infrastructure.totalVramGB;
const era = state.meta.currentEra;
const maxByEra: Record<Era, number> = {
startup: 7,
scaleup: 70,
bigtech: 300,
agi: 1400,
};
const vramPerBillion = 2;
const maxByVram = Math.floor(vram / vramPerBillion);
const cap = Math.min(maxByEra[era], maxByVram);
let best = PARAMETER_OPTIONS[0];
for (const p of PARAMETER_OPTIONS) {
if (p <= cap) best = p;
}
return best;
}
export class GreedyStrategy implements Strategy {
name = 'greedy';
decide(state: GameState, _metrics: SimulationMetrics[]): void {
this.tryRaiseFunding(state);
this.tryBuildInfrastructure(state);
this.tryDeployRacks(state);
this.tryDeployModels(state);
this.tryOpenSourceModel(state);
this.cancelStalledTraining(state);
this.tryStartTraining(state);
this.tryEnableRevenue(state);
this.tryStartResearch(state);
this.tryHireTalent(state);
this.tryUpgradeInfra(state);
this.tryExpandInfra(state);
}
private tryRaiseFunding(state: GameState): void {
const { canRaise, nextRound } = canRaiseFunding(state);
if (canRaise && nextRound) {
actions.raiseFunding(state, nextRound);
}
}
private tryBuildInfrastructure(state: GameState): void {
if (state.infrastructure.clusters.length === 0) {
actions.buildCluster(state, 'Primary', 'us-west');
}
for (const cluster of state.infrastructure.clusters) {
if (cluster.status !== 'operational') continue;
if (cluster.campuses.length === 0) {
actions.buildCampus(state, 'Campus-1', cluster.id, 'small');
}
for (const campus of cluster.campuses) {
if (campus.status !== 'operational') continue;
if (campus.dataCenters.length === 0) {
actions.buildDataCenter(state, 'DC-1', campus.id);
}
}
}
}
private tryDeployRacks(state: GameState): void {
const skuId = getBestAffordableSku(state);
if (!skuId) return;
const sku = RACK_SKU_CONFIGS[skuId];
const operationalDCs = getOperationalDCs(state);
for (const { dcId, coolingType, rackSkuId } of operationalDCs) {
if (rackSkuId !== null && rackSkuId !== skuId) continue;
const coolingOk = COOLING_ORDER.indexOf(sku.requiredCooling) <= COOLING_ORDER.indexOf(coolingType as typeof sku.requiredCooling);
if (!coolingOk) continue;
if (!cashSafe(state, sku.baseCost, 50)) break;
actions.fillDCToCapacity(state, dcId, skuId);
}
}
private tryDeployModels(state: GameState): void {
const undeployed = state.models.baseModels
.filter(m => !m.isDeployed)
.sort((a, b) => b.rawCapability - a.rawCapability);
if (undeployed.length > 0) {
actions.deployModel(state, undeployed[0].id);
}
}
private tryOpenSourceModel(state: GameState): void {
if (state.market.openSourcedModels.length > 0) return;
const deployed = state.models.baseModels.filter(m => m.isDeployed);
if (deployed.length > 0) {
actions.openSourceModel(state, deployed[0].id);
}
}
private cancelStalledTraining(state: GameState): void {
const stalledPipelines = state.models.activeTrainingPipelines.filter(
p => p.status === 'stalled',
);
for (const pipeline of stalledPipelines) {
const stalledTicks = state.meta.tickCount - pipeline.startedAtTick;
if (stalledTicks < 500) continue;
const gen = state.models.families.find(f => f.id === pipeline.familyId)?.generation ?? 1;
const requiredVram = VRAM_REQUIREMENTS_BY_GENERATION[gen] ?? 0;
if (requiredVram > 0 && state.compute.totalVramGB < requiredVram) {
state.models.activeTrainingPipelines = state.models.activeTrainingPipelines.filter(
p => p.id !== pipeline.id,
);
}
}
}
private tryStartTraining(state: GameState): void {
const activeCount = state.models.activeTrainingPipelines.filter(
p => p.status === 'active' || p.status === 'stalled',
).length;
const maxSlots = MAX_CONCURRENT_TRAINING[state.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return;
if (state.infrastructure.totalVramGB <= 0) return;
const gen = state.models.families.length + 1;
const requiredVram = VRAM_REQUIREMENTS_BY_GENERATION[gen] ?? 0;
if (requiredVram > 0 && state.compute.totalVramGB < requiredVram) return;
const params = pickModelParams(state);
const trainingFlops = state.infrastructure.totalTrainingFlops;
const totalTicks = trainingFlops > 0
? Math.max(30, Math.ceil(PRETRAINING_BASE_TICKS / (1 + trainingFlops * 0.1)))
: PRETRAINING_BASE_TICKS;
const targetTokens = params * 20e9;
const hasCodeGen = state.research.completedResearch.includes('code-generation');
const sftSpecs: ('general' | 'code')[] = hasCodeGen ? ['general', 'code'] : ['general'];
const hasAlignment = state.research.completedResearch.includes('alignment-research');
actions.startTrainingPipeline(state, {
familyName: `SimCorp-${gen}`,
architecture: {
type: 'dense',
totalParameters: params,
activeParameters: params,
contextWindow: 32,
vocabularySize: 32000,
},
dataMix: { ...DEFAULT_DATA_MIX },
allocatedComputeFraction: 1.0,
targetTokens,
totalTicks,
sftSpecializations: sftSpecs,
alignmentMethod: hasAlignment ? 'rlhf' : 'dpo',
alignmentSafetyWeight: 0.75,
});
}
private tryEnableRevenue(state: GameState): void {
if (state.models.bestDeployedModelScore <= 0) return;
const ct = state.market.consumerTiers.tiers;
if (!ct.free.config.isActive) actions.toggleConsumerTier(state, 'free');
if (!ct.plus.config.isActive) actions.toggleConsumerTier(state, 'plus');
if (!ct.pro.config.isActive && state.models.bestDeployedModelScore >= 20) {
actions.toggleConsumerTier(state, 'pro');
}
if (!ct.team.config.isActive && state.models.bestDeployedModelScore >= 30) {
actions.toggleConsumerTier(state, 'team');
}
const at = state.market.apiTiers.tiers;
if (!at.free.config.isActive) actions.toggleApiTier(state, 'free');
if (!at.payg.config.isActive) actions.toggleApiTier(state, 'payg');
if (!at.scale.config.isActive && state.models.bestDeployedModelScore >= 25) {
actions.toggleApiTier(state, 'scale');
}
if (!at['enterprise-api'].config.isActive && state.models.bestDeployedModelScore >= 40) {
actions.toggleApiTier(state, 'enterprise-api');
}
if (state.research.completedResearch.includes('code-assistant-product')
&& !state.market.codeAssistant.isActive) {
actions.toggleCodeAssistant(state);
actions.setCodeAssistantPrice(state, 20);
}
if (state.research.completedResearch.includes('agents-platform-product')
&& !state.market.agentsPlatform.isActive) {
actions.toggleAgentsPlatform(state);
actions.setAgentsPlatformPrice(state, 50);
}
}
private tryStartResearch(state: GameState): void {
if (state.research.activeResearch) return;
const available = getAvailableResearch(state);
if (available.length === 0) return;
const sorted = [...available].sort((a, b) => {
const pa = RESEARCH_PRIORITY[a.id] ?? 0;
const pb = RESEARCH_PRIORITY[b.id] ?? 0;
return pb - pa;
});
const best = sorted[0];
actions.startResearch(state, {
researchId: best.id,
progressTicks: 0,
totalTicks: best.cost.ticks,
allocatedResearchers: 0,
allocatedCompute: 0,
});
}
private tryHireTalent(state: GameState): void {
const targets = TALENT_TARGETS[state.meta.currentEra];
const depts = state.talent.departments;
for (const [dept, target] of Object.entries(targets)) {
const current = depts[dept as keyof typeof depts].headcount;
if (current < target) {
const needed = Math.min(target - current, 3);
const cost = needed * 2000;
if (cashSafe(state, cost, 200)) {
actions.hireDepartment(state, dept as actions.DepartmentId, needed);
}
}
}
}
private tryUpgradeInfra(state: GameState): void {
for (const cluster of state.infrastructure.clusters) {
for (const campus of cluster.campuses) {
for (const dc of campus.dataCenters) {
if (dc.status !== 'operational') continue;
if (dc.coolingType === 'air'
&& state.research.completedResearch.includes('liquid-cooling-tech')
&& cashSafe(state, 500_000)) {
actions.upgradeCoolingType(state, dc.id, 'liquid');
}
if (dc.coolingType === 'liquid'
&& state.research.completedResearch.includes('immersion-cooling-tech')
&& cashSafe(state, 1_000_000)) {
actions.upgradeCoolingType(state, dc.id, 'immersion');
}
if (dc.networkFabric === 'ethernet-100g'
&& cashSafe(state, 200_000)) {
actions.upgradeNetworkFabric(state, dc.id, 'ethernet-400g');
}
if (dc.networkFabric === 'ethernet-400g'
&& state.research.completedResearch.includes('infiniband-networking')
&& cashSafe(state, 500_000)) {
actions.upgradeNetworkFabric(state, dc.id, 'infiniband-ndr');
}
}
}
}
}
private tryExpandInfra(state: GameState): void {
const era = state.meta.currentEra;
for (const cluster of state.infrastructure.clusters) {
if (cluster.status !== 'operational') continue;
for (const campus of cluster.campuses) {
if (campus.status !== 'operational') continue;
const allFull = campus.dataCenters.length > 0 && campus.dataCenters.every(dc => {
if (dc.status !== 'operational') return true;
const tierConfig = DC_TIER_CONFIGS[dc.tier];
const mc = maxComputeRacks(tierConfig.rackSlots, dc.tier);
const existing = dc.computeRacksOnline + actions.pipelineCount(dc);
return existing >= mc;
});
if (allFull && campus.dataCenters.length > 0) {
const tierConfig = DC_TIER_CONFIGS[campus.dcTier];
if (cashSafe(state, tierConfig.baseCost, 300)) {
actions.addDCsToCampus(state, campus.id, 1);
}
}
}
}
if (ERA_ORDER.indexOf(era) >= ERA_ORDER.indexOf('scaleup')) {
const targetTier = state.research.completedResearch.includes('dc-engineering-iii') ? 'large' as const
: state.research.completedResearch.includes('dc-engineering-ii') ? 'medium' as const
: 'small' as const;
for (const cluster of state.infrastructure.clusters) {
if (cluster.status !== 'operational') continue;
const hasHighTierCampus = cluster.campuses.some(c => c.dcTier === targetTier);
if (!hasHighTierCampus && cashSafe(state, 2_000_000, 300)) {
actions.buildCampus(state, `${targetTier}-Campus`, cluster.id, targetTier);
}
}
}
if (ERA_ORDER.indexOf(era) >= ERA_ORDER.indexOf('scaleup')) {
const usedLocations = new Set(state.infrastructure.clusters.map(c => c.locationId));
const candidates: ('eu-north' | 'us-east')[] = ['eu-north', 'us-east'];
for (const loc of candidates) {
if (!usedLocations.has(loc)) {
const locConfig = LOCATION_CONFIGS[loc];
if (ERA_ORDER.indexOf(era) >= ERA_ORDER.indexOf(locConfig.availableAt)) {
if (cashSafe(state, CLUSTER_COST_CONFIG.baseCost, 500)) {
actions.buildCluster(state, `Cluster-${loc}`, loc);
break;
}
}
}
}
}
}
}
@@ -0,0 +1,97 @@
import type { GameState } from '@ai-tycoon/shared';
import { RACK_SKU_CONFIGS, DEFAULT_DATA_MIX, PARAMETER_OPTIONS } from '@ai-tycoon/shared';
import { canRaiseFunding, getNextFundingRound, getAvailableResearch } from '@ai-tycoon/game-engine';
import * as actions from '../actions';
import type { Strategy, SimulationMetrics } from './types';
export class RandomStrategy implements Strategy {
name = 'random';
decide(state: GameState, _metrics: SimulationMetrics[]): void {
const candidates: (() => void)[] = [];
const { canRaise, nextRound } = canRaiseFunding(state);
if (canRaise && nextRound) {
candidates.push(() => actions.raiseFunding(state, nextRound));
}
if (state.infrastructure.clusters.length === 0) {
candidates.push(() => actions.buildCluster(state, 'Cluster-1', 'us-west'));
}
for (const cluster of state.infrastructure.clusters) {
if (cluster.status !== 'operational') continue;
if (cluster.campuses.length < 3) {
candidates.push(() => actions.buildCampus(state, 'Campus', cluster.id, 'small'));
}
for (const campus of cluster.campuses) {
if (campus.status !== 'operational') continue;
if (campus.dataCenters.length < 5) {
candidates.push(() => actions.buildDataCenter(state, 'DC', campus.id));
}
for (const dc of campus.dataCenters) {
if (dc.status !== 'operational') continue;
const skuIds = Object.keys(RACK_SKU_CONFIGS) as (keyof typeof RACK_SKU_CONFIGS)[];
const randomSku = skuIds[Math.floor(Math.random() * skuIds.length)];
candidates.push(() => actions.fillDCToCapacity(state, dc.id, randomSku));
}
}
}
if (!state.research.activeResearch) {
const available = getAvailableResearch(state);
if (available.length > 0) {
const pick = available[Math.floor(Math.random() * available.length)];
candidates.push(() => actions.startResearch(state, {
researchId: pick.id,
progressTicks: 0,
totalTicks: pick.cost.ticks,
allocatedResearchers: 0,
allocatedCompute: 0,
}));
}
}
const undeployed = state.models.baseModels.filter(m => !m.isDeployed);
if (undeployed.length > 0) {
candidates.push(() => actions.deployModel(state, undeployed[0].id));
}
if (state.infrastructure.totalVramGB > 0) {
const params = PARAMETER_OPTIONS[Math.floor(Math.random() * PARAMETER_OPTIONS.length)];
candidates.push(() => actions.startTrainingPipeline(state, {
familyName: `Rand-${state.models.families.length + 1}`,
architecture: {
type: 'dense',
totalParameters: params,
activeParameters: params,
contextWindow: 32,
vocabularySize: 32000,
},
dataMix: { ...DEFAULT_DATA_MIX },
allocatedComputeFraction: 1.0,
targetTokens: params * 20e9,
totalTicks: Math.ceil(params * 2 + 60),
sftSpecializations: ['general'],
alignmentMethod: 'dpo',
alignmentSafetyWeight: 0.5,
}));
}
const depts: actions.DepartmentId[] = ['research', 'engineering', 'operations', 'sales'];
const dept = depts[Math.floor(Math.random() * depts.length)];
candidates.push(() => actions.hireDepartment(state, dept, 1));
if (state.models.bestDeployedModelScore > 0) {
candidates.push(() => {
actions.toggleConsumerTier(state, 'free');
actions.toggleApiTier(state, 'free');
});
}
if (candidates.length > 0) {
const pick = Math.floor(Math.random() * candidates.length);
candidates[pick]();
}
}
}
@@ -0,0 +1,60 @@
import type { GameState } from '@ai-tycoon/shared';
export interface SimulationMetrics {
tick: number;
era: string;
money: number;
revenue: number;
totalRevenue: number;
expensesPerTick: number;
bestModelCapability: number;
reputation: number;
subscribers: number;
developers: number;
totalFlops: number;
totalTrainingFlops: number;
researchCount: number;
headcount: number;
modelsDeployed: number;
// Reputation breakdown
safetyRecord: number;
publicPerception: number;
employeeSatisfaction: number;
regulatoryStanding: number;
// Cash flow
netCashFlow: number;
// Infrastructure utilization
tokensPerSecondCapacity: number;
tokensPerSecondDemand: number;
inferenceUtilization: number;
// Training pipeline
activeTrainingPipelines: number;
bestPipelineProgress: number;
// Revenue breakdown
subscriptionRevenue: number;
apiTokenRevenue: number;
enterpriseRevenue: number;
// Talent breakdown
researchHeadcount: number;
engineeringHeadcount: number;
operationsHeadcount: number;
salesHeadcount: number;
// Feature activation counts
completedResearchIds: string[];
activeConsumerTiers: number;
activeApiTiers: number;
enterpriseContracts: number;
fundingRoundsCompleted: number;
}
export interface Strategy {
name: string;
decide(state: GameState, metrics: SimulationMetrics[]): void;
}
+54
View File
@@ -0,0 +1,54 @@
import { runSimulation } from './runner';
import { generateJsonReport } from './analysis/report';
import { GreedyStrategy } from './strategies/greedy';
import { RandomStrategy } from './strategies/random';
const args = process.argv.slice(2);
function getArg(name: string, defaultValue: string): string {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
}
const strategyName = getArg('strategy', 'greedy');
const totalTicks = parseInt(getArg('ticks', '28800'), 10);
const seed = parseInt(getArg('seed', '0'), 10);
const runId = parseInt(getArg('run-id', '1'), 10);
const decisionInterval = 60;
const strategy = strategyName === 'random' ? new RandomStrategy() : new GreedyStrategy();
process.stderr.write(`[Run #${runId}] Starting (seed ${seed}, ${totalTicks} ticks, ${strategyName})...\n`);
const result = runSimulation({ totalTicks, decisionInterval, strategy, seed, verbose: false, silent: true });
const report = generateJsonReport(result, { totalTicks, decisionInterval, strategy, seed });
const output = {
runId,
seed,
passed: report.passed,
failureReasons: report.failureReasons,
wallTimeMs: result.wallTimeMs,
eraTransitions: report.eraTransitions,
finalMetrics: report.finalMetrics,
featureUtilization: {
coverageByCategory: report.featureUtilization.coverageByCategory,
unusedFeatures: report.featureUtilization.unusedFeatures,
revenueStreamDiversity: report.featureUtilization.revenueStreamDiversity,
},
systemInterconnections: {
connections: report.systemInterconnections.connections,
overallScore: report.systemInterconnections.overallScore,
},
cashFlow: {
bankruptcyRisks: report.cashFlow.bankruptcyRisks.length,
},
sanityChecks: {
passed: report.sanityChecks.passed,
errorCount: report.sanityChecks.violations.filter(v => v.severity === 'error').length,
},
metrics: result.metrics,
};
process.stdout.write(JSON.stringify(output));
process.stderr.write(`[Run #${runId}] Done in ${(result.wallTimeMs / 1000).toFixed(1)}s — ${report.passed ? 'PASSED' : 'FAILED'}\n`);
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ai-tycoon/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+31 -28
View File
@@ -27,7 +27,7 @@ export const CAPABILITY_FORMULA = {
efficiencyWeight: 0.1,
};
export const PRETRAINING_BASE_TICKS = 180;
export const PRETRAINING_BASE_TICKS = 90;
export const SFT_TIME_FRACTION = 0.10;
export const SFT_COMPUTE_FRACTION = 0.06;
export const ALIGNMENT_TIME_FRACTION = 0.08;
@@ -97,12 +97,12 @@ export const SFT_SPECIALIZATION_BONUSES: Record<string, Record<string, number>>
'tool-use': { reasoning: 0, coding: 8, creative: 0, math: 0, knowledge: 0, multimodal: 0, agents: 15, speed: -5, contextUtilization: 0 },
};
export const CONSUMER_BASE_GROWTH = 0.002;
export const CONSUMER_BASE_GROWTH = 0.005;
export const CONSUMER_QUALITY_GROWTH_MULTIPLIER = 0.01;
export const CONSUMER_PRICE_ELASTICITY = -0.5;
export const CONSUMER_BASE_CHURN = 0.001;
export const CONSUMER_TOKENS_PER_SUBSCRIBER = 0.5;
export const CONSUMER_TOKENS_PER_SUBSCRIBER = 2.0;
export const API_TOKENS_PER_REQUEST = 500;
export const API_REVENUE_PER_MTOK = 1.0;
@@ -149,9 +149,9 @@ export const BASE_LATENCY_MS = 50;
export const QUEUE_LATENCY_MS_PER_PERCENT = 5;
export const ERA_THRESHOLDS = {
scaleup: { revenue: 10_000, capability: 15, reputation: 30 },
bigtech: { revenue: 1_000_000, capability: 50, reputation: 60 },
agi: { revenue: 100_000_000, capability: 90, reputation: 70 },
scaleup: { revenue: 5_000, capability: 10, reputation: 40 },
bigtech: { revenue: 10_000_000, capability: 55, reputation: 65 },
agi: { revenue: 1_000_000_000, capability: 93, reputation: 80 },
};
// --- Data Center Tier Configs ---
@@ -162,12 +162,12 @@ export const DC_TIER_CONFIGS: Record<DCTier, DCTierConfig> = {
name: 'Small Data Center',
rackSlots: 200,
powerBudgetKW: 1_000,
baseCost: 500_000,
baseCost: 250_000,
buildTimeTicks: 600,
firstBuildTimeTicks: 30,
firstBuildTimeTicks: 20,
requiredEra: 'startup',
requiredResearch: null,
baseEnergyCostPerTick: 50,
baseEnergyCostPerTick: 15,
},
medium: {
tier: 'medium',
@@ -392,7 +392,7 @@ export const RACK_SKU_CONFIGS: Record<RackSkuId, RackSkuConfig> = {
powerDrawKW: 0.4,
baseCost: 3_200,
requiredResearch: [],
pipelineTimeTicks: { manufacturing: 20, receiving: 10, installation: 15, testing: 15 },
pipelineTimeTicks: { manufacturing: 15, receiving: 8, installation: 10, testing: 10 },
testFailureRate: 0.05,
productionFailureRate: 0.0002,
repairCostFraction: 0.10,
@@ -414,7 +414,7 @@ export const RACK_SKU_CONFIGS: Record<RackSkuId, RackSkuConfig> = {
powerDrawKW: 0.5,
baseCost: 12_000,
requiredResearch: [],
pipelineTimeTicks: { manufacturing: 30, receiving: 15, installation: 25, testing: 20 },
pipelineTimeTicks: { manufacturing: 20, receiving: 10, installation: 15, testing: 15 },
testFailureRate: 0.07,
productionFailureRate: 0.0003,
repairCostFraction: 0.12,
@@ -436,7 +436,7 @@ export const RACK_SKU_CONFIGS: Record<RackSkuId, RackSkuConfig> = {
powerDrawKW: 1.0,
baseCost: 22_000,
requiredResearch: [],
pipelineTimeTicks: { manufacturing: 40, receiving: 20, installation: 30, testing: 30 },
pipelineTimeTicks: { manufacturing: 25, receiving: 12, installation: 20, testing: 18 },
testFailureRate: 0.08,
productionFailureRate: 0.0003,
repairCostFraction: 0.12,
@@ -801,8 +801,8 @@ export const DC_UPGRADE_INCREMENT = 0.1;
export const COHORT_SCALE_FACTOR = 0.0003;
export const FUNDING_ROUNDS = {
seed: { amount: 500_000, dilution: 0.10, requirements: { minRevenue: 500, minUsers: 0, minReputation: 0 } },
seriesA: { amount: 2_000_000, dilution: 0.15, requirements: { minRevenue: 2_500, minUsers: 100, minReputation: 20 } },
seed: { amount: 500_000, dilution: 0.10, requirements: { minRevenue: 100, minUsers: 0, minReputation: 0 } },
seriesA: { amount: 2_000_000, dilution: 0.15, requirements: { minRevenue: 1_000, minUsers: 50, minReputation: 20 } },
seriesB: { amount: 10_000_000, dilution: 0.12, requirements: { minRevenue: 25_000, minUsers: 1_000, minReputation: 30 } },
seriesC: { amount: 50_000_000, dilution: 0.10, requirements: { minRevenue: 250_000, minUsers: 10_000, minReputation: 40 } },
seriesD: { amount: 200_000_000, dilution: 0.08, requirements: { minRevenue: 2_500_000, minUsers: 50_000, minReputation: 50 } },
@@ -818,6 +818,9 @@ export const REGULATION_COMPLIANCE_PER_CAPABILITY = 50;
export const SAFETY_INCIDENT_PROBABILITY_BASE = 0.0002;
export const SAFETY_INCIDENT_REPUTATION_HIT = 15;
export const LOW_SAFETY_THRESHOLD = 40;
export const MODEL_BASE_SAFETY = 40;
export const SAFETY_RECORD_RECOVERY_RATE = 0.02;
export const PUBLIC_PERCEPTION_GROWTH_RATE = 0.3;
// ========================================================================
// MARKET SYSTEM v2 — Shared TAM, Tiered Products, Enterprise Pipeline
@@ -826,14 +829,14 @@ export const LOW_SAFETY_THRESHOLD = 40;
// --- Shared TAM ---
export const TAM_BASE_SIZES: Record<Era, Record<TAMSegmentId, number>> = {
startup: { consumer: 50_000, developer: 5_000, enterprise: 500, government: 50 },
startup: { consumer: 200_000, developer: 20_000, enterprise: 500, government: 50 },
scaleup: { consumer: 5_000_000, developer: 200_000, enterprise: 5_000, government: 500 },
bigtech: { consumer: 50_000_000, developer: 2_000_000, enterprise: 50_000, government: 5_000 },
agi: { consumer: 500_000_000, developer: 20_000_000, enterprise: 200_000, government: 20_000 },
};
export const TAM_GROWTH_PER_TICK = 0.0001;
export const TAM_GROWTH_PER_TICK = 0.0003;
export const SHARE_TEMPERATURE = 4.0;
export const SHARE_MIGRATION_SPEED = 0.03;
export const SHARE_MIGRATION_SPEED = 0.05;
// --- Attractiveness Weights ---
@@ -856,7 +859,7 @@ export const CONSUMER_TIER_DEFAULTS: Record<ConsumerTierId, { price: number; tok
export const CONSUMER_TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
export const CONVERSION_RATES: Record<string, number> = {
'free->plus': 0.002,
'free->plus': 0.008,
'plus->pro': 0.0008,
'pro->team': 0.0003,
};
@@ -868,7 +871,7 @@ export const TIER_CHURN_RATES: Record<ConsumerTierId, number> = {
team: 0.0004,
};
export const FREE_TIER_ADOPTION_RATE = 0.05;
export const FREE_TIER_ADOPTION_RATE = 0.10;
// --- API Tier Defaults ---
@@ -889,14 +892,14 @@ export const API_TIER_CHURN_RATES: Record<ApiTierId, number> = {
};
export const API_CONVERSION_RATES: Record<string, number> = {
'free->payg': 0.003,
'free->payg': 0.010,
'payg->scale': 0.001,
'scale->enterprise-api': 0.0004,
};
export const API_TOKENS_PER_DEVELOPER_PER_TICK: Record<ApiTierId, number> = {
free: 0.5,
payg: 5,
free: 1.0,
payg: 10,
scale: 50,
'enterprise-api': 200,
};
@@ -917,7 +920,7 @@ export const AGENTS_PLATFORM_CHURN_RATE = 0.0005;
// --- Enterprise Pipeline ---
export const BASE_LEAD_RATE = 0.005;
export const BASE_LEAD_RATE = 0.02;
export const LEAD_EXPIRY_TICKS = 600;
export const PIPELINE_STAGE_TIMEOUTS: Record<EnterprisePipelineStage, number> = {
@@ -928,10 +931,10 @@ export const PIPELINE_STAGE_TIMEOUTS: Record<EnterprisePipelineStage, number> =
};
export const PIPELINE_TRANSITION_RATES: Record<string, number> = {
'lead->qualification': 0.02,
'qualification->poc': 0.015,
'poc->negotiation': 0.01,
'negotiation->active': 0.008,
'lead->qualification': 0.04,
'qualification->poc': 0.03,
'poc->negotiation': 0.02,
'negotiation->active': 0.015,
};
export const SLA_PENALTY_FRACTION = 0.02;
@@ -974,7 +977,7 @@ export const CONTRACT_DURATION_BY_SEGMENT: Record<EnterpriseSegment, number> = {
// --- Developer Ecosystem ---
export const BASE_DEV_GROWTH = 0.001;
export const BASE_DEV_GROWTH = 0.003;
export const FREE_TIER_DEV_MULTIPLIER = 0.0005;
export const OPEN_SOURCE_DEV_BOOST = 0.05;
export const DEV_REL_EFFECTIVENESS = 0.00001;
+1 -1
View File
@@ -41,7 +41,7 @@ export const INITIAL_TALENT: TalentState = {
research: { id: 'research', headcount: 2, budget: 5_000, effectiveness: 0.5, morale: 0.8 },
engineering: { id: 'engineering', headcount: 3, budget: 7_000, effectiveness: 0.5, morale: 0.8 },
operations: { id: 'operations', headcount: 1, budget: 3_000, effectiveness: 0.5, morale: 0.8 },
sales: { id: 'sales', headcount: 0, budget: 0, effectiveness: 0, morale: 0.8 },
sales: { id: 'sales', headcount: 0, budget: 0, effectiveness: 0.5, morale: 0.8 },
},
keyHires: [],
hiringPipeline: [],