From 900d1d5190905b4b6cdd1aed4f03161e10f73cf2 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 24 Apr 2026 20:50:26 -0400 Subject: [PATCH] Fix compute utilization bug and add subscriber saturation cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined fixes: 1. Zero-capacity utilization: when inference allocation was 0%, the guard clause returned 0% utilization instead of 100%, so the market system never penalized satisfaction and subscribers never churned. 2. Stale compute in market: restructured tick order so capacity is computed before market runs, giving satisfaction calculations current-tick demand/capacity ratio instead of previous tick's. 3. Subscriber growth: replaced pure compound growth (reached billions in minutes) with logistic saturation curve. Era-based market caps: startup 10K, scaleup 1M, bigtech 20M, agi 100M. Quality and reputation expand the effective cap. Also tuned FLOPS-to-tokens multiplier (10 → 26) for balanced demand/capacity feel across all eras, and added market saturation indicator to the Market page. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/pages/MarketPage.tsx | 35 ++++++++- .../game-engine/src/systems/computeSystem.ts | 36 ++++++--- .../game-engine/src/systems/marketSystem.ts | 78 ++++++++++++++----- packages/game-engine/src/tick.ts | 11 +-- packages/shared/src/constants/gameBalance.ts | 15 ++++ 5 files changed, 134 insertions(+), 41 deletions(-) diff --git a/apps/web/src/pages/MarketPage.tsx b/apps/web/src/pages/MarketPage.tsx index ad7e768..58e2cc7 100644 --- a/apps/web/src/pages/MarketPage.tsx +++ b/apps/web/src/pages/MarketPage.tsx @@ -1,5 +1,8 @@ import { useGameStore } from '@/store'; -import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; +import { + formatNumber, formatMoney, formatPercent, + MARKET_SIZE_CAP, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS, +} from '@ai-tycoon/shared'; import { Users, Zap, Shield, TrendingUp, Settings2 } from 'lucide-react'; export function MarketPage() { @@ -10,9 +13,21 @@ export function MarketPage() { const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization); const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity); const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand); + const currentEra = useGameStore((s) => s.meta.currentEra); + const reputationScore = useGameStore((s) => s.reputation.score); + const deployedModels = useGameStore((s) => s.models.trainedModels.filter(m => m.isDeployed)); const setProductPricing = useGameStore((s) => s.setProductPricing); const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy); + const bestQuality = deployedModels.length > 0 + ? Math.max(...deployedModels.map(m => m.benchmarkScore)) / 100 + : 0; + const eraCapBase = MARKET_SIZE_CAP[currentEra] ?? 100_000_000; + const effectiveCap = eraCapBase + * (1 + bestQuality * MARKET_CAP_QUALITY_BONUS) + * (1 + (reputationScore / 100) * MARKET_CAP_REPUTATION_BONUS); + const saturation = effectiveCap > 0 ? consumers.totalSubscribers / effectiveCap : 0; + const chatProduct = productLines.find(p => p.type === 'chat-product'); const textApi = productLines.find(p => p.type === 'text-api'); @@ -20,7 +35,7 @@ export function MarketPage() {

Market

-
+
@@ -55,6 +70,22 @@ export function MarketPage() { {formatNumber(tokensDemand)} / {formatNumber(tokensCapacity)} tok/s
+
+
+ + Market Saturation +
+
{formatPercent(saturation)}
+
+ Cap: {formatNumber(effectiveCap)} ({currentEra}) +
+
+
0.9 ? 'bg-danger' : saturation > 0.7 ? 'bg-warning' : 'bg-accent'}`} + style={{ width: `${Math.min(100, saturation * 100)}%` }} + /> +
+
diff --git a/packages/game-engine/src/systems/computeSystem.ts b/packages/game-engine/src/systems/computeSystem.ts index 4274512..b4b63fe 100644 --- a/packages/game-engine/src/systems/computeSystem.ts +++ b/packages/game-engine/src/systems/computeSystem.ts @@ -1,24 +1,36 @@ import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared'; +import { FLOPS_TO_TOKENS_MULTIPLIER } from '@ai-tycoon/shared'; -export function processCompute(state: GameState, infrastructure: InfrastructureState): ComputeState { +export interface CapacityResult { + totalFlops: number; + trainingAllocation: number; + inferenceAllocation: number; + tokensPerSecondCapacity: number; +} + +export function computeCapacity(state: GameState, infrastructure: InfrastructureState): CapacityResult { const totalFlops = infrastructure.totalFlops; const trainingAllocation = state.compute.trainingAllocation; const inferenceAllocation = 1 - trainingAllocation; - const inferenceFlops = totalFlops * inferenceAllocation; - const tokensPerSecondCapacity = inferenceFlops * 10; + const tokensPerSecondCapacity = inferenceFlops * FLOPS_TO_TOKENS_MULTIPLIER; - const tokensPerSecondDemand = state.compute.tokensPerSecondDemand; - const inferenceUtilization = tokensPerSecondCapacity > 0 - ? Math.min(1, tokensPerSecondDemand / tokensPerSecondCapacity) - : 0; + return { totalFlops, trainingAllocation, inferenceAllocation, tokensPerSecondCapacity }; +} + +export function finalizeCompute(capacity: CapacityResult, totalTokenDemand: number): ComputeState { + const inferenceUtilization = capacity.tokensPerSecondCapacity > 0 + ? Math.min(1, totalTokenDemand / capacity.tokensPerSecondCapacity) + : (totalTokenDemand > 0 ? 1 : 0); return { - totalFlops, - trainingAllocation, - inferenceAllocation, + ...capacity, + tokensPerSecondDemand: totalTokenDemand, inferenceUtilization, - tokensPerSecondCapacity, - tokensPerSecondDemand, }; } + +export function processCompute(state: GameState, infrastructure: InfrastructureState): ComputeState { + const cap = computeCapacity(state, infrastructure); + return finalizeCompute(cap, state.compute.tokensPerSecondDemand); +} diff --git a/packages/game-engine/src/systems/marketSystem.ts b/packages/game-engine/src/systems/marketSystem.ts index 89af9a5..87322f6 100644 --- a/packages/game-engine/src/systems/marketSystem.ts +++ b/packages/game-engine/src/systems/marketSystem.ts @@ -1,11 +1,16 @@ -import type { GameState, MarketState, ComputeState } from '@ai-tycoon/shared'; +import type { GameState, MarketState } from '@ai-tycoon/shared'; import { CONSUMER_BASE_GROWTH, CONSUMER_QUALITY_GROWTH_MULTIPLIER, CONSUMER_BASE_CHURN, + CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_REQUEST, OPEN_SOURCE_REVENUE_PENALTY, OPEN_SOURCE_TALENT_ATTRACTION, + MARKET_SIZE_CAP, + MARKET_CAP_QUALITY_BONUS, + MARKET_CAP_REPUTATION_BONUS, + OVERLOAD_PENALTY_EXPONENT, } from '@ai-tycoon/shared'; export interface MarketTickResult { @@ -15,7 +20,7 @@ export interface MarketTickResult { totalTokenDemand: number; } -export function processMarket(state: GameState, compute: ComputeState): MarketTickResult { +export function processMarket(state: GameState, currentTickCapacity: number): MarketTickResult { const bestModel = state.models.trainedModels .filter(m => m.isDeployed) .sort((a, b) => b.benchmarkScore - a.benchmarkScore)[0]; @@ -33,7 +38,17 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi const fairPrice = 20 + modelQuality * 80; const priceRatio = price / Math.max(1, fairPrice); const priceAttractiveness = Math.max(0, Math.min(1, 1 - (priceRatio - 1) * 0.8)); - const growthRate = (CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER) * priceAttractiveness; + + // --- Logistic growth with era-based market cap --- + const eraCapBase = MARKET_SIZE_CAP[state.meta.currentEra] ?? 100_000_000; + const effectiveCap = eraCapBase + * (1 + modelQuality * MARKET_CAP_QUALITY_BONUS) + * (1 + (state.reputation.score / 100) * MARKET_CAP_REPUTATION_BONUS); + + const saturationFactor = Math.max(0, 1 - consumers.totalSubscribers / effectiveCap); + + const growthRate = (CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER) + * priceAttractiveness * saturationFactor; const priceChurnMultiplier = priceRatio > 1 ? 1 + (priceRatio - 1) * 3 : 1; const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction) * 2) * priceChurnMultiplier; @@ -42,25 +57,51 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi const newSubs = consumers.totalSubscribers * growthRate; const lostSubs = consumers.totalSubscribers * churnRate; - consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs); + consumers.totalSubscribers = Math.max(0, Math.min( + effectiveCap, + consumers.totalSubscribers + newSubs - lostSubs, + )); if (consumers.totalSubscribers < 100 && modelQuality > 0.1 && priceRatio < 3) { consumers.totalSubscribers += 5 + modelQuality * 20; } - const loadPenalty = compute.inferenceUtilization > 0.9 - ? (compute.inferenceUtilization - 0.9) * 5 - : 0; + // --- Satisfaction from demand/capacity ratio (current tick) --- + const consumerDemand = consumers.totalSubscribers * CONSUMER_TOKENS_PER_SUBSCRIBER; + let demandCapacityRatio: number; + if (currentTickCapacity > 0) { + demandCapacityRatio = consumerDemand / currentTickCapacity; + } else { + demandCapacityRatio = consumerDemand > 0 ? 10 : 0; + } + + let headroomBonus = 0; + let overloadPenalty = 0; + if (demandCapacityRatio <= 1) { + headroomBonus = (1 - demandCapacityRatio) * 0.2; + } else { + overloadPenalty = Math.min(1, Math.pow(demandCapacityRatio - 1, OVERLOAD_PENALTY_EXPONENT)); + } + consumers.satisfaction = Math.min(1, Math.max(0, - 0.3 + modelQuality * 0.5 + (1 - Math.min(1, compute.inferenceUtilization)) * 0.2 - loadPenalty, + 0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty, )); consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0; subscriptionRevenue = consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 86400); + + // --- Overload policy --- + const policy = state.market.overloadPolicy; + if (policy.degradeQualityUnderLoad && demandCapacityRatio > 0.85) { + consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.02); + } + if (policy.prioritizeEnterprise && demandCapacityRatio > 0.9) { + consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.01); + } } - // --- B2B API market (organic demand based on model quality + reputation) --- + // --- B2B API market --- const enterprise = { ...state.market.enterprise }; let apiRevenue = 0; let organicApiTokens = 0; @@ -80,29 +121,26 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi apiRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken; } - const totalApiTokens = organicApiTokens + contractTokens; apiRevenue += (organicApiTokens / 1_000_000) * textApi.pricing.outputTokenPrice; - - enterprise.totalApiCallsPerTick = totalApiTokens / API_TOKENS_PER_REQUEST; + enterprise.totalApiCallsPerTick = (organicApiTokens + contractTokens) / API_TOKENS_PER_REQUEST; } const totalTokenDemand = organicApiTokens + - consumers.totalSubscribers * 0.5 + + consumers.totalSubscribers * CONSUMER_TOKENS_PER_SUBSCRIBER + enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0); + // --- Open source effects --- const openSourceCount = state.market.openSourcedModels.length; if (openSourceCount > 0) { const growthBoost = 1 + openSourceCount * OPEN_SOURCE_TALENT_ATTRACTION; consumers.totalSubscribers *= growthBoost > 1 ? 1 + (growthBoost - 1) * 0.01 : 1; apiRevenue *= 1 - openSourceCount * OPEN_SOURCE_REVENUE_PENALTY * 0.3; - } - const policy = state.market.overloadPolicy; - if (policy.degradeQualityUnderLoad && compute.inferenceUtilization > 0.85) { - consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.02); - } - if (policy.prioritizeEnterprise && compute.inferenceUtilization > 0.9) { - consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.01); + const eraCapBase = MARKET_SIZE_CAP[state.meta.currentEra] ?? 100_000_000; + const effectiveCap = eraCapBase + * (1 + modelQuality * MARKET_CAP_QUALITY_BONUS) + * (1 + (state.reputation.score / 100) * MARKET_CAP_REPUTATION_BONUS); + consumers.totalSubscribers = Math.min(effectiveCap, consumers.totalSubscribers); } const subscriberHistory = [...(state.market.subscriberHistory || [])]; diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts index a02d0b1..c027d90 100644 --- a/packages/game-engine/src/tick.ts +++ b/packages/game-engine/src/tick.ts @@ -1,7 +1,7 @@ import type { GameState, AchievementDefinition } from '@ai-tycoon/shared'; import { processEconomy } from './systems/economySystem'; import { processInfrastructure } from './systems/infrastructureSystem'; -import { processCompute } from './systems/computeSystem'; +import { computeCapacity, finalizeCompute } from './systems/computeSystem'; import { processResearch } from './systems/researchSystem'; import { processModels } from './systems/modelSystem'; import { processMarket } from './systems/marketSystem'; @@ -49,13 +49,10 @@ export function processTick(state: GameState): Partial { } const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState }; - const market = processMarket(stateWithModels, state.compute); - const compute = processCompute(state, infrastructure); - compute.tokensPerSecondDemand = market.totalTokenDemand; - compute.inferenceUtilization = compute.tokensPerSecondCapacity > 0 - ? Math.min(1, market.totalTokenDemand / compute.tokensPerSecondCapacity) - : 0; + const capacity = computeCapacity(state, infrastructure); + const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity); + const compute = finalizeCompute(capacity, market.totalTokenDemand); const talent = processTalent(stateWithModels); const stateWithTalent = { ...stateWithModels, talent }; diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts index 4ed8e84..bcf6d61 100644 --- a/packages/shared/src/constants/gameBalance.ts +++ b/packages/shared/src/constants/gameBalance.ts @@ -28,9 +28,24 @@ 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 API_TOKENS_PER_REQUEST = 500; export const API_REVENUE_PER_MTOK = 1.0; +export const MARKET_SIZE_CAP: Record = { + startup: 10_000, + scaleup: 1_000_000, + bigtech: 20_000_000, + agi: 100_000_000, +}; +export const MARKET_CAP_QUALITY_BONUS = 0.3; +export const MARKET_CAP_REPUTATION_BONUS = 0.2; + +export const FLOPS_TO_TOKENS_MULTIPLIER = 26; + +export const OVERLOAD_PENALTY_EXPONENT = 1.5; + export const ERA_THRESHOLDS = { scaleup: { revenue: 10_000, capability: 15, reputation: 30 }, bigtech: { revenue: 1_000_000, capability: 50, reputation: 60 },