@@ -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 },