diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 558e0de..2f2d0bb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,15 +1,42 @@ +import { useState, useEffect } from 'react'; import { useGameStore } from '@/store'; import { MainLayout } from '@/components/layout/MainLayout'; import { NewGameScreen } from '@/components/game/NewGameScreen'; +import { OfflineCatchUp } from '@/components/game/OfflineCatchUp'; import { useGameLoop } from '@/hooks/useGameLoop'; +import { TICK_INTERVAL_MS } from '@ai-tycoon/shared'; export function App() { const companyName = useGameStore((s) => s.meta.companyName); - useGameLoop(); + const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); + const [catchUpTicks, setCatchUpTicks] = useState(null); + const [catchUpDone, setCatchUpDone] = useState(false); + + useEffect(() => { + if (!companyName || catchUpDone) return; + const elapsed = Date.now() - lastTickTimestamp; + const missed = Math.floor(elapsed / TICK_INTERVAL_MS); + if (missed > 10) { + setCatchUpTicks(missed); + } else { + setCatchUpDone(true); + } + }, [companyName, lastTickTimestamp, catchUpDone]); + + useGameLoop(!catchUpDone); if (!companyName) { return ; } + if (catchUpTicks !== null && !catchUpDone) { + return ( + setCatchUpDone(true)} + /> + ); + } + return ; } diff --git a/apps/web/src/components/common/ToastContainer.tsx b/apps/web/src/components/common/ToastContainer.tsx new file mode 100644 index 0000000..fff5d50 --- /dev/null +++ b/apps/web/src/components/common/ToastContainer.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from 'lucide-react'; +import { useGameStore, type GameNotification } from '@/store'; + +interface Toast extends GameNotification { + exiting: boolean; +} + +export function ToastContainer() { + const notifications = useGameStore((s) => s.notifications); + const dismissNotification = useGameStore((s) => s.dismissNotification); + const [toasts, setToasts] = useState([]); + const [seen, setSeen] = useState(new Set()); + + useEffect(() => { + const newNotifs = notifications.filter(n => !n.read && !seen.has(n.id)); + if (newNotifs.length === 0) return; + + setSeen(prev => { + const next = new Set(prev); + for (const n of newNotifs) next.add(n.id); + return next; + }); + + setToasts(prev => [ + ...newNotifs.map(n => ({ ...n, exiting: false })), + ...prev, + ].slice(0, 5)); + }, [notifications, seen]); + + useEffect(() => { + if (toasts.length === 0) return; + const timer = setTimeout(() => { + setToasts(prev => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + dismissNotification(last.id); + return prev.slice(0, -1); + }); + }, 4000); + return () => clearTimeout(timer); + }, [toasts, dismissNotification]); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+
+
+ {toast.type === 'success' && } + {toast.type === 'warning' && } + {toast.type === 'danger' && } + {toast.type === 'info' && } +
+
+
{toast.title}
+
{toast.message}
+
+ +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/game/OfflineCatchUp.tsx b/apps/web/src/components/game/OfflineCatchUp.tsx new file mode 100644 index 0000000..cf92ea1 --- /dev/null +++ b/apps/web/src/components/game/OfflineCatchUp.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect, useRef } from 'react'; +import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared'; +import { GameEngine } from '@ai-tycoon/game-engine'; +import { useGameStore } from '@/store'; + +interface OfflineResult { + ticksProcessed: number; + revenue: number; + expenses: number; + duration: number; +} + +export function OfflineCatchUp({ missedTicks, onComplete }: { missedTicks: number; onComplete: () => void }) { + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + const processingRef = useRef(false); + + useEffect(() => { + if (processingRef.current) return; + processingRef.current = true; + + const capped = Math.min(missedTicks, MAX_OFFLINE_TICKS); + const batchSize = 100; + let processed = 0; + let totalRevenue = 0; + let totalExpenses = 0; + + const engine = new GameEngine({ + getState: () => { + const s = useGameStore.getState(); + return { + meta: s.meta, economy: s.economy, infrastructure: s.infrastructure, + compute: s.compute, research: s.research, models: s.models, + market: s.market, competitors: s.competitors, talent: s.talent, + data: s.data, reputation: s.reputation, events: s.events, + achievements: s.achievements, + }; + }, + setState: (partial) => useGameStore.getState().updateState(partial), + }); + + function processBatch() { + const end = Math.min(processed + batchSize, capped); + const batchResult = engine.processOfflineTicks(end - processed); + totalRevenue += batchResult.revenue; + totalExpenses += batchResult.expenses; + processed = end; + setProgress(processed / capped); + + if (processed < capped) { + requestAnimationFrame(processBatch); + } else { + setResult({ + ticksProcessed: processed, + revenue: totalRevenue, + expenses: totalExpenses, + duration: missedTicks, + }); + } + } + + requestAnimationFrame(processBatch); + }, [missedTicks]); + + if (result) { + return ( +
+
+

Welcome Back!

+

+ You were away for {formatDuration(result.duration)}. Here's what happened: +

+
+
+ Revenue earned + +{formatMoney(result.revenue)} +
+
+ Expenses + -{formatMoney(result.expenses)} +
+
+ Net + = 0 ? 'text-success' : 'text-danger'}`}> + {formatMoney(result.revenue - result.expenses)} + +
+
+ Time simulated + {formatNumber(result.ticksProcessed)} ticks +
+
+ +
+
+ ); + } + + return ( +
+
+

Catching up...

+

+ Simulating {formatNumber(Math.min(missedTicks, MAX_OFFLINE_TICKS))} ticks +

+
+
+
+ {Math.floor(progress * 100)}% +
+
+ ); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index f1c3479..54f25eb 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -1,10 +1,13 @@ import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; +import { ToastContainer } from '@/components/common/ToastContainer'; import { useGameStore } from '@/store'; import { DashboardPage } from '@/pages/DashboardPage'; import { InfrastructurePage } from '@/pages/InfrastructurePage'; import { ModelsPage } from '@/pages/ModelsPage'; import { SettingsPage } from '@/pages/SettingsPage'; +import { MarketPage } from '@/pages/MarketPage'; +import { FinancePage } from '@/pages/FinancePage'; export function MainLayout() { const activePage = useGameStore((s) => s.activePage); @@ -18,6 +21,7 @@ export function MainLayout() {
+ ); } @@ -27,6 +31,8 @@ function PageRouter({ page }: { page: string }) { case 'dashboard': return ; case 'infrastructure': return ; case 'models': return ; + case 'market': return ; + case 'finance': return ; case 'settings': return ; default: return ; } diff --git a/apps/web/src/hooks/useGameLoop.ts b/apps/web/src/hooks/useGameLoop.ts index 53f60aa..1c40004 100644 --- a/apps/web/src/hooks/useGameLoop.ts +++ b/apps/web/src/hooks/useGameLoop.ts @@ -1,14 +1,15 @@ import { useEffect, useRef } from 'react'; import { GameEngine } from '@ai-tycoon/game-engine'; +import type { TickNotification } from '@ai-tycoon/game-engine'; import { useGameStore } from '@/store'; -export function useGameLoop() { +export function useGameLoop(skip = false) { const engineRef = useRef(null); const companyName = useGameStore((s) => s.meta.companyName); const gameSpeed = useGameStore((s) => s.meta.gameSpeed); useEffect(() => { - if (!companyName) return; + if (!companyName || skip) return; const engine = new GameEngine({ getState: () => { @@ -30,7 +31,22 @@ export function useGameLoop() { }; }, setState: (partial) => { + const notifications = (partial as Record)['_notifications'] as TickNotification[] | undefined; + delete (partial as Record)['_notifications']; + useGameStore.getState().updateState(partial); + + if (notifications?.length) { + const store = useGameStore.getState(); + for (const n of notifications) { + store.addNotification({ + title: n.title, + message: n.message, + type: n.type, + tick: store.meta.tickCount, + }); + } + } }, }); @@ -41,7 +57,7 @@ export function useGameLoop() { engine.stop(); engineRef.current = null; }; - }, [companyName]); + }, [companyName, skip]); useEffect(() => { if (engineRef.current) { diff --git a/apps/web/src/pages/FinancePage.tsx b/apps/web/src/pages/FinancePage.tsx new file mode 100644 index 0000000..680b906 --- /dev/null +++ b/apps/web/src/pages/FinancePage.tsx @@ -0,0 +1,151 @@ +import { useGameStore } from '@/store'; +import { formatMoney, formatPercent } from '@ai-tycoon/shared'; +import { TrendingUp, TrendingDown, DollarSign, PiggyBank, BarChart3 } from 'lucide-react'; +import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid } from 'recharts'; + +export function FinancePage() { + const money = useGameStore((s) => s.economy.money); + const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick); + const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick); + const totalRevenue = useGameStore((s) => s.economy.totalRevenue); + const totalExpenses = useGameStore((s) => s.economy.totalExpenses); + const funding = useGameStore((s) => s.economy.funding); + const history = useGameStore((s) => s.economy.financialHistory); + const infrastructure = useGameStore((s) => s.infrastructure); + const talent = useGameStore((s) => s.talent); + + const netIncome = revenuePerTick - expensesPerTick; + const burnRate = expensesPerTick > revenuePerTick ? expensesPerTick - revenuePerTick : 0; + const runway = burnRate > 0 ? money / burnRate : Infinity; + + const infraCosts = infrastructure.dataCenters.reduce( + (s, dc) => s + dc.energyCostPerTick + dc.maintenanceCostPerTick, 0, + ); + const talentCosts = talent.totalSalaryPerTick; + + return ( +
+

Finance

+ +
+ + = 0 ? 'text-green-400' : 'text-red-400'} + /> + + +
+ +
+
+

Cash Over Time

+ {history.length > 1 ? ( + + + + + + + + + + + [formatMoney(v)]} + /> + + + + ) : ( +
+ Data will appear as time passes +
+ )} +
+ +
+

Income Statement (per second)

+
+
+ Revenue + {formatMoney(revenuePerTick)} +
+
+
+ Infrastructure + -{formatMoney(infraCosts)} +
+
+ Talent + -{formatMoney(talentCosts)} +
+
+ Total Expenses + -{formatMoney(expensesPerTick)} +
+
+
+ Net Income + = 0 ? 'text-success' : 'text-danger'}`}> + {formatMoney(netIncome)} + +
+
+
+
+ +
+

Funding History

+
+
+ Founder Equity: {formatPercent(funding.founderEquity)} +
+
+ Total Raised: {formatMoney(funding.totalRaised)} +
+
+ {funding.completedRounds.length === 0 ? ( +

No funding rounds completed yet.

+ ) : ( +
+ {funding.completedRounds.map((round, i) => ( +
+ {round.type} +
+ {formatMoney(round.amount)} + {formatPercent(round.dilution)} dilution +
+
+ ))} +
+ )} +
+
+ ); +} + +function FinanceCard({ icon: Icon, label, value, color }: { + icon: typeof DollarSign; + label: string; + value: string; + color: string; +}) { + return ( +
+
+ + {label} +
+
{value}
+
+ ); +} diff --git a/apps/web/src/pages/MarketPage.tsx b/apps/web/src/pages/MarketPage.tsx new file mode 100644 index 0000000..a12a20c --- /dev/null +++ b/apps/web/src/pages/MarketPage.tsx @@ -0,0 +1,145 @@ +import { useGameStore } from '@/store'; +import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; +import { Users, Zap, Shield, TrendingUp, Settings2 } from 'lucide-react'; + +export function MarketPage() { + const consumers = useGameStore((s) => s.market.consumers); + const enterprise = useGameStore((s) => s.market.enterprise); + const overloadPolicy = useGameStore((s) => s.market.overloadPolicy); + const productLines = useGameStore((s) => s.models.productLines); + const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization); + const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity); + const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand); + const setProductPricing = useGameStore((s) => s.setProductPricing); + + const chatProduct = productLines.find(p => p.type === 'chat-product'); + const textApi = productLines.find(p => p.type === 'text-api'); + + return ( +
+

Market

+ +
+
+
+ + Subscribers +
+
{formatNumber(consumers.totalSubscribers)}
+
+ Growth: {formatPercent(consumers.growthRatePerTick)}/s + {' '}Churn: {formatPercent(consumers.churnRatePerTick)}/s +
+
+
+
+ + Satisfaction +
+
{formatPercent(consumers.satisfaction)}
+
+
0.7 ? 'bg-success' : consumers.satisfaction > 0.4 ? 'bg-warning' : 'bg-danger'}`} + style={{ width: `${consumers.satisfaction * 100}%` }} + /> +
+
+
+
+ + Load +
+
{formatPercent(inferenceUtil)}
+
+ {formatNumber(tokensDemand)} / {formatNumber(tokensCapacity)} tok/s +
+
+
+ +
+ {chatProduct && ( +
+
+

Chat Product Pricing

+ + {chatProduct.isActive ? 'Active' : 'Inactive'} + +
+
+ +
+ $ + setProductPricing(chatProduct.id, 'subscriptionPrice', Number(e.target.value))} + className="w-24 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={0} + step={5} + /> + /month +
+
+
+ )} + + {textApi && ( +
+
+

API Pricing

+ + {textApi.isActive ? 'Active' : 'Inactive'} + +
+
+
+ + setProductPricing(textApi.id, 'inputTokenPrice', Number(e.target.value))} + 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={0} + step={0.5} + /> +
+
+ + setProductPricing(textApi.id, 'outputTokenPrice', Number(e.target.value))} + 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={0} + step={0.5} + /> +
+
+
+ )} +
+ +
+

+ + API Contracts +

+ {enterprise.activeContracts.length === 0 ? ( +

No enterprise contracts yet. Improve your model quality and reputation to attract enterprise customers.

+ ) : ( +
+ {enterprise.activeContracts.map(c => ( +
+
+
{c.customerName}
+
{formatNumber(c.tokensPerTick)} tok/s ยท SLA: {formatPercent(c.slaUptime)}
+
+
{formatMoney(c.pricePerMToken)}/M tok
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/game-engine/src/index.ts b/packages/game-engine/src/index.ts index f220bc3..a5f2bb1 100644 --- a/packages/game-engine/src/index.ts +++ b/packages/game-engine/src/index.ts @@ -1,2 +1,3 @@ export { GameEngine } from './engine'; export { processTick } from './tick'; +export type { TickNotification } from './tick'; diff --git a/packages/game-engine/src/systems/marketSystem.ts b/packages/game-engine/src/systems/marketSystem.ts index 5598fbb..52931db 100644 --- a/packages/game-engine/src/systems/marketSystem.ts +++ b/packages/game-engine/src/systems/marketSystem.ts @@ -10,6 +10,7 @@ export interface MarketTickResult { marketState: MarketState; apiRevenue: number; subscriptionRevenue: number; + totalTokenDemand: number; } export function processMarket(state: GameState, compute: ComputeState): MarketTickResult { @@ -21,40 +22,68 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi const chatProduct = state.models.productLines.find(p => p.type === 'chat-product'); const textApi = state.models.productLines.find(p => p.type === 'text-api'); + // --- Consumer market (subscription product) --- const consumers = { ...state.market.consumers }; + let subscriptionRevenue = 0; + if (chatProduct?.isActive && bestModel) { - const growthRate = CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER; - const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction)); + const priceAttractiveness = Math.max(0, 1 - chatProduct.pricing.subscriptionPrice / 100); + const growthRate = (CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER) * (0.5 + priceAttractiveness * 0.5); + const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction) * 2); + consumers.growthRatePerTick = growthRate; consumers.churnRatePerTick = churnRate; + const newSubs = consumers.totalSubscribers * growthRate; const lostSubs = consumers.totalSubscribers * churnRate; consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs); - if (consumers.totalSubscribers < 10 && modelQuality > 0) { - consumers.totalSubscribers += 1; + if (consumers.totalSubscribers < 50 && modelQuality > 0.1) { + consumers.totalSubscribers += 2 + modelQuality * 5; } + const loadPenalty = compute.inferenceUtilization > 0.9 + ? (compute.inferenceUtilization - 0.9) * 5 + : 0; consumers.satisfaction = Math.min(1, Math.max(0, - 0.3 + modelQuality * 0.5 + (1 - compute.inferenceUtilization) * 0.2, + 0.3 + modelQuality * 0.5 + (1 - Math.min(1, compute.inferenceUtilization)) * 0.2 - loadPenalty, )); + + consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0; + + subscriptionRevenue = consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 2592000); } - const subscriptionRevenue = chatProduct?.isActive - ? consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 30 / 24 / 3600) - : 0; - + // --- B2B API market (organic demand based on model quality + reputation) --- const enterprise = { ...state.market.enterprise }; let apiRevenue = 0; + let organicApiTokens = 0; + if (textApi?.isActive && bestModel) { - let totalTokens = 0; + const reputationFactor = state.reputation.score / 100; + const qualityFactor = modelQuality; + const priceFactor = Math.max(0.1, 1 - (textApi.pricing.outputTokenPrice / 20)); + + organicApiTokens = Math.floor( + qualityFactor * reputationFactor * priceFactor * 50000 * (1 + state.meta.tickCount * 0.0001), + ); + + let contractTokens = 0; for (const contract of enterprise.activeContracts) { - totalTokens += contract.tokensPerTick; + contractTokens += contract.tokensPerTick; apiRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken; } - enterprise.totalApiCallsPerTick = totalTokens / API_TOKENS_PER_REQUEST; + + const totalApiTokens = organicApiTokens + contractTokens; + apiRevenue += (organicApiTokens / 1_000_000) * textApi.pricing.outputTokenPrice; + + enterprise.totalApiCallsPerTick = totalApiTokens / API_TOKENS_PER_REQUEST; } + const totalTokenDemand = organicApiTokens + + consumers.totalSubscribers * 100 + + enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0); + return { marketState: { ...state.market, @@ -63,5 +92,6 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi }, apiRevenue, subscriptionRevenue, + totalTokenDemand, }; } diff --git a/packages/game-engine/src/systems/modelSystem.ts b/packages/game-engine/src/systems/modelSystem.ts new file mode 100644 index 0000000..79d2315 --- /dev/null +++ b/packages/game-engine/src/systems/modelSystem.ts @@ -0,0 +1,92 @@ +import type { GameState, ModelsState, TrainedModel, ModelCapabilities } from '@ai-tycoon/shared'; + +export interface ModelTickResult { + modelsState: ModelsState; + modelCompleted: TrainedModel | null; +} + +export function processModels(state: GameState): ModelTickResult { + const active = state.models.activeTraining; + if (!active) { + return { modelsState: state.models, modelCompleted: null }; + } + + const researcherBoost = state.talent.departments.research.headcount * + state.talent.departments.research.effectiveness; + const engineerBoost = state.talent.departments.engineering.headcount * + state.talent.departments.engineering.effectiveness; + const speedMultiplier = 1 + (researcherBoost + engineerBoost) * 0.05; + + const newProgress = active.progressTicks + speedMultiplier; + + if (newProgress >= active.totalTicks) { + const model = createTrainedModel(active.modelName, active.generation, active.allocatedCompute, active.allocatedDataTokens, state); + + return { + modelsState: { + ...state.models, + trainedModels: [...state.models.trainedModels, model], + activeTraining: null, + }, + modelCompleted: model, + }; + } + + return { + modelsState: { + ...state.models, + activeTraining: { ...active, progressTicks: newProgress }, + }, + modelCompleted: null, + }; +} + +function createTrainedModel( + name: string, + generation: number, + compute: number, + dataTokens: number, + state: GameState, +): TrainedModel { + const computeFactor = Math.log10(1 + compute) * 15; + const dataFactor = Math.log10(1 + dataTokens / 1e8) * 10; + const researchBonus = state.research.completedResearch.length * 3; + const efficiencyBonus = state.research.completedResearch.filter(r => r.includes('efficiency')).length * 5; + + const baseCapability = Math.min(95, computeFactor + dataFactor + researchBonus + efficiencyBonus); + + const researcherQuality = state.talent.departments.research.effectiveness; + const capabilities: ModelCapabilities = { + reasoning: clamp(baseCapability * (0.8 + Math.random() * 0.4) * (1 + researcherQuality * 0.2)), + coding: clamp(baseCapability * (0.7 + Math.random() * 0.5)), + creative: clamp(baseCapability * (0.6 + Math.random() * 0.6)), + multimodal: clamp(baseCapability * (0.3 + Math.random() * 0.3)), + agents: clamp(baseCapability * (0.2 + Math.random() * 0.3)), + speed: Math.max(1, 100 - compute * 0.5 + efficiencyBonus * 2), + }; + + const benchmarkScore = (capabilities.reasoning * 0.3 + capabilities.coding * 0.25 + + capabilities.creative * 0.2 + capabilities.multimodal * 0.15 + capabilities.agents * 0.1); + + const safetyScore = 50 + Math.random() * 20; + + const parameterCount = Math.pow(10, generation) * (0.5 + Math.random()); + + return { + id: crypto.randomUUID(), + name, + generation, + parameterCount, + trainingDataSize: dataTokens, + capabilities, + safetyScore, + benchmarkScore, + tuning: { preset: 'helpful-safe' }, + isDeployed: false, + trainedAtTick: state.meta.tickCount, + }; +} + +function clamp(n: number): number { + return Math.min(100, Math.max(0, n)); +} diff --git a/packages/game-engine/src/systems/talentSystem.ts b/packages/game-engine/src/systems/talentSystem.ts new file mode 100644 index 0000000..89d74fb --- /dev/null +++ b/packages/game-engine/src/systems/talentSystem.ts @@ -0,0 +1,22 @@ +import type { GameState, TalentState } from '@ai-tycoon/shared'; + +const SALARY_PER_HEADCOUNT_PER_TICK = 0.03; + +export function processTalent(state: GameState): TalentState { + const departments = { ...state.talent.departments }; + + let totalSalary = 0; + for (const [id, dept] of Object.entries(departments)) { + totalSalary += dept.headcount * SALARY_PER_HEADCOUNT_PER_TICK; + totalSalary += dept.budget / 2592000; + } + + for (const hire of state.talent.keyHires) { + totalSalary += hire.salary / 2592000; + } + + return { + ...state.talent, + totalSalaryPerTick: totalSalary, + }; +} diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts index 3d8ac4e..b5994f8 100644 --- a/packages/game-engine/src/tick.ts +++ b/packages/game-engine/src/tick.ts @@ -3,20 +3,56 @@ import { processEconomy } from './systems/economySystem'; import { processInfrastructure } from './systems/infrastructureSystem'; import { processCompute } from './systems/computeSystem'; import { processResearch } from './systems/researchSystem'; +import { processModels } from './systems/modelSystem'; import { processMarket } from './systems/marketSystem'; import { processReputation } from './systems/reputationSystem'; +import { processTalent } from './systems/talentSystem'; + +export interface TickResult { + state: Partial; + notifications: TickNotification[]; +} + +export interface TickNotification { + title: string; + message: string; + type: 'info' | 'success' | 'warning' | 'danger'; +} export function processTick(state: GameState): Partial { + const notifications: TickNotification[] = []; + const infrastructure = processInfrastructure(state); + + const stateWithInfra = { ...state, infrastructure }; + const modelResult = processModels(stateWithInfra); + + if (modelResult.modelCompleted) { + notifications.push({ + title: 'Training Complete', + message: `${modelResult.modelCompleted.name} is ready! Benchmark: ${modelResult.modelCompleted.benchmarkScore.toFixed(1)}/100`, + type: 'success', + }); + } + + const stateWithModels = { ...stateWithInfra, models: modelResult.modelsState }; + const market = processMarket(stateWithModels, state.compute); + const compute = processCompute(state, infrastructure); - const research = processResearch(state, compute); - const market = processMarket(state, compute); - const reputation = processReputation(state); - const economy = processEconomy(state, market, infrastructure); + compute.tokensPerSecondDemand = market.totalTokenDemand; + compute.inferenceUtilization = compute.tokensPerSecondCapacity > 0 + ? Math.min(1, market.totalTokenDemand / compute.tokensPerSecondCapacity) + : 0; + + const talent = processTalent(stateWithModels); + const stateWithTalent = { ...stateWithModels, talent }; + const research = processResearch(stateWithTalent, compute); + const reputation = processReputation(stateWithTalent); + const economy = processEconomy(stateWithTalent, market, infrastructure); const tickCount = state.meta.tickCount + 1; - return { + const result: Partial = { meta: { ...state.meta, tickCount, @@ -27,7 +63,13 @@ export function processTick(state: GameState): Partial { infrastructure, compute, research, + models: modelResult.modelsState, market: market.marketState, + talent, reputation, }; + + (result as Record)['_notifications'] = notifications; + + return result; }