diff --git a/.gitea/workflows/balance-check.yml b/.gitea/workflows/balance-check.yml new file mode 100644 index 0000000..1336851 --- /dev/null +++ b/.gitea/workflows/balance-check.yml @@ -0,0 +1,82 @@ +name: Balance Check + +on: + push: + branches: [main] + paths: + - 'packages/shared/src/constants/**' + - 'packages/game-engine/src/**' + - 'packages/game-simulation/**' + pull_request: + branches: [main] + paths: + - 'packages/shared/src/constants/**' + - 'packages/game-engine/src/**' + - 'packages/game-simulation/**' + +jobs: + balance-simulation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run greedy simulation + run: pnpm --filter @ai-tycoon/game-simulation simulate:ci + + - name: Run random simulation + if: always() + run: | + cd packages/game-simulation + npx tsx src/simulate.ts --strategy random --ticks 28800 --json --seed 42 + + - name: Upload balance reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: balance-reports + path: | + packages/game-simulation/balance-report*.json + packages/game-simulation/balance-metrics*.csv + + multi-run-balance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run multi-simulation (5 runs) + run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 5 --parallel 2 --strategy greedy --ticks 28800 --no-timeseries + + - name: Interpret results + if: always() + run: pnpm --filter @ai-tycoon/game-simulation interpret -- --summary packages/game-simulation/multirun-summary.csv + + - name: Upload multi-run reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: multirun-reports + path: packages/game-simulation/multirun-*.csv diff --git a/.gitignore b/.gitignore index 8edc0e4..08e06b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist/ .env.local .DS_Store .claude/ +balance-report*.json +balance-metrics*.csv +multirun-summary.csv +multirun-timeseries.csv diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index f0bdcbd..920e63c 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -12,6 +12,7 @@ import { SIZE_TIER_MAP, SIZE_TIER_LABELS, SFT_SPECIALIZATION_BONUSES, + PRETRAINING_BASE_TICKS, } from '@ai-tycoon/shared'; import type { ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod, @@ -90,7 +91,7 @@ export function ModelsPage() { const [safetyWeight, setSafetyWeight] = useState(0.5); const trainingFlops = totalFlops * trainingAlloc; - const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(180 / (1 + trainingFlops * 0.1))) : Infinity; + const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(PRETRAINING_BASE_TICKS / (1 + trainingFlops * 0.1))) : Infinity; const estimatedCapability = Math.min(95, Math.sqrt(trainingFlops) * 5 + Math.log10(1 + totalData / 1e8) * 10); const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled'); diff --git a/package.json b/package.json index a6b3f8b..6253434 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "build": "turbo build", "typecheck": "turbo typecheck", "lint": "turbo lint", - "clean": "turbo clean" + "clean": "turbo clean", + "simulate": "turbo simulate --filter=@ai-tycoon/game-simulation", + "simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci" }, "devDependencies": { "turbo": "^2.5.0", diff --git a/packages/game-engine/src/index.ts b/packages/game-engine/src/index.ts index 35031b4..4340480 100644 --- a/packages/game-engine/src/index.ts +++ b/packages/game-engine/src/index.ts @@ -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'; diff --git a/packages/game-engine/src/systems/competitorSystem.ts b/packages/game-engine/src/systems/competitorSystem.ts index c1a39a4..07b8700 100644 --- a/packages/game-engine/src/systems/competitorSystem.ts +++ b/packages/game-engine/src/systems/competitorSystem.ts @@ -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 }; } diff --git a/packages/game-engine/src/systems/infrastructureSystem.ts b/packages/game-engine/src/systems/infrastructureSystem.ts index b2d81dc..420a97a 100644 --- a/packages/game-engine/src/systems/infrastructureSystem.ts +++ b/packages/game-engine/src/systems/infrastructureSystem.ts @@ -358,22 +358,19 @@ function processNetworkTick( repairSpeedBonus: number, hotStandbyTicks: number, redundancyBonus: number, -): { switchRepairCosts: number; notifications: TickNotification[]; dirty: boolean } { +): { switchRepairCosts: number; notifications: TickNotification[]; dirtyDCs: Set } { const notifications: TickNotification[] = []; let switchRepairCosts = 0; - let dirty = false; + const dirtyDCs = new Set(); const healthyByTier: Partial> = {}; 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 = {}; - 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, ); diff --git a/packages/game-engine/src/systems/market/enterprisePipeline.ts b/packages/game-engine/src/systems/market/enterprisePipeline.ts index b455cdf..415a600 100644 --- a/packages/game-engine/src/systems/market/enterprisePipeline.ts +++ b/packages/game-engine/src/systems/market/enterprisePipeline.ts @@ -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; diff --git a/packages/game-engine/src/systems/market/servingPipeline.ts b/packages/game-engine/src/systems/market/servingPipeline.ts index 8649092..043b9c5 100644 --- a/packages/game-engine/src/systems/market/servingPipeline.ts +++ b/packages/game-engine/src/systems/market/servingPipeline.ts @@ -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(); + 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) { diff --git a/packages/game-engine/src/systems/market/tamSystem.ts b/packages/game-engine/src/systems/market/tamSystem.ts index 0da53b0..7f93887 100644 --- a/packages/game-engine/src/systems/market/tamSystem.ts +++ b/packages/game-engine/src/systems/market/tamSystem.ts @@ -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(n); + const targetShares = new Array(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(); 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 }; diff --git a/packages/game-engine/src/systems/modelSystem.ts b/packages/game-engine/src/systems/modelSystem.ts index cbd90a1..7e45b51 100644 --- a/packages/game-engine/src/systems/modelSystem.ts +++ b/packages/game-engine/src/systems/modelSystem.ts @@ -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) { diff --git a/packages/game-engine/src/systems/reputationSystem.ts b/packages/game-engine/src/systems/reputationSystem.ts index 10d429d..b553a08 100644 --- a/packages/game-engine/src/systems/reputationSystem.ts +++ b/packages/game-engine/src/systems/reputationSystem.ts @@ -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 + diff --git a/packages/game-engine/src/systems/researchBonuses.ts b/packages/game-engine/src/systems/researchBonuses.ts index ab56874..1a28561 100644 --- a/packages/game-engine/src/systems/researchBonuses.ts +++ b/packages/game-engine/src/systems/researchBonuses.ts @@ -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; +} diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts index 54c34a7..6488e1e 100644 --- a/packages/game-engine/src/tick.ts +++ b/packages/game-engine/src/tick.ts @@ -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; @@ -38,10 +39,11 @@ export function processTick(state: GameState): Partial { 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 { 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 { 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 { ); } 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 { 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 { }); } - 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) { diff --git a/packages/game-simulation/package.json b/packages/game-simulation/package.json new file mode 100644 index 0000000..51141cc --- /dev/null +++ b/packages/game-simulation/package.json @@ -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" + } +} diff --git a/packages/game-simulation/src/actions/competitors.ts b/packages/game-simulation/src/actions/competitors.ts new file mode 100644 index 0000000..8825b6c --- /dev/null +++ b/packages/game-simulation/src/actions/competitors.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/data.ts b/packages/game-simulation/src/actions/data.ts new file mode 100644 index 0000000..067891c --- /dev/null +++ b/packages/game-simulation/src/actions/data.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/funding.ts b/packages/game-simulation/src/actions/funding.ts new file mode 100644 index 0000000..d08356a --- /dev/null +++ b/packages/game-simulation/src/actions/funding.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/ids.ts b/packages/game-simulation/src/actions/ids.ts new file mode 100644 index 0000000..b92aff1 --- /dev/null +++ b/packages/game-simulation/src/actions/ids.ts @@ -0,0 +1,9 @@ +let counter = 0; + +export function simId(): string { + return `sim-${++counter}`; +} + +export function resetIds(): void { + counter = 0; +} diff --git a/packages/game-simulation/src/actions/index.ts b/packages/game-simulation/src/actions/index.ts new file mode 100644 index 0000000..a6d7148 --- /dev/null +++ b/packages/game-simulation/src/actions/index.ts @@ -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'; diff --git a/packages/game-simulation/src/actions/infrastructure.ts b/packages/game-simulation/src/actions/infrastructure.ts new file mode 100644 index 0000000..b4cb3c8 --- /dev/null +++ b/packages/game-simulation/src/actions/infrastructure.ts @@ -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 }; diff --git a/packages/game-simulation/src/actions/market.ts b/packages/game-simulation/src/actions/market.ts new file mode 100644 index 0000000..1a94421 --- /dev/null +++ b/packages/game-simulation/src/actions/market.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/models.ts b/packages/game-simulation/src/actions/models.ts new file mode 100644 index 0000000..069b30a --- /dev/null +++ b/packages/game-simulation/src/actions/models.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/research.ts b/packages/game-simulation/src/actions/research.ts new file mode 100644 index 0000000..7a3e9eb --- /dev/null +++ b/packages/game-simulation/src/actions/research.ts @@ -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; +} diff --git a/packages/game-simulation/src/actions/talent.ts b/packages/game-simulation/src/actions/talent.ts new file mode 100644 index 0000000..184026a --- /dev/null +++ b/packages/game-simulation/src/actions/talent.ts @@ -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; +} diff --git a/packages/game-simulation/src/analysis/breakpoints.ts b/packages/game-simulation/src/analysis/breakpoints.ts new file mode 100644 index 0000000..9ca6fe8 --- /dev/null +++ b/packages/game-simulation/src/analysis/breakpoints.ts @@ -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; +} diff --git a/packages/game-simulation/src/analysis/cashFlow.ts b/packages/game-simulation/src/analysis/cashFlow.ts new file mode 100644 index 0000000..b0c1258 --- /dev/null +++ b/packages/game-simulation/src/analysis/cashFlow.ts @@ -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; +} + +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(); + + 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 = {}; + 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 }; +} diff --git a/packages/game-simulation/src/analysis/deadZones.ts b/packages/game-simulation/src/analysis/deadZones.ts new file mode 100644 index 0000000..9b0f1d3 --- /dev/null +++ b/packages/game-simulation/src/analysis/deadZones.ts @@ -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 }; diff --git a/packages/game-simulation/src/analysis/eraProximity.ts b/packages/game-simulation/src/analysis/eraProximity.ts new file mode 100644 index 0000000..2857be1 --- /dev/null +++ b/packages/game-simulation/src/analysis/eraProximity.ts @@ -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; +} + +export interface EraProximityResult { + snapshots: EraProximitySnapshot[]; + ceilings: EraCeiling[]; + mathCeilings: MathCeiling[]; + perEraBottleneck: Record; +} + +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 = {}; + + const ceilingTrackers = new Map(); + + 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 }; +} diff --git a/packages/game-simulation/src/analysis/featureUtilization.ts b/packages/game-simulation/src/analysis/featureUtilization.ts new file mode 100644 index 0000000..f09887e --- /dev/null +++ b/packages/game-simulation/src/analysis/featureUtilization.ts @@ -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; + 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(); + const dcTiers = new Set(); + const coolingTypes = new Set(); + const networkFabrics = new Set(); + const locations = new Set(); + + 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(); + const paramSizes = new Set(); + const sftSpecs = new Set(); + const alignmentMethods = new Set(); + 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(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 = {}; + 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 }; +} diff --git a/packages/game-simulation/src/analysis/growthRates.ts b/packages/game-simulation/src/analysis/growthRates.ts new file mode 100644 index 0000000..9a45872 --- /dev/null +++ b/packages/game-simulation/src/analysis/growthRates.ts @@ -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 }; +} diff --git a/packages/game-simulation/src/analysis/metrics.ts b/packages/game-simulation/src/analysis/metrics.ts new file mode 100644 index 0000000..3fa1e91 --- /dev/null +++ b/packages/game-simulation/src/analysis/metrics.ts @@ -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, + }; +} diff --git a/packages/game-simulation/src/analysis/milestones.ts b/packages/game-simulation/src/analysis/milestones.ts new file mode 100644 index 0000000..093bde3 --- /dev/null +++ b/packages/game-simulation/src/analysis/milestones.ts @@ -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(); + + 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; +} diff --git a/packages/game-simulation/src/analysis/report.ts b/packages/game-simulation/src/analysis/report.ts new file mode 100644 index 0000000..f32b1d7 --- /dev/null +++ b/packages/game-simulation/src/analysis/report.ts @@ -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 = { + 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, + }; +} diff --git a/packages/game-simulation/src/analysis/sanityChecks.ts b/packages/game-simulation/src/analysis/sanityChecks.ts new file mode 100644 index 0000000..4126e1b --- /dev/null +++ b/packages/game-simulation/src/analysis/sanityChecks.ts @@ -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(); + + 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'), + }; +} diff --git a/packages/game-simulation/src/analysis/systemInterconnections.ts b/packages/game-simulation/src/analysis/systemInterconnections.ts new file mode 100644 index 0000000..7d8bb2a --- /dev/null +++ b/packages/game-simulation/src/analysis/systemInterconnections.ts @@ -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 }; +} diff --git a/packages/game-simulation/src/initialState.ts b/packages/game-simulation/src/initialState.ts new file mode 100644 index 0000000..da2a826 --- /dev/null +++ b/packages/game-simulation/src/initialState.ts @@ -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), + }; +} diff --git a/packages/game-simulation/src/interpret.ts b/packages/game-simulation/src/interpret.ts new file mode 100644 index 0000000..f25aa8b --- /dev/null +++ b/packages/game-simulation/src/interpret.ts @@ -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 [--out ]'); + 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; + interconnectionOverall: number; + interconnections: Record; + 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 = {}; + const icLinks: Record = {}; + + 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 = {}; + for (const r of rows) { + if (!r.failureReasons) continue; + const seen = new Set(); + 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); +} diff --git a/packages/game-simulation/src/multirun.ts b/packages/game-simulation/src/multirun.ts new file mode 100644 index 0000000..8cecbd8 --- /dev/null +++ b/packages/game-simulation/src/multirun.ts @@ -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 [--parallel

] [--strategy ] [--ticks ] [--out

] [--seed ] [--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; + 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 { + 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 = { + 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>(); + + async function runOne(runId: number, seed: number): Promise { + 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); +}); diff --git a/packages/game-simulation/src/rng.ts b/packages/game-simulation/src/rng.ts new file mode 100644 index 0000000..d61792a --- /dev/null +++ b/packages/game-simulation/src/rng.ts @@ -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; }, + }; +} diff --git a/packages/game-simulation/src/runner.ts b/packages/game-simulation/src/runner.ts new file mode 100644 index 0000000..881b3ab --- /dev/null +++ b/packages/game-simulation/src/runner.ts @@ -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 | 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)['_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, + }; +} diff --git a/packages/game-simulation/src/simulate.ts b/packages/game-simulation/src/simulate.ts new file mode 100644 index 0000000..c97c698 --- /dev/null +++ b/packages/game-simulation/src/simulate.ts @@ -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); +} diff --git a/packages/game-simulation/src/strategies/greedy.ts b/packages/game-simulation/src/strategies/greedy.ts new file mode 100644 index 0000000..903a0a1 --- /dev/null +++ b/packages/game-simulation/src/strategies/greedy.ts @@ -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> = { + 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 = { + '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 = { + 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; + } + } + } + } + } + } +} + diff --git a/packages/game-simulation/src/strategies/random.ts b/packages/game-simulation/src/strategies/random.ts new file mode 100644 index 0000000..ab05f66 --- /dev/null +++ b/packages/game-simulation/src/strategies/random.ts @@ -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](); + } + } +} diff --git a/packages/game-simulation/src/strategies/types.ts b/packages/game-simulation/src/strategies/types.ts new file mode 100644 index 0000000..d7e7a01 --- /dev/null +++ b/packages/game-simulation/src/strategies/types.ts @@ -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; +} diff --git a/packages/game-simulation/src/worker.ts b/packages/game-simulation/src/worker.ts new file mode 100644 index 0000000..e902568 --- /dev/null +++ b/packages/game-simulation/src/worker.ts @@ -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`); diff --git a/packages/game-simulation/tsconfig.json b/packages/game-simulation/tsconfig.json new file mode 100644 index 0000000..07fbfa6 --- /dev/null +++ b/packages/game-simulation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@ai-tycoon/tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts index 56317c8..553dc28 100644 --- a/packages/shared/src/constants/gameBalance.ts +++ b/packages/shared/src/constants/gameBalance.ts @@ -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> '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 = { 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 = { 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 = { 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 = { 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> = { - 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 = { - '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 = { 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 = { }; export const API_CONVERSION_RATES: Record = { - '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 = { - 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 = { @@ -928,10 +931,10 @@ export const PIPELINE_STAGE_TIMEOUTS: Record = }; export const PIPELINE_TRANSITION_RATES: Record = { - '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 = { // --- 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; diff --git a/packages/shared/src/types/talent.ts b/packages/shared/src/types/talent.ts index aca8783..d539a57 100644 --- a/packages/shared/src/types/talent.ts +++ b/packages/shared/src/types/talent.ts @@ -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: [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88b7e77..9fc78bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,28 @@ importers: specifier: ^5.8.0 version: 5.9.3 + packages/game-simulation: + dependencies: + '@ai-tycoon/game-engine': + specifier: workspace:* + version: link:../game-engine + '@ai-tycoon/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@ai-tycoon/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + packages/shared: devDependencies: '@ai-tycoon/tsconfig': @@ -923,6 +945,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -1567,6 +1592,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -2176,6 +2204,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -2788,6 +2820,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + undici-types@7.19.2: {} update-browserslist-db@1.2.3(browserslist@4.28.2): diff --git a/turbo.json b/turbo.json index 09b5086..9ea9936 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,10 @@ "lint": {}, "clean": { "cache": false + }, + "simulate": { + "dependsOn": ["^build"], + "cache": false } } }