diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 0afe44e..2435a3f 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -17,6 +17,7 @@ import { DataPage } from '@/pages/DataPage'; import { CompetitorsPage } from '@/pages/CompetitorsPage'; import { AchievementsPage } from '@/pages/AchievementsPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage'; +import { ServingPage } from '@/pages/ServingPage'; export function MainLayout() { const { subPath, setSubPath } = useHashRouter(); @@ -45,6 +46,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri case 'research': return ; case 'models': return ; case 'market': return ; + case 'serving': return ; case 'finance': return ; case 'talent': return ; case 'data': return ; diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 486a76f..150a1e1 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { LayoutDashboard, Server, FlaskConical, Brain, - TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy, Medal, + TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; import { useGameStore, type ActivePage } from '@/store'; @@ -12,6 +12,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard { page: 'research', label: 'Research', icon: FlaskConical }, { page: 'models', label: 'Models', icon: Brain }, { page: 'market', label: 'Market', icon: TrendingUp }, + { page: 'serving', label: 'Serving', icon: Activity }, { page: 'finance', label: 'Finance', icon: DollarSign }, { page: 'talent', label: 'Talent', icon: Users, era: 'scaleup' }, { page: 'data', label: 'Data', icon: Database, era: 'scaleup' }, diff --git a/apps/web/src/pages/MarketPage.tsx b/apps/web/src/pages/MarketPage.tsx index 358faa6..616a3d6 100644 --- a/apps/web/src/pages/MarketPage.tsx +++ b/apps/web/src/pages/MarketPage.tsx @@ -1,9 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { useGameStore } from '@/store'; -import { - formatNumber, formatMoney, formatPercent, -} from '@ai-tycoon/shared'; -import { Users, Zap, Shield, Settings2, Check } from 'lucide-react'; +import { useState } from 'react'; import { TutorialHint } from '@/components/game/TutorialHint'; import { MarketOverviewPanel } from './market/MarketOverviewPanel'; import { ConsumerTiersPanel } from './market/ConsumerTiersPanel'; @@ -12,7 +7,7 @@ import { EnterprisePipelinePanel } from './market/EnterprisePipelinePanel'; import { DeveloperEcosystemPanel } from './market/DeveloperEcosystemPanel'; import { ProductLinesPanel } from './market/ProductLinesPanel'; -type MarketTab = 'overview' | 'consumer' | 'api' | 'enterprise' | 'ecosystem' | 'products' | 'settings'; +type MarketTab = 'overview' | 'consumer' | 'api' | 'enterprise' | 'ecosystem' | 'products'; const TABS: { id: MarketTab; label: string }[] = [ { id: 'overview', label: 'Overview' }, @@ -21,133 +16,8 @@ const TABS: { id: MarketTab; label: string }[] = [ { id: 'enterprise', label: 'Enterprise' }, { id: 'ecosystem', label: 'Dev Ecosystem' }, { id: 'products', label: 'Products' }, - { id: 'settings', label: 'Settings' }, ]; -function useAppliedFeedback() { - const [state, setState] = useState<'hidden' | 'valid' | 'invalid'>('hidden'); - const timerRef = useRef>(undefined); - const trigger = useCallback((valid = true) => { - setState(valid ? 'valid' : 'invalid'); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setState('hidden'), 1200); - }, []); - useEffect(() => () => clearTimeout(timerRef.current), []); - return { show: state !== 'hidden', valid: state === 'valid', trigger }; -} - -function AppliedBadge({ visible, valid = true }: { visible: boolean; valid?: boolean }) { - if (!visible) return null; - if (!valid) { - return ( - - Invalid - - ); - } - return ( - - Applied - - ); -} - -function SettingsPanel() { - const overloadPolicy = useGameStore((s) => s.market.overloadPolicy); - const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization); - const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity); - const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand); - const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy); - const policyFeedback = useAppliedFeedback(); - - return ( -
-
-
-
- - Inference Load -
-
{formatPercent(inferenceUtil)}
-
- {formatNumber(tokensDemand)} / {formatNumber(tokensCapacity)} tok/s -
-
-
-
- - Subscribers -
-
{formatNumber(useGameStore.getState().market.consumerTiers.totalUsers)}
-
-
-
- - Satisfaction -
-
{formatPercent(useGameStore.getState().market.consumerTiers.satisfaction)}
-
-
- -
-

- - Overload Policy - -

-
-
- - { const v = Number(e.target.value); if (v >= 10) { setOverloadPolicy({ maxQueueDepth: v }); policyFeedback.trigger(true); } else { policyFeedback.trigger(false); } }} - className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" - min={10} - step={10} - /> -

Higher = more latency tolerance, lower satisfaction

-
-
- - { const v = Number(e.target.value); if (v >= 100) { setOverloadPolicy({ rateLimitPerCustomer: v }); policyFeedback.trigger(true); } else { policyFeedback.trigger(false); } }} - className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" - min={100} - step={100} - /> -

Lower = less compute per user, serves more customers

-
-
-
- - -
-
-
- ); -} - const VALID_TABS = new Set(TABS.map(t => t.id)); export function MarketPage({ initialTab, onTabChange }: { initialTab?: string | null; onTabChange?: (tab: string | null) => void }) { @@ -189,7 +59,6 @@ export function MarketPage({ initialTab, onTabChange }: { initialTab?: string | {activeTab === 'enterprise' && } {activeTab === 'ecosystem' && } {activeTab === 'products' && } - {activeTab === 'settings' && } ); } diff --git a/apps/web/src/pages/ServingPage.tsx b/apps/web/src/pages/ServingPage.tsx new file mode 100644 index 0000000..420687c --- /dev/null +++ b/apps/web/src/pages/ServingPage.tsx @@ -0,0 +1,484 @@ +import { useGameStore } from '@/store'; +import { + formatNumber, formatPercent, + type TrafficPriority, type OverflowBehavior, type RoutingStrategy, + TRAFFIC_PRIORITIES, +} from '@ai-tycoon/shared'; +import { + Activity, Shield, Clock, CheckCircle, XCircle, Layers, + AlertTriangle, Zap, Server, ArrowRight, +} from 'lucide-react'; + +const TIER_COLORS: Record = { + 'enterprise': 'text-purple-400', + 'api-paid': 'text-blue-400', + 'consumer-paid': 'text-green-400', + 'api-free': 'text-yellow-400', + 'consumer-free': 'text-surface-400', +}; + +const TIER_BG: Record = { + 'enterprise': 'bg-purple-500/20', + 'api-paid': 'bg-blue-500/20', + 'consumer-paid': 'bg-green-500/20', + 'api-free': 'bg-yellow-500/20', + 'consumer-free': 'bg-surface-500/20', +}; + +const TIER_LABELS: Record = { + 'enterprise': 'Enterprise', + 'api-paid': 'API Paid', + 'consumer-paid': 'Consumer Paid', + 'api-free': 'API Free', + 'consumer-free': 'Consumer Free', +}; + +const OVERFLOW_OPTIONS: { value: OverflowBehavior; label: string }[] = [ + { value: 'queue', label: 'Queue' }, + { value: 'reject', label: 'Reject' }, + { value: 'degrade', label: 'Degrade' }, +]; + +const ROUTING_OPTIONS: { value: RoutingStrategy; label: string; desc: string }[] = [ + { value: 'quality-first', label: 'Quality First', desc: 'Best model first — maximizes quality' }, + { value: 'balanced', label: 'Balanced', desc: 'Adapts to load — quality when idle, speed when busy' }, + { value: 'speed-first', label: 'Speed First', desc: 'Fastest model first — maximizes throughput' }, +]; + +function MetricCard({ icon: Icon, label, value, sub, color }: { + icon: typeof Activity; label: string; value: string; sub?: string; color: string; +}) { + return ( +
+
+ + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function PipelineFlow() { + const sm = useGameStore(s => s.market.servingMetrics); + const tiers = sm.tierMetrics; + + return ( +
+

+ + Request Pipeline +

+
+ + + + + + + + + + + + + + {TRAFFIC_PRIORITIES.map(tier => { + const m = tiers[tier]; + if (!m || m.demandTokens === 0) return ( + + + + + + + + + + ); + return ( + + + + + + + + + + ); + })} + +
TierDemandServedQueuedRejectedDegradedQuality
{TIER_LABELS[tier]}
{TIER_LABELS[tier]}{formatNumber(m.demandTokens)}{formatNumber(m.servedTokens)}{m.queuedTokens > 0 ? formatNumber(m.queuedTokens) : '—'}{m.rejectedTokens > 0 ? formatNumber(m.rejectedTokens) : '—'}{m.degradedTokens > 0 ? formatNumber(m.degradedTokens) : '—'}{formatPercent(m.avgQualityDelivered)}
+
+
+ ); +} + +function ModelFleetPanel() { + const utilization = useGameStore(s => s.market.servingMetrics.modelUtilization); + + if (utilization.length === 0) { + return ( +
+

+ + Model Fleet +

+

No models deployed. Train and deploy models to start serving requests.

+
+ ); + } + + return ( +
+

+ + 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: [],