+
+
+ Model Fleet
+
+
+ {utilization.map(m => (
+
+
+ {m.modelName}
+ {m.quantization && ({m.quantization.toUpperCase()})}
+
+
+
+
0.9 ? 'bg-red-500' : m.utilization > 0.7 ? 'bg-yellow-500' : 'bg-green-500'
+ }`}
+ style={{ width: `${Math.min(100, m.utilization * 100)}%` }}
+ />
+
+
+
{formatPercent(m.utilization)}
+
Q:{(m.qualityScore * 100).toFixed(0)}
+
{formatNumber(m.throughputCapacity)} t/s
+
+ ))}
+
+
+ );
+}
+
+function PolicyControls() {
+ const policy = useGameStore(s => s.market.overloadPolicy);
+ const setPolicy = useGameStore(s => s.setOverloadPolicy);
+ const completedResearch = useGameStore(s => s.research?.completedResearch ?? []);
+
+ const hasRouting = completedResearch.includes('request-routing');
+ const hasPriorityQueues = completedResearch.includes('priority-queues');
+ const hasBatching = completedResearch.includes('request-batching');
+ const hasAutoScaling = completedResearch.includes('auto-scaling');
+
+ return (
+
+
+
+ Policy Controls
+
+
+ {/* Always available: Enterprise Reservation */}
+
+
+
+ setPolicy({ enterpriseReservation: Number(e.target.value) / 100 })}
+ className="flex-1 accent-accent"
+ />
+ {(policy.enterpriseReservation * 100).toFixed(0)}%
+
+
Reserve capacity for enterprise SLAs — protects contracts but limits other tiers
+
+
+ {/* Always available: Auto-Degradation toggle */}
+
+
+ {hasAutoScaling && policy.autoDegradation.enabled && (
+
+
+
+
+ setPolicy({
+ autoDegradation: { ...policy.autoDegradation, triggerThreshold: Number(e.target.value) / 100 },
+ })}
+ className="flex-1 accent-accent"
+ />
+ {(policy.autoDegradation.triggerThreshold * 100).toFixed(0)}%
+
+
+
+
+
+ setPolicy({
+ autoDegradation: { ...policy.autoDegradation, minQualityFloor: Number(e.target.value) / 100 },
+ })}
+ className="flex-1 accent-accent"
+ />
+ {(policy.autoDegradation.minQualityFloor * 100).toFixed(0)}%
+
+
+
+ )}
+
+
+ {/* Routing Strategy — requires research */}
+ {hasRouting ? (
+
+
+
+ {ROUTING_OPTIONS.map(opt => (
+
+ ))}
+
+
+ ) : (
+
+
+ Research "Intelligent Request Routing" to unlock routing strategies and per-tier rate limits
+
+ )}
+
+ {/* Priority & Overflow — requires research */}
+ {hasPriorityQueues ? (
+
+
+
+ {TRAFFIC_PRIORITIES.map(tier => (
+
+ {TIER_LABELS[tier]}
+
+
+ ))}
+
+
+
+
+ setPolicy({ maxQueueDepth: Number(e.target.value) })}
+ className="flex-1 accent-accent"
+ />
+ {policy.maxQueueDepth}
+
+
+
+ ) : !hasRouting ? null : (
+
+
+ Research "Priority Queue System" to unlock per-tier overflow behavior and queue controls
+
+ )}
+
+ {/* Batch API — requires research */}
+ {hasBatching ? (
+
+
+ {policy.batchApiEnabled && (
+
+
+
+ setPolicy({ batchApiDiscount: Number(e.target.value) / 100 })}
+ className="flex-1 accent-accent"
+ />
+ {(policy.batchApiDiscount * 100).toFixed(0)}%
+
+
Higher discount = more batch demand, lower per-token revenue
+
+ )}
+
+ ) : hasRouting ? (
+
+
+ Research "Request Batching" to unlock the Batch API product line
+
+ ) : null}
+
+ {/* Rate limits — requires routing research */}
+ {hasRouting && (
+
+
+
+ {TRAFFIC_PRIORITIES.map(tier => (
+
+ {TIER_LABELS[tier]}
+ {
+ const v = Number(e.target.value);
+ if (v >= 10) {
+ setPolicy({
+ rateLimitPerCustomer: {
+ ...policy.rateLimitPerCustomer,
+ [tier]: v,
+ },
+ });
+ }
+ }}
+ className="w-28 bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono"
+ min={10}
+ step={100}
+ />
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function BatchApiPanel() {
+ const batch = useGameStore(s => s.market.batchApi);
+ const sm = useGameStore(s => s.market.servingMetrics);
+ const policy = useGameStore(s => s.market.overloadPolicy);
+ const completedResearch = useGameStore(s => s.research?.completedResearch ?? []);
+
+ if (!completedResearch.includes('request-batching') || !policy.batchApiEnabled) return null;
+
+ return (
+
+
+
+ Batch API
+
+
+
+
Pending Queue
+
{formatNumber(batch.pendingQueue)} tok
+
+
+
Served Last Tick
+
{formatNumber(batch.servedLastTick)} tok
+
+
+
Revenue
+
${sm.batchApiRevenue.toFixed(4)}/tick
+
+
+
+ );
+}
+
+export function ServingPage() {
+ const sm = useGameStore(s => s.market.servingMetrics);
+ const compute = useGameStore(s => s.compute);
+
+ const totalDemand = sm.totalServed + sm.totalQueued + sm.totalRejected;
+ const successRate = totalDemand > 0 ? sm.totalServed / totalDemand : 1;
+
+ return (
+
+
Serving Pipeline
+
+ {/* Top metrics */}
+
+
+
+ 0 ? `${formatNumber(sm.totalQueued)} queued` : 'No queuing'}
+ color="text-yellow-400"
+ />
+ 0 ? XCircle : CheckCircle}
+ label="Success Rate"
+ value={formatPercent(successRate)}
+ sub={sm.totalRejected > 0 ? `${formatNumber(sm.totalRejected)} rejected` : 'All requests served'}
+ color={sm.totalRejected > 0 ? 'text-red-400' : 'text-green-400'}
+ />
+
+
+ {/* Pipeline flow table */}
+
+
+ {/* Batch API metrics */}
+
+
+ {/* Bottom row: controls + fleet */}
+
+
+ );
+}
diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts
index 9f925db..0a9ccc1 100644
--- a/apps/web/src/store/index.ts
+++ b/apps/web/src/store/index.ts
@@ -48,7 +48,7 @@ import {
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
- | 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
+ | 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
diff --git a/packages/game-engine/src/data/techTree.ts b/packages/game-engine/src/data/techTree.ts
index 6997b87..c5321f1 100644
--- a/packages/game-engine/src/data/techTree.ts
+++ b/packages/game-engine/src/data/techTree.ts
@@ -433,6 +433,48 @@ export const TECH_TREE: ResearchNode[] = [
effects: [{ type: 'unlock_product_line', target: 'agents-platform', value: 1 }],
},
+ // === SERVING INFRASTRUCTURE ===
+ {
+ id: 'request-routing',
+ name: 'Intelligent Request Routing',
+ description: 'Route requests to optimal model size/variant. Unlocks routing strategy and per-tier rate limits.',
+ era: 'scaleup',
+ category: 'efficiency',
+ prerequisites: ['inference-optimization'],
+ cost: { researchPoints: 2, compute: 25, ticks: 150 },
+ effects: [{ type: 'unlock_feature', target: 'request-routing', value: 1 }],
+ },
+ {
+ id: 'priority-queues',
+ name: 'Priority Queue System',
+ description: 'SLA-aware scheduling with granular priority controls. Unlocks priority ordering and overflow policies.',
+ era: 'scaleup',
+ category: 'efficiency',
+ prerequisites: ['request-routing'],
+ cost: { researchPoints: 3, compute: 30, ticks: 180 },
+ effects: [{ type: 'unlock_feature', target: 'priority-queues', value: 1 }],
+ },
+ {
+ id: 'request-batching',
+ name: 'Request Batching',
+ description: 'Group inference requests for higher throughput. Unlocks Batch API product line at 50% discount.',
+ era: 'scaleup',
+ category: 'efficiency',
+ prerequisites: ['inference-optimization'],
+ cost: { researchPoints: 2, compute: 20, ticks: 120 },
+ effects: [{ type: 'unlock_feature', target: 'request-batching', value: 1 }],
+ },
+ {
+ id: 'auto-scaling',
+ name: 'Auto-Scaling Infrastructure',
+ description: 'Dynamically reallocate compute during demand spikes. +20% effective capacity headroom.',
+ era: 'bigtech',
+ category: 'efficiency',
+ prerequisites: ['request-routing'],
+ cost: { researchPoints: 4, compute: 60, ticks: 300 },
+ effects: [{ type: 'efficiency_boost', target: 'auto_scaling', value: 0.2 }],
+ },
+
// === DATA ===
{
id: 'data-pipeline',
diff --git a/packages/game-engine/src/systems/market/apiTierSystem.ts b/packages/game-engine/src/systems/market/apiTierSystem.ts
index 8370e4d..8a143e9 100644
--- a/packages/game-engine/src/systems/market/apiTierSystem.ts
+++ b/packages/game-engine/src/systems/market/apiTierSystem.ts
@@ -1,9 +1,10 @@
-import type { ApiTierState, ApiTierId, DeveloperEcosystem } from '@ai-tycoon/shared';
+import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@ai-tycoon/shared';
import {
API_TIER_ORDER,
API_CONVERSION_RATES,
API_TIER_CHURN_RATES,
API_TOKENS_PER_DEVELOPER_PER_TICK,
+ REJECTION_CHURN_MULTIPLIER,
} from '@ai-tycoon/shared';
export interface ApiTickResult {
@@ -18,6 +19,8 @@ export function processApiTiers(
modelQuality: number,
seasonalApiMultiplier: number,
ecosystem: DeveloperEcosystem,
+ apiPaidMetrics: TierServingMetrics,
+ apiFreeMetrics: TierServingMetrics,
): ApiTickResult {
const updated: ApiTierState = {
tiers: { ...tiers.tiers },
@@ -89,6 +92,23 @@ export function processApiTiers(
updated.totalDevelopers = totalDevelopers;
updated.totalTokensPerTick = totalTokens;
+ const freeRejectRate = apiFreeMetrics.demandTokens > 0
+ ? apiFreeMetrics.rejectedTokens / apiFreeMetrics.demandTokens : 0;
+ if (freeRejectRate > 0) {
+ const extraChurn = updated.tiers.free.developerCount * freeRejectRate * 0.01 * REJECTION_CHURN_MULTIPLIER;
+ updated.tiers.free.developerCount = Math.max(0, updated.tiers.free.developerCount - extraChurn);
+ }
+
+ const paidRejectRate = apiPaidMetrics.demandTokens > 0
+ ? apiPaidMetrics.rejectedTokens / apiPaidMetrics.demandTokens : 0;
+ if (paidRejectRate > 0) {
+ for (const id of API_TIER_ORDER) {
+ if (id === 'free') continue;
+ const extraChurn = updated.tiers[id].developerCount * paidRejectRate * 0.005 * REJECTION_CHURN_MULTIPLIER;
+ updated.tiers[id].developerCount = Math.max(0, updated.tiers[id].developerCount - extraChurn);
+ }
+ }
+
return {
apiTiers: updated,
apiRevenue: Math.max(0, apiRevenue),
diff --git a/packages/game-engine/src/systems/market/consumerTierSystem.ts b/packages/game-engine/src/systems/market/consumerTierSystem.ts
index 3e79e48..8841eae 100644
--- a/packages/game-engine/src/systems/market/consumerTierSystem.ts
+++ b/packages/game-engine/src/systems/market/consumerTierSystem.ts
@@ -1,12 +1,13 @@
-import type { ConsumerTierState, ConsumerTierId } from '@ai-tycoon/shared';
+import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@ai-tycoon/shared';
import {
CONSUMER_TIER_ORDER,
CONVERSION_RATES,
TIER_CHURN_RATES,
FREE_TIER_ADOPTION_RATE,
CONSUMER_TOKENS_PER_SUBSCRIBER,
- OVERLOAD_PENALTY_EXPONENT,
NETWORK_DEGRADATION,
+ REJECTION_CHURN_MULTIPLIER,
+ QUEUE_CHURN_MULTIPLIER,
} from '@ai-tycoon/shared';
export interface ConsumerTickResult {
@@ -20,9 +21,9 @@ export function processConsumerTiers(
playerConsumerCustomers: number,
modelQuality: number,
seasonalConsumerMultiplier: number,
- demandCapacityRatio: number,
networkLatencyPenalty: number,
- overloadPolicy: { degradeQualityUnderLoad: boolean; prioritizeEnterprise: boolean },
+ consumerPaidMetrics: TierServingMetrics,
+ consumerFreeMetrics: TierServingMetrics,
): ConsumerTickResult {
const updated = {
tiers: { ...tiers.tiers },
@@ -97,26 +98,64 @@ export function processConsumerTiers(
updated.totalUsers = totalUsers;
+ const paidDemand = consumerPaidMetrics.demandTokens;
+ const freeDemand = consumerFreeMetrics.demandTokens;
+ const totalDemand = paidDemand + freeDemand;
+
+ let servingPenalty = 0;
+ if (totalDemand > 0) {
+ const totalRejected = consumerPaidMetrics.rejectedTokens + consumerFreeMetrics.rejectedTokens;
+ const totalQueued = consumerPaidMetrics.queuedTokens + consumerFreeMetrics.queuedTokens;
+ const rejectedFraction = totalRejected / totalDemand;
+ const queuedFraction = totalQueued / totalDemand;
+
+ servingPenalty = rejectedFraction * 1.5 + queuedFraction * 0.5;
+
+ const avgQuality = totalDemand > 0
+ ? (consumerPaidMetrics.avgQualityDelivered * paidDemand + consumerFreeMetrics.avgQualityDelivered * freeDemand) / totalDemand
+ : modelQuality;
+ const qualityGap = Math.max(0, modelQuality - avgQuality);
+ servingPenalty += qualityGap * 0.8;
+
+ if (consumerFreeMetrics.rejectedTokens > 0 && freeDemand > 0) {
+ const freeRejectRate = consumerFreeMetrics.rejectedTokens / freeDemand;
+ const extraChurn = updated.tiers.free.userCount * freeRejectRate * 0.01 * REJECTION_CHURN_MULTIPLIER;
+ updated.tiers.free.userCount = Math.max(0, updated.tiers.free.userCount - extraChurn);
+ }
+
+ if (consumerPaidMetrics.rejectedTokens > 0 && paidDemand > 0) {
+ const paidRejectRate = consumerPaidMetrics.rejectedTokens / paidDemand;
+ for (const id of CONSUMER_TIER_ORDER) {
+ if (id === 'free') continue;
+ const extraChurn = updated.tiers[id].userCount * paidRejectRate * 0.005 * REJECTION_CHURN_MULTIPLIER;
+ updated.tiers[id].userCount = Math.max(0, updated.tiers[id].userCount - extraChurn);
+ }
+ }
+
+ if (totalQueued > 0) {
+ for (const id of CONSUMER_TIER_ORDER) {
+ const extraChurn = updated.tiers[id].userCount * queuedFraction * 0.002 * QUEUE_CHURN_MULTIPLIER;
+ updated.tiers[id].userCount = Math.max(0, updated.tiers[id].userCount - extraChurn);
+ }
+ }
+ }
+
let headroomBonus = 0;
- let overloadPenalty = 0;
- if (demandCapacityRatio <= 1) {
- headroomBonus = (1 - demandCapacityRatio) * 0.2;
+ if (totalDemand > 0) {
+ const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
+ const servedFraction = totalServed / totalDemand;
+ if (servedFraction > 0.95) {
+ headroomBonus = (servedFraction - 0.95) * 4;
+ }
} else {
- overloadPenalty = Math.min(1, Math.pow(demandCapacityRatio - 1, OVERLOAD_PENALTY_EXPONENT));
+ headroomBonus = 0.1;
}
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
updated.satisfaction = Math.min(1, Math.max(0,
- 0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty - netLatencyPenalty,
+ 0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty,
));
- if (overloadPolicy.degradeQualityUnderLoad && demandCapacityRatio > 0.85) {
- updated.satisfaction = Math.max(0, updated.satisfaction - 0.02);
- }
- if (overloadPolicy.prioritizeEnterprise && demandCapacityRatio > 0.9) {
- updated.satisfaction = Math.max(0, updated.satisfaction - 0.01);
- }
-
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
return {
diff --git a/packages/game-engine/src/systems/market/enterprisePipeline.ts b/packages/game-engine/src/systems/market/enterprisePipeline.ts
index 71e520a..b455cdf 100644
--- a/packages/game-engine/src/systems/market/enterprisePipeline.ts
+++ b/packages/game-engine/src/systems/market/enterprisePipeline.ts
@@ -5,6 +5,7 @@ import type {
EnterpriseSegment,
EnterprisePipelineStage,
DeveloperEcosystem,
+ TierServingMetrics,
} from '@ai-tycoon/shared';
import {
BASE_LEAD_RATE,
@@ -17,6 +18,7 @@ import {
ENTERPRISE_SLA_REQUIREMENTS,
ENTERPRISE_CAPABILITY_REQUIREMENTS,
ENTERPRISE_TOKENS_PER_TICK,
+ ENTERPRISE_REJECTION_SLA_MULTIPLIER,
} from '@ai-tycoon/shared';
import { ENTERPRISE_NAMES } from '../../data/enterpriseNames';
@@ -62,7 +64,7 @@ export function processEnterprisePipeline(
devEcosystem: DeveloperEcosystem,
seasonalEntMultiplier: number,
currentTick: number,
- demandCapacityRatio: number,
+ enterpriseServingMetrics: TierServingMetrics,
): EnterprisePipelineResult {
const pipeline = [...ent.pipeline];
const activeContracts = [...ent.activeContracts];
@@ -129,7 +131,10 @@ export function processEnterprisePipeline(
if (lead.stage === 'qualification') {
transitionProb *= modelCapability >= lead.requiredCapability ? 1 : 0.1;
} else if (lead.stage === 'poc') {
- transitionProb *= Math.max(0.2, 1 - Math.max(0, demandCapacityRatio - 0.9) * 5);
+ const entDemand = enterpriseServingMetrics.demandTokens;
+ const entRejected = enterpriseServingMetrics.rejectedTokens;
+ const rejectRate = entDemand > 0 ? entRejected / entDemand : 0;
+ transitionProb *= Math.max(0.2, 1 - rejectRate * 5);
} else if (lead.stage === 'negotiation') {
transitionProb *= Math.max(0.3, 1 - (lead.dealValue / 10_000_000) * 0.5);
}
@@ -181,14 +186,22 @@ export function processEnterprisePipeline(
const updated = { ...contract };
updated.totalTicks++;
- if (demandCapacityRatio <= (1 / updated.slaUptime)) {
+ const entDemand = enterpriseServingMetrics.demandTokens;
+ const entServed = enterpriseServingMetrics.servedTokens;
+ const entRejected = enterpriseServingMetrics.rejectedTokens;
+ const servedFraction = entDemand > 0 ? entServed / entDemand : 1;
+ const wasRejected = entRejected > 0;
+ const qualityMet = enterpriseServingMetrics.avgQualityDelivered >= 0.85;
+
+ if (servedFraction >= updated.slaUptime && qualityMet && !wasRejected) {
updated.uptimeTicks++;
} else {
updated.slaViolations++;
- const penalty = updated.pricePerMToken * (updated.tokensPerTick / 1_000_000) * SLA_PENALTY_FRACTION;
+ const severityMultiplier = wasRejected ? ENTERPRISE_REJECTION_SLA_MULTIPLIER : 1.0;
+ const penalty = updated.pricePerMToken * (updated.tokensPerTick / 1_000_000) * SLA_PENALTY_FRACTION * severityMultiplier;
slaPenalties += penalty;
updated.slaPenaltiesPaid += penalty;
- updated.satisfaction = Math.max(0, updated.satisfaction - 0.005);
+ updated.satisfaction = Math.max(0, updated.satisfaction - (wasRejected ? 0.01 : 0.005));
}
if (updated.totalTicks > 0 && updated.slaViolations === 0) {
diff --git a/packages/game-engine/src/systems/market/index.ts b/packages/game-engine/src/systems/market/index.ts
index 29775e5..7606113 100644
--- a/packages/game-engine/src/systems/market/index.ts
+++ b/packages/game-engine/src/systems/market/index.ts
@@ -1,5 +1,6 @@
-import type { GameState, MarketState, BenchmarkResult, Competitor } from '@ai-tycoon/shared';
-import { CONSUMER_TOKENS_PER_SUBSCRIBER } from '@ai-tycoon/shared';
+import type { GameState, MarketState, BenchmarkResult } from '@ai-tycoon/shared';
+import { CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_DEVELOPER_PER_TICK, BATCH_API_DEMAND_PER_DEV, makeInitialServingMetrics } from '@ai-tycoon/shared';
+import type { TrafficPriority, TierServingMetrics } from '@ai-tycoon/shared';
import { BENCHMARKS } from '../../data/benchmarks';
import { computeSeasonal } from './seasonalSystem';
import { updateObsolescence } from './obsolescenceSystem';
@@ -9,6 +10,9 @@ import { processApiTiers } from './apiTierSystem';
import { processProductLines } from './productLines';
import { processDeveloperEcosystem } from './developerEcosystem';
import { processEnterprisePipeline } from './enterprisePipeline';
+import { processServingPipeline } from './servingPipeline';
+import type { DemandByTier } from './servingPipeline';
+import type { ResearchBonuses } from '../researchBonuses';
export interface MarketTickResult {
marketState: MarketState;
@@ -44,24 +48,26 @@ function getSegmentQuality(
return weightedSum / totalWeight;
}
-export function processMarketV2(state: GameState, currentTickCapacity: number): MarketTickResult {
+export function processMarketV2(
+ state: GameState,
+ currentTickCapacity: number,
+ effectiveInferenceFlops?: number,
+ researchBonuses?: ResearchBonuses,
+): MarketTickResult {
const consumerQuality = getSegmentQuality('consumer', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const enterpriseQuality = getSegmentQuality('enterprise', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const modelQuality = state.models.benchmarkResults.length > 0
? (consumerQuality + enterpriseQuality) / 2
: state.models.bestDeployedModelScore / 100;
- // --- Seasonal ---
const seasonal = computeSeasonal(state.meta.tickCount);
- // --- Obsolescence ---
const obsolescence = updateObsolescence(
state.market.obsolescence,
state.meta.currentEra,
state.meta.tickCount,
);
- // --- Developer Ecosystem ---
const freeApiDevs = state.market.apiTiers.tiers.free.developerCount;
const totalApiDevs = state.market.apiTiers.totalDevelopers;
const engineeringCount = state.talent.departments.engineering.headcount;
@@ -75,7 +81,6 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
state.meta.currentEra,
);
- // --- TAM & Market Shares ---
const chatProduct = state.models.productLines.find(p => p.type === 'chat-product');
const textApi = state.models.productLines.find(p => p.type === 'text-api');
@@ -106,32 +111,7 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
const playerDevCustomers = tam.segments.developer.shares.find(s => s.playerId === 'player')?.customers ?? 0;
const playerEntCustomers = tam.segments.enterprise.shares.find(s => s.playerId === 'player')?.customers ?? 0;
- // --- Consumer Tiers ---
- const consumerDemandEstimate = state.market.consumerTiers.totalUsers * CONSUMER_TOKENS_PER_SUBSCRIBER;
- const demandCapacityRatio = currentTickCapacity > 0
- ? consumerDemandEstimate / currentTickCapacity
- : consumerDemandEstimate > 0 ? 10 : 0;
-
- const consumerResult = processConsumerTiers(
- state.market.consumerTiers,
- playerConsumerCustomers,
- modelQuality,
- seasonal.multipliers.consumer,
- demandCapacityRatio,
- state.infrastructure.networkLatencyPenalty,
- state.market.overloadPolicy,
- );
-
- // --- API Tiers ---
- const apiResult = processApiTiers(
- state.market.apiTiers,
- playerDevCustomers,
- modelQuality,
- seasonal.multipliers.api,
- devEcosystem,
- );
-
- // --- Product Lines ---
+ // --- Product Lines (compute first to get token demand) ---
const productResult = processProductLines(
state.market.codeAssistant,
state.market.agentsPlatform,
@@ -142,22 +122,103 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
seasonal.multipliers.enterprise,
);
- // --- Enterprise Pipeline ---
+ // --- Pre-compute demand estimates by tier for serving pipeline ---
+ const consumerTiers = state.market.consumerTiers;
+ const apiTiers = state.market.apiTiers;
+ const enterprise = state.market.enterprise;
+
+ const consumerPaidTokens = (consumerTiers.tiers.plus.userCount + consumerTiers.tiers.pro.userCount + consumerTiers.tiers.team.userCount) * CONSUMER_TOKENS_PER_SUBSCRIBER;
+ const consumerFreeTokens = consumerTiers.tiers.free.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
+
+ const apiPaidTokens =
+ apiTiers.tiers.payg.developerCount * API_TOKENS_PER_DEVELOPER_PER_TICK.payg
+ + apiTiers.tiers.scale.developerCount * API_TOKENS_PER_DEVELOPER_PER_TICK.scale
+ + apiTiers.tiers['enterprise-api'].developerCount * API_TOKENS_PER_DEVELOPER_PER_TICK['enterprise-api']
+ + productResult.codeAssistantTokenDemand;
+ const apiFreeTokens = apiTiers.tiers.free.developerCount * API_TOKENS_PER_DEVELOPER_PER_TICK.free;
+
+ let enterpriseTokens = 0;
+ for (const contract of enterprise.activeContracts) {
+ enterpriseTokens += contract.tokensPerTick;
+ }
+ enterpriseTokens += productResult.agentsPlatformTokenDemand;
+
+ const demandByTier: DemandByTier = {
+ 'enterprise': enterpriseTokens,
+ 'api-paid': apiPaidTokens,
+ 'consumer-paid': consumerPaidTokens,
+ 'api-free': apiFreeTokens,
+ 'consumer-free': consumerFreeTokens,
+ };
+
+ // --- Batch API demand ---
+ let batchDemand = 0;
+ if (state.market.overloadPolicy.batchApiEnabled) {
+ for (const id of ['free', 'payg', 'scale', 'enterprise-api'] as const) {
+ batchDemand += apiTiers.tiers[id].developerCount * (BATCH_API_DEMAND_PER_DEV[id] ?? 0);
+ }
+ batchDemand *= Math.max(0.1, modelQuality);
+ }
+
+ const completedResearch = state.research?.completedResearch ?? [];
+
+ // --- Serving Pipeline ---
+ const servingResult = processServingPipeline({
+ modelsState: state.models,
+ effectiveInferenceFlops: effectiveInferenceFlops ?? currentTickCapacity,
+ overloadPolicy: state.market.overloadPolicy,
+ demandByTier,
+ batchApi: {
+ ...state.market.batchApi,
+ totalBatchDemand: batchDemand,
+ },
+ modelQuality,
+ researchUnlocks: {
+ servingRoutingUnlocked: completedResearch.includes('request-routing'),
+ priorityQueuesUnlocked: completedResearch.includes('priority-queues'),
+ batchApiUnlocked: completedResearch.includes('request-batching'),
+ autoScalingBonus: completedResearch.includes('auto-scaling') ? 0.2 : 0,
+ },
+ });
+
+ const sm = servingResult.servingMetrics;
+
+ // --- Consumer Tiers (now with serving metrics) ---
+ const consumerResult = processConsumerTiers(
+ state.market.consumerTiers,
+ playerConsumerCustomers,
+ modelQuality,
+ seasonal.multipliers.consumer,
+ state.infrastructure.networkLatencyPenalty,
+ sm.tierMetrics['consumer-paid'],
+ sm.tierMetrics['consumer-free'],
+ );
+
+ // --- API Tiers (now with serving metrics) ---
+ const apiResult = processApiTiers(
+ state.market.apiTiers,
+ playerDevCustomers,
+ modelQuality,
+ seasonal.multipliers.api,
+ devEcosystem,
+ sm.tierMetrics['api-paid'],
+ sm.tierMetrics['api-free'],
+ );
+
+ // --- Enterprise Pipeline (now with serving metrics) ---
const salesDept = state.talent.departments.sales;
- const salesHeadcount = salesDept.headcount;
- const salesEffectiveness = salesDept.effectiveness;
const enterpriseResult = processEnterprisePipeline(
state.market.enterprise,
state.reputation.score,
state.models.bestDeployedModelScore,
state.models.bestDeployedSafetyScore,
- salesHeadcount,
- salesEffectiveness,
+ salesDept.headcount,
+ salesDept.effectiveness,
devEcosystem,
seasonal.multipliers.enterprise,
state.meta.tickCount,
- demandCapacityRatio,
+ sm.tierMetrics['enterprise'],
);
// --- Aggregate revenue ---
@@ -165,9 +226,10 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
+ productResult.codeAssistantRevenue
+ productResult.agentsPlatformRevenue;
- const apiRevenue = apiResult.apiRevenue
+ let apiRevenue = apiResult.apiRevenue
+ enterpriseResult.contractRevenue
- - enterpriseResult.slaPenalties;
+ - enterpriseResult.slaPenalties
+ + servingResult.batchRevenue;
const totalTokenDemand = consumerResult.totalConsumerTokenDemand
+ apiResult.totalApiTokenDemand
@@ -186,26 +248,7 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
const openSourceCount = state.market.openSourcedModels.length;
if (openSourceCount > 0) {
const revenueReduction = openSourceCount * 0.10 * 0.3;
- const adjustedApiRevenue = apiRevenue * (1 - revenueReduction);
- return {
- marketState: {
- ...state.market,
- tam,
- consumerTiers: consumerResult.consumerTiers,
- apiTiers: apiResult.apiTiers,
- codeAssistant: productResult.codeAssistant,
- agentsPlatform: productResult.agentsPlatform,
- enterprise: enterpriseResult.enterprise,
- developerEcosystem: devEcosystem,
- seasonalPhase: seasonal.phase,
- seasonalMultiplier: seasonal.multipliers.consumer,
- obsolescence,
- subscriberHistory,
- },
- apiRevenue: Math.max(0, adjustedApiRevenue),
- subscriptionRevenue,
- totalTokenDemand,
- };
+ apiRevenue = apiRevenue * (1 - revenueReduction);
}
return {
@@ -221,6 +264,8 @@ export function processMarketV2(state: GameState, currentTickCapacity: number):
seasonalPhase: seasonal.phase,
seasonalMultiplier: seasonal.multipliers.consumer,
obsolescence,
+ servingMetrics: sm,
+ batchApi: servingResult.batchApi,
subscriberHistory,
},
apiRevenue: Math.max(0, apiRevenue),
diff --git a/packages/game-engine/src/systems/market/servingPipeline.ts b/packages/game-engine/src/systems/market/servingPipeline.ts
new file mode 100644
index 0000000..8649092
--- /dev/null
+++ b/packages/game-engine/src/systems/market/servingPipeline.ts
@@ -0,0 +1,462 @@
+import type {
+ OverloadPolicy,
+ TrafficPriority,
+ TierServingMetrics,
+ ServingMetrics,
+ ModelUtilizationEntry,
+ BatchApiState,
+} from '@ai-tycoon/shared';
+import type { BaseModel, ModelVariant, ModelFamily, ModelsState, SizeTier } from '@ai-tycoon/shared';
+import {
+ MODEL_SIZE_THROUGHPUT_SCALER,
+ MOE_SPEED_MULTIPLIER,
+ FLOPS_TO_TOKENS_MULTIPLIER,
+ QUANTIZATION_CONFIGS,
+ REJECTION_SATISFACTION_PENALTY,
+ QUEUE_SATISFACTION_PENALTY,
+ DEGRADATION_SATISFACTION_PENALTY,
+ BASE_LATENCY_MS,
+ QUEUE_LATENCY_MS_PER_PERCENT,
+ BATCH_API_MAX_PENDING,
+} from '@ai-tycoon/shared';
+import { makeInitialServingMetrics } from '@ai-tycoon/shared';
+
+export interface ModelServingSlot {
+ modelId: string;
+ modelName: string;
+ sizeTier: SizeTier;
+ isVariant: boolean;
+ quantization: string | null;
+ qualityScore: number;
+ speedMultiplier: number;
+ throughputCapacity: number;
+ isMoE: boolean;
+}
+
+export interface DemandByTier {
+ enterprise: number;
+ 'api-paid': number;
+ 'consumer-paid': number;
+ 'api-free': number;
+ 'consumer-free': number;
+}
+
+export interface ServingPipelineInput {
+ modelsState: ModelsState;
+ effectiveInferenceFlops: number;
+ overloadPolicy: OverloadPolicy;
+ demandByTier: DemandByTier;
+ batchApi: BatchApiState;
+ modelQuality: number;
+ researchUnlocks: {
+ servingRoutingUnlocked: boolean;
+ priorityQueuesUnlocked: boolean;
+ batchApiUnlocked: boolean;
+ autoScalingBonus: number;
+ };
+}
+
+export interface ServingPipelineResult {
+ servingMetrics: ServingMetrics;
+ batchApi: BatchApiState;
+ batchRevenue: number;
+}
+
+function buildModelFleet(
+ modelsState: ModelsState,
+ effectiveInferenceFlops: number,
+): ModelServingSlot[] {
+ const slots: ModelServingSlot[] = [];
+
+ const deployedBases = modelsState.baseModels.filter(m => m.isDeployed);
+ 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);
+ if (base) deployedVariants.push({ variant, baseModel: base });
+ }
+ }
+
+ const totalDeployed = deployedBases.length + deployedVariants.length;
+ if (totalDeployed === 0 || effectiveInferenceFlops <= 0) return slots;
+
+ const flopsPerModel = effectiveInferenceFlops / totalDeployed;
+
+ for (const model of deployedBases) {
+ const sizeFactor = MODEL_SIZE_THROUGHPUT_SCALER[model.sizeTier] ?? 1.0;
+ const moeFactor = model.architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER : 1.0;
+ const throughput = flopsPerModel * FLOPS_TO_TOKENS_MULTIPLIER * sizeFactor * moeFactor;
+
+ slots.push({
+ modelId: model.id,
+ modelName: model.name,
+ sizeTier: model.sizeTier,
+ isVariant: false,
+ quantization: null,
+ qualityScore: model.rawCapability / 100,
+ speedMultiplier: moeFactor,
+ throughputCapacity: throughput,
+ isMoE: model.architecture.type === 'moe',
+ });
+ }
+
+ for (const { variant, baseModel } of deployedVariants) {
+ const sizeFactor = MODEL_SIZE_THROUGHPUT_SCALER[baseModel.sizeTier] ?? 1.0;
+ const moeFactor = variant.architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER : 1.0;
+ const quantConfig = variant.quantization ? QUANTIZATION_CONFIGS[variant.quantization] : null;
+ const quantSpeedFactor = quantConfig?.speedMultiplier ?? 1.0;
+ const qualityRetention = quantConfig?.qualityRetention ?? 1.0;
+ const throughput = flopsPerModel * FLOPS_TO_TOKENS_MULTIPLIER * sizeFactor * moeFactor * quantSpeedFactor;
+
+ slots.push({
+ modelId: variant.id,
+ modelName: variant.name,
+ sizeTier: baseModel.sizeTier,
+ isVariant: true,
+ quantization: variant.quantization ?? null,
+ qualityScore: (baseModel.rawCapability / 100) * qualityRetention,
+ speedMultiplier: moeFactor * quantSpeedFactor,
+ throughputCapacity: throughput,
+ isMoE: variant.architecture.type === 'moe',
+ });
+ }
+
+ return slots;
+}
+
+function sortFleetByStrategy(
+ fleet: ModelServingSlot[],
+ strategy: string,
+ overallUtilization: number,
+): ModelServingSlot[] {
+ const sorted = [...fleet];
+ switch (strategy) {
+ case 'quality-first':
+ sorted.sort((a, b) => b.qualityScore - a.qualityScore);
+ break;
+ case 'speed-first':
+ sorted.sort((a, b) => b.throughputCapacity - a.throughputCapacity);
+ break;
+ case 'balanced':
+ default:
+ if (overallUtilization > 0.8) {
+ sorted.sort((a, b) => b.throughputCapacity - a.throughputCapacity);
+ } else {
+ sorted.sort((a, b) => b.qualityScore - a.qualityScore);
+ }
+ break;
+ }
+ return sorted;
+}
+
+interface FleetState {
+ remaining: Map
;
+ used: Map;
+}
+
+function serveFromFleet(
+ demand: number,
+ fleet: ModelServingSlot[],
+ fleetState: FleetState,
+ policy: OverloadPolicy,
+ tier: TrafficPriority,
+ overallUtilization: number,
+): TierServingMetrics {
+ if (demand <= 0) {
+ return { demandTokens: 0, servedTokens: 0, queuedTokens: 0, rejectedTokens: 0, degradedTokens: 0, avgQualityDelivered: 1 };
+ }
+
+ let remaining = demand;
+ let served = 0;
+ let degraded = 0;
+ let qualityWeightedSum = 0;
+
+ const bestQuality = fleet.length > 0 ? Math.max(...fleet.map(s => s.qualityScore)) : 1;
+ const degradationActive = policy.autoDegradation.enabled && overallUtilization > policy.autoDegradation.triggerThreshold;
+
+ for (const slot of fleet) {
+ if (remaining <= 0) break;
+
+ const isDegraded = slot.qualityScore < bestQuality * 0.95;
+ if (isDegraded && !degradationActive) continue;
+ if (isDegraded && slot.qualityScore < policy.autoDegradation.minQualityFloor) continue;
+
+ const available = fleetState.remaining.get(slot.modelId) ?? 0;
+ if (available <= 0) continue;
+
+ const toServe = Math.min(remaining, available);
+ fleetState.remaining.set(slot.modelId, available - toServe);
+ fleetState.used.set(slot.modelId, (fleetState.used.get(slot.modelId) ?? 0) + toServe);
+
+ served += toServe;
+ if (isDegraded) degraded += toServe;
+ qualityWeightedSum += toServe * slot.qualityScore;
+ remaining -= toServe;
+ }
+
+ let queued = 0;
+ let rejected = 0;
+
+ if (remaining > 0) {
+ const behavior = policy.overflowBehavior[tier];
+ switch (behavior) {
+ case 'queue':
+ queued = remaining;
+ break;
+ case 'reject':
+ rejected = remaining;
+ break;
+ case 'degrade':
+ for (const slot of fleet) {
+ if (remaining <= 0) break;
+ const available = fleetState.remaining.get(slot.modelId) ?? 0;
+ if (available <= 0) continue;
+
+ const toServe = Math.min(remaining, available);
+ fleetState.remaining.set(slot.modelId, available - toServe);
+ fleetState.used.set(slot.modelId, (fleetState.used.get(slot.modelId) ?? 0) + toServe);
+ served += toServe;
+ degraded += toServe;
+ qualityWeightedSum += toServe * slot.qualityScore;
+ remaining -= toServe;
+ }
+ rejected = remaining;
+ break;
+ }
+ }
+
+ const avgQuality = served > 0 ? qualityWeightedSum / served : bestQuality;
+
+ return {
+ demandTokens: demand,
+ servedTokens: served,
+ queuedTokens: queued,
+ rejectedTokens: rejected,
+ degradedTokens: degraded,
+ avgQualityDelivered: avgQuality,
+ };
+}
+
+export function processServingPipeline(input: ServingPipelineInput): ServingPipelineResult {
+ const { modelsState, effectiveInferenceFlops, overloadPolicy, demandByTier, batchApi, modelQuality, researchUnlocks } = input;
+
+ const fleet = buildModelFleet(modelsState, effectiveInferenceFlops);
+ const totalFleetCapacity = fleet.reduce((sum, s) => sum + s.throughputCapacity, 0);
+
+ if (fleet.length === 0 || totalFleetCapacity <= 0) {
+ const metrics = makeInitialServingMetrics();
+ for (const tier of Object.keys(demandByTier) as TrafficPriority[]) {
+ const demand = demandByTier[tier] ?? 0;
+ if (demand > 0) {
+ metrics.tierMetrics[tier] = {
+ demandTokens: demand,
+ servedTokens: 0,
+ queuedTokens: 0,
+ rejectedTokens: demand,
+ degradedTokens: 0,
+ avgQualityDelivered: 0,
+ };
+ metrics.totalRejected += demand;
+ }
+ }
+ return {
+ servingMetrics: metrics,
+ batchApi: { ...batchApi, servedLastTick: 0, revenue: 0 },
+ batchRevenue: 0,
+ };
+ }
+
+ const totalDemand = Object.values(demandByTier).reduce((s, v) => s + v, 0);
+ const overallUtilization = totalFleetCapacity > 0 ? totalDemand / totalFleetCapacity : 0;
+
+ const effectiveStrategy = researchUnlocks.servingRoutingUnlocked
+ ? overloadPolicy.routingStrategy
+ : 'balanced';
+
+ const sortedFleet = sortFleetByStrategy(fleet, effectiveStrategy, overallUtilization);
+
+ const fleetState: FleetState = {
+ remaining: new Map(fleet.map(s => [s.modelId, s.throughputCapacity])),
+ used: new Map(fleet.map(s => [s.modelId, 0])),
+ };
+
+ const reservedCapacity = totalFleetCapacity * overloadPolicy.enterpriseReservation;
+ const enterpriseDemand = demandByTier['enterprise'] ?? 0;
+
+ if (reservedCapacity > 0 && enterpriseDemand > 0) {
+ const reservePerModel = reservedCapacity / fleet.length;
+ for (const slot of sortedFleet) {
+ const current = fleetState.remaining.get(slot.modelId) ?? 0;
+ const reserved = Math.min(reservePerModel, current);
+ fleetState.remaining.set(slot.modelId, current - reserved);
+ }
+ }
+
+ const effectivePriorityOrder = researchUnlocks.priorityQueuesUnlocked
+ ? overloadPolicy.priorityOrder
+ : ['enterprise', 'api-paid', 'consumer-paid', 'api-free', 'consumer-free'] as TrafficPriority[];
+
+ const tierResults: Record = {} as Record;
+
+ const nonEnterpriseTiers = effectivePriorityOrder.filter(t => t !== 'enterprise');
+
+ if (enterpriseDemand > 0) {
+ const enterpriseFleetState: FleetState = {
+ remaining: new Map(fleet.map(s => [s.modelId, s.throughputCapacity])),
+ used: new Map(fleet.map(s => [s.modelId, 0])),
+ };
+
+ const reserveLimit = reservedCapacity > 0 ? reservedCapacity : totalFleetCapacity;
+ let budgetLeft = reserveLimit;
+ for (const slot of sortedFleet) {
+ const cap = slot.throughputCapacity;
+ const alloc = Math.min(cap, budgetLeft);
+ enterpriseFleetState.remaining.set(slot.modelId, alloc);
+ budgetLeft -= alloc;
+ if (budgetLeft <= 0) break;
+ }
+
+ const effectiveEntDemand = researchUnlocks.servingRoutingUnlocked
+ ? Math.min(enterpriseDemand, overloadPolicy.rateLimitPerCustomer['enterprise'] * 100)
+ : enterpriseDemand;
+
+ tierResults['enterprise'] = serveFromFleet(
+ effectiveEntDemand, sortedFleet, enterpriseFleetState, overloadPolicy, 'enterprise', overallUtilization,
+ );
+
+ for (const slot of fleet) {
+ const entUsed = enterpriseFleetState.used.get(slot.modelId) ?? 0;
+ const mainRemaining = fleetState.remaining.get(slot.modelId) ?? 0;
+ fleetState.remaining.set(slot.modelId, Math.max(0, mainRemaining - entUsed + (reservedCapacity > 0 ? reservedCapacity / fleet.length : 0)));
+ fleetState.used.set(slot.modelId, entUsed);
+ }
+ } else {
+ tierResults['enterprise'] = { demandTokens: 0, servedTokens: 0, queuedTokens: 0, rejectedTokens: 0, degradedTokens: 0, avgQualityDelivered: 1 };
+
+ if (reservedCapacity > 0) {
+ const reservePerModel = reservedCapacity / fleet.length;
+ for (const slot of fleet) {
+ const current = fleetState.remaining.get(slot.modelId) ?? 0;
+ fleetState.remaining.set(slot.modelId, current + reservePerModel);
+ }
+ }
+ }
+
+ for (const tier of nonEnterpriseTiers) {
+ const rawDemand = demandByTier[tier] ?? 0;
+ const effectiveDemand = researchUnlocks.servingRoutingUnlocked
+ ? Math.min(rawDemand, overloadPolicy.rateLimitPerCustomer[tier] * 100)
+ : rawDemand;
+
+ tierResults[tier] = serveFromFleet(
+ effectiveDemand, sortedFleet, fleetState, overloadPolicy, tier, overallUtilization,
+ );
+ }
+
+ for (const tier of effectivePriorityOrder) {
+ if (!(tier in tierResults)) {
+ tierResults[tier] = { demandTokens: 0, servedTokens: 0, queuedTokens: 0, rejectedTokens: 0, degradedTokens: 0, avgQualityDelivered: 1 };
+ }
+ }
+
+ let batchTokensServed = 0;
+ let batchRevenue = 0;
+ const updatedBatchApi = { ...batchApi };
+
+ if (overloadPolicy.batchApiEnabled && researchUnlocks.batchApiUnlocked) {
+ let idleCapacity = 0;
+ for (const slot of fleet) {
+ const remaining = fleetState.remaining.get(slot.modelId) ?? 0;
+ idleCapacity += remaining;
+ }
+
+ const pendingBatch = Math.min(batchApi.pendingQueue + batchApi.totalBatchDemand, BATCH_API_MAX_PENDING);
+ batchTokensServed = Math.min(pendingBatch, idleCapacity);
+
+ const baseTokenPrice = 3.0;
+ batchRevenue = (batchTokensServed / 1_000_000) * baseTokenPrice * (1 - overloadPolicy.batchApiDiscount);
+
+ updatedBatchApi.pendingQueue = Math.max(0, pendingBatch - batchTokensServed);
+ updatedBatchApi.servedLastTick = batchTokensServed;
+ updatedBatchApi.revenue = batchRevenue;
+ }
+
+ const totalServed = Object.values(tierResults).reduce((s, t) => s + t.servedTokens, 0);
+ const totalQueued = Object.values(tierResults).reduce((s, t) => s + t.queuedTokens, 0);
+ const totalRejected = Object.values(tierResults).reduce((s, t) => s + t.rejectedTokens, 0);
+ const totalDegraded = Object.values(tierResults).reduce((s, t) => s + t.degradedTokens, 0);
+
+ let effectiveQuality = modelQuality;
+ if (totalServed > 0) {
+ let qualitySum = 0;
+ for (const t of Object.values(tierResults)) {
+ qualitySum += t.avgQualityDelivered * t.servedTokens;
+ }
+ effectiveQuality = qualitySum / totalServed;
+ }
+
+ const queuedFraction = totalDemand > 0 ? totalQueued / totalDemand : 0;
+ const avgLatencyMs = BASE_LATENCY_MS + queuedFraction * 100 * QUEUE_LATENCY_MS_PER_PERCENT;
+
+ const modelUtilization: ModelUtilizationEntry[] = fleet.map(slot => ({
+ modelId: slot.modelId,
+ modelName: slot.modelName,
+ quantization: slot.quantization,
+ qualityScore: slot.qualityScore,
+ throughputCapacity: slot.throughputCapacity,
+ throughputUsed: fleetState.used.get(slot.modelId) ?? 0,
+ utilization: slot.throughputCapacity > 0
+ ? Math.min(1, (fleetState.used.get(slot.modelId) ?? 0) / slot.throughputCapacity)
+ : 0,
+ }));
+
+ const autoScaleBoost = researchUnlocks.autoScalingBonus;
+ if (autoScaleBoost > 0) {
+ for (const tier of Object.keys(tierResults) as TrafficPriority[]) {
+ const metrics = tierResults[tier];
+ if (metrics.rejectedTokens > 0) {
+ const recovered = Math.min(metrics.rejectedTokens, metrics.rejectedTokens * autoScaleBoost);
+ tierResults[tier] = {
+ ...metrics,
+ servedTokens: metrics.servedTokens + recovered,
+ rejectedTokens: metrics.rejectedTokens - recovered,
+ };
+ }
+ }
+ }
+
+ return {
+ servingMetrics: {
+ tierMetrics: tierResults,
+ totalServed,
+ totalQueued,
+ totalRejected,
+ totalDegraded,
+ effectiveQuality,
+ avgLatencyMs,
+ modelUtilization,
+ batchApiTokensServed: batchTokensServed,
+ batchApiRevenue: batchRevenue,
+ },
+ batchApi: updatedBatchApi,
+ batchRevenue,
+ };
+}
+
+export function computeSatisfactionImpact(
+ metrics: TierServingMetrics,
+): number {
+ if (metrics.demandTokens <= 0) return 0;
+
+ const rejectedFraction = metrics.rejectedTokens / metrics.demandTokens;
+ const queuedFraction = metrics.queuedTokens / metrics.demandTokens;
+ const degradedFraction = metrics.servedTokens > 0 ? metrics.degradedTokens / metrics.servedTokens : 0;
+
+ const rejectionPenalty = rejectedFraction * REJECTION_SATISFACTION_PENALTY * 10;
+ const queuePenalty = queuedFraction * QUEUE_SATISFACTION_PENALTY * 10;
+ const degradationPenalty = degradedFraction * (1 - metrics.avgQualityDelivered) * DEGRADATION_SATISFACTION_PENALTY * 10;
+
+ return -(rejectionPenalty + queuePenalty + degradationPenalty);
+}
diff --git a/packages/game-engine/src/systems/marketSystem.ts b/packages/game-engine/src/systems/marketSystem.ts
index e357c84..4a6ca9d 100644
--- a/packages/game-engine/src/systems/marketSystem.ts
+++ b/packages/game-engine/src/systems/marketSystem.ts
@@ -1,8 +1,9 @@
import type { GameState } from '@ai-tycoon/shared';
import { processMarketV2 } from './market/index';
+import type { ResearchBonuses } from './researchBonuses';
export type { MarketTickResult } from './market/index';
-export function processMarket(state: GameState, currentTickCapacity: number) {
- return processMarketV2(state, currentTickCapacity);
+export function processMarket(state: GameState, currentTickCapacity: number, effectiveInferenceFlops?: number, researchBonuses?: ResearchBonuses) {
+ return processMarketV2(state, currentTickCapacity, effectiveInferenceFlops, researchBonuses);
}
diff --git a/packages/game-engine/src/systems/researchBonuses.ts b/packages/game-engine/src/systems/researchBonuses.ts
index 163fa6b..ab56874 100644
--- a/packages/game-engine/src/systems/researchBonuses.ts
+++ b/packages/game-engine/src/systems/researchBonuses.ts
@@ -18,6 +18,7 @@ export interface ResearchBonuses {
reputationBonus: number;
safetyBonus: number;
+ autoScalingBonus: number;
}
export function getResearchBonuses(completedResearch: string[]): ResearchBonuses {
@@ -37,6 +38,7 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
agentsBonus: 0,
reputationBonus: 0,
safetyBonus: 0,
+ autoScalingBonus: 0,
};
for (const id of completedResearch) {
@@ -53,6 +55,7 @@ export function getResearchBonuses(completedResearch: string[]): ResearchBonuses
case 'pipeline_speed': bonuses.pipelineSpeedBonus += effect.value; break;
case 'data_quality': bonuses.dataQualityBonus += effect.value; break;
case 'sdk_coverage': bonuses.sdkCoverageBonus += effect.value; break;
+ case 'auto_scaling': bonuses.autoScalingBonus += effect.value; break;
}
break;
case 'capability_boost':
diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts
index 6b96f5e..2011214 100644
--- a/packages/game-engine/src/tick.ts
+++ b/packages/game-engine/src/tick.ts
@@ -56,7 +56,7 @@ export function processTick(state: GameState): Partial {
const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState };
const capacity = computeCapacity(state, infrastructure, researchBonuses);
- const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity);
+ const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses);
const compute = finalizeCompute(capacity, market.totalTokenDemand);
const talent = processTalent(stateWithModels);
diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts
index 6c7b643..55c303e 100644
--- a/packages/shared/src/constants/gameBalance.ts
+++ b/packages/shared/src/constants/gameBalance.ts
@@ -118,6 +118,34 @@ export const FLOPS_TO_TOKENS_MULTIPLIER = 26;
export const OVERLOAD_PENALTY_EXPONENT = 1.5;
+// --- Serving Pipeline ---
+
+export const REJECTION_SATISFACTION_PENALTY = 0.15;
+export const QUEUE_SATISFACTION_PENALTY = 0.05;
+export const DEGRADATION_SATISFACTION_PENALTY = 0.08;
+
+export const REJECTION_CHURN_MULTIPLIER = 3.0;
+export const QUEUE_CHURN_MULTIPLIER = 1.5;
+
+export const ENTERPRISE_REJECTION_SLA_MULTIPLIER = 3.0;
+
+export const FREE_TIER_REJECTION_TOLERANCE = 0.3;
+export const PAID_TIER_REJECTION_TOLERANCE = 0.05;
+
+export const MODEL_SIZE_THROUGHPUT_SCALER: Record = {
+ nano: 10.0, small: 5.0, medium: 2.0, large: 1.2, flagship: 1.0,
+};
+
+export const BATCH_API_DEMAND_PER_DEV: Record = {
+ free: 0, payg: 2, scale: 20, 'enterprise-api': 100,
+};
+export const BATCH_API_DEFAULT_DISCOUNT = 0.5;
+export const BATCH_API_MAX_PENDING = 100_000;
+
+export const BATCHING_THROUGHPUT_FACTOR = 0.15;
+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 },
diff --git a/packages/shared/src/types/gameState.ts b/packages/shared/src/types/gameState.ts
index bbbc242..9002935 100644
--- a/packages/shared/src/types/gameState.ts
+++ b/packages/shared/src/types/gameState.ts
@@ -52,4 +52,4 @@ export const INITIAL_SETTINGS: GameSettings = {
musicVolume: 0.5,
};
-export const SAVE_VERSION = 8;
+export const SAVE_VERSION = 9;
diff --git a/packages/shared/src/types/market.ts b/packages/shared/src/types/market.ts
index c061ada..4d257be 100644
--- a/packages/shared/src/types/market.ts
+++ b/packages/shared/src/types/market.ts
@@ -170,13 +170,93 @@ export interface ObsolescenceState {
newModelBoostRemaining: number;
}
-// --- Overload Policy (kept from original) ---
+// --- Serving Pipeline & Overload Policy ---
+
+export type TrafficPriority = 'enterprise' | 'api-paid' | 'consumer-paid' | 'api-free' | 'consumer-free';
+export type RoutingStrategy = 'quality-first' | 'speed-first' | 'balanced';
+export type OverflowBehavior = 'queue' | 'reject' | 'degrade';
+
+export const TRAFFIC_PRIORITIES: TrafficPriority[] = ['enterprise', 'api-paid', 'consumer-paid', 'api-free', 'consumer-free'];
export interface OverloadPolicy {
+ priorityOrder: TrafficPriority[];
+ overflowBehavior: Record;
maxQueueDepth: number;
- rateLimitPerCustomer: number;
- degradeQualityUnderLoad: boolean;
- prioritizeEnterprise: boolean;
+ rateLimitPerCustomer: Record;
+ enterpriseReservation: number;
+ routingStrategy: RoutingStrategy;
+ autoDegradation: {
+ enabled: boolean;
+ triggerThreshold: number;
+ minQualityFloor: number;
+ };
+ batchApiEnabled: boolean;
+ batchApiDiscount: number;
+ batchApiMaxDelay: number;
+}
+
+export interface TierServingMetrics {
+ demandTokens: number;
+ servedTokens: number;
+ queuedTokens: number;
+ rejectedTokens: number;
+ degradedTokens: number;
+ avgQualityDelivered: number;
+}
+
+export interface ModelUtilizationEntry {
+ modelId: string;
+ modelName: string;
+ quantization: string | null;
+ qualityScore: number;
+ throughputCapacity: number;
+ throughputUsed: number;
+ utilization: number;
+}
+
+export interface ServingMetrics {
+ tierMetrics: Record;
+ totalServed: number;
+ totalQueued: number;
+ totalRejected: number;
+ totalDegraded: number;
+ effectiveQuality: number;
+ avgLatencyMs: number;
+ modelUtilization: ModelUtilizationEntry[];
+ batchApiTokensServed: number;
+ batchApiRevenue: number;
+}
+
+export interface BatchApiState {
+ totalBatchDemand: number;
+ pendingQueue: number;
+ servedLastTick: number;
+ revenue: number;
+}
+
+function makeEmptyTierMetrics(): TierServingMetrics {
+ return { demandTokens: 0, servedTokens: 0, queuedTokens: 0, rejectedTokens: 0, degradedTokens: 0, avgQualityDelivered: 1 };
+}
+
+export function makeInitialServingMetrics(): ServingMetrics {
+ return {
+ tierMetrics: {
+ 'enterprise': makeEmptyTierMetrics(),
+ 'api-paid': makeEmptyTierMetrics(),
+ 'consumer-paid': makeEmptyTierMetrics(),
+ 'api-free': makeEmptyTierMetrics(),
+ 'consumer-free': makeEmptyTierMetrics(),
+ },
+ totalServed: 0,
+ totalQueued: 0,
+ totalRejected: 0,
+ totalDegraded: 0,
+ effectiveQuality: 1,
+ avgLatencyMs: 0,
+ modelUtilization: [],
+ batchApiTokensServed: 0,
+ batchApiRevenue: 0,
+ };
}
// --- Root Market State ---
@@ -193,6 +273,8 @@ export interface MarketState {
seasonalMultiplier: number;
obsolescence: ObsolescenceState;
overloadPolicy: OverloadPolicy;
+ servingMetrics: ServingMetrics;
+ batchApi: BatchApiState;
openSourcedModels: string[];
subscriberHistory: { tick: number; subscribers: number }[];
}
@@ -315,10 +397,39 @@ export const INITIAL_MARKET: MarketState = {
newModelBoostRemaining: 0,
},
overloadPolicy: {
+ priorityOrder: ['enterprise', 'api-paid', 'consumer-paid', 'api-free', 'consumer-free'],
+ overflowBehavior: {
+ 'enterprise': 'queue' as OverflowBehavior,
+ 'api-paid': 'queue' as OverflowBehavior,
+ 'consumer-paid': 'degrade' as OverflowBehavior,
+ 'api-free': 'reject' as OverflowBehavior,
+ 'consumer-free': 'reject' as OverflowBehavior,
+ },
maxQueueDepth: 100,
- rateLimitPerCustomer: 1000,
- degradeQualityUnderLoad: false,
- prioritizeEnterprise: true,
+ rateLimitPerCustomer: {
+ 'enterprise': 10000,
+ 'api-paid': 1000,
+ 'consumer-paid': 500,
+ 'api-free': 100,
+ 'consumer-free': 50,
+ },
+ enterpriseReservation: 0.2,
+ routingStrategy: 'balanced' as RoutingStrategy,
+ autoDegradation: {
+ enabled: true,
+ triggerThreshold: 0.85,
+ minQualityFloor: 0.75,
+ },
+ batchApiEnabled: false,
+ batchApiDiscount: 0.5,
+ batchApiMaxDelay: 60,
+ },
+ servingMetrics: makeInitialServingMetrics(),
+ batchApi: {
+ totalBatchDemand: 0,
+ pendingQueue: 0,
+ servedLastTick: 0,
+ revenue: 0,
},
openSourcedModels: [],
subscriberHistory: [],