diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 47576b8..0b95d1c 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -1,27 +1,94 @@ -import { useGameStore } from '@/store'; -import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared'; +import type React from 'react'; +import { useGameStore, type ActivePage } from '@/store'; +import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared'; +import type { Era } from '@ai-tycoon/shared'; +import { TECH_TREE } from '@ai-tycoon/game-engine'; import { - DollarSign, Server, Brain, Users, TrendingUp, - TrendingDown, Minus, Cpu, Zap, Shield, ChevronRight, + DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users, + Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2, + HardDrive, Clock, } from 'lucide-react'; -import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'; +import { + XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart, Line, LineChart, +} from 'recharts'; import { TutorialHint } from '@/components/game/TutorialHint'; +const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; + +function isEraAtLeast(current: Era, threshold: Era): boolean { + return ERA_ORDER.indexOf(current) >= ERA_ORDER.indexOf(threshold); +} + export function DashboardPage() { const money = useGameStore((s) => s.economy.money); const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick); const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick); + const financialHistory = useGameStore((s) => s.economy.financialHistory); const totalFlops = useGameStore((s) => s.infrastructure.totalFlops); const totalDCs = useGameStore((s) => s.infrastructure.totalDataCenterCount); + const clusters = useGameStore((s) => s.infrastructure.clusters); const baseModels = useGameStore((s) => s.models.baseModels); const activePipelines = useGameStore((s) => s.models.activeTrainingPipelines); const subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers); + const satisfaction = useGameStore((s) => s.market.consumerTiers.satisfaction); const reputation = useGameStore((s) => s.reputation.score); + const reputationHistory = useGameStore((s) => s.reputation.reputationHistory); const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization); - const financialHistory = useGameStore((s) => s.economy.financialHistory); + const effectiveInferenceFlops = useGameStore((s) => s.compute.effectiveInferenceFlops); + const trainingAllocation = useGameStore((s) => s.compute.trainingAllocation); + const computeHistory = useGameStore((s) => s.compute.computeHistory); + const totalUptime = useGameStore((s) => s.infrastructure.totalUptime); + const modelFreshness = useGameStore((s) => s.market.obsolescence.playerModelFreshness); const era = useGameStore((s) => s.meta.currentEra); + const activeResearch = useGameStore((s) => s.research.activeResearch); + const tam = useGameStore((s) => s.market.tam); + const competitors = useGameStore((s) => s.competitors.rivals); const netIncome = revenuePerTick - expensesPerTick; + const scaleup = isEraAtLeast(era, 'scaleup'); + const hasDeployedModel = baseModels.some(m => m.isDeployed); + + const bestDeployedCapability = baseModels + .filter(m => m.isDeployed) + .reduce((best, m) => Math.max(best, m.rawCapability), 0); + + const revenueTrend = (() => { + if (financialHistory.length < 6) return 'neutral' as const; + const recent = financialHistory[financialHistory.length - 1].revenue; + const earlier = financialHistory[financialHistory.length - 6].revenue; + if (recent > earlier * 1.01) return 'up' as const; + if (recent < earlier * 0.99) return 'down' as const; + return 'neutral' as const; + })(); + + const repTrend = (() => { + if (reputationHistory.length < 2) return 'neutral' as const; + const last = reputationHistory[reputationHistory.length - 1].score; + const prev = reputationHistory[reputationHistory.length - 2].score; + if (last > prev) return 'up' as const; + if (last < prev) return 'down' as const; + return 'neutral' as const; + })(); + + const constructingDCs = clusters.reduce((count, cluster) => { + for (const campus of cluster.campuses) { + for (const dc of campus.dataCenters) { + if (dc.status === 'constructing') count++; + } + } + return count; + }, 0); + + const deployingRacks = clusters.reduce((count, cluster) => { + for (const campus of cluster.campuses) { + for (const dc of campus.dataCenters) { + count += dc.deploymentCohorts.length; + } + } + return count; + }, 0); + + const navigate = useGameStore.getState().setActivePage; return (
@@ -45,7 +112,8 @@ export function DashboardPage() { )} -
+ {/* Section 1: Stat Cards */} +
= 0 ? '+' : ''}${formatMoney(netIncome)}/s`} trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'} color="text-green-400" - onClick={() => useGameStore.getState().setActivePage('finance')} + onClick={() => navigate('finance')} /> + {scaleup && ( + navigate('finance')} + /> + )} useGameStore.getState().setActivePage('infrastructure')} + icon={Cpu} + label="Compute" + value={`${formatPercent(inferenceUtil)} util`} + subValue={`${formatNumber(effectiveInferenceFlops)} FLOPS`} + trend={inferenceUtil > 0.9 ? 'down' : inferenceUtil < 0.5 ? 'up' : 'neutral'} + color="text-cyan-400" + onClick={() => navigate('infrastructure')} /> p.status === 'active').length > 0 ? `Training: ${activePipelines.filter(p => p.status === 'active').length} active` : 'Idle'} + subValue={ + activePipelines.filter(p => p.status === 'active').length > 0 + ? `Training: ${activePipelines.filter(p => p.status === 'active').length} active` + : hasDeployedModel + ? `Best: ${bestDeployedCapability.toFixed(1)}` + : 'Idle' + } color="text-purple-400" - onClick={() => useGameStore.getState().setActivePage('models')} - /> - useGameStore.getState().setActivePage('market')} + onClick={() => navigate('models')} /> + {scaleup && ( + navigate('market')} + /> + )} + {scaleup && ( + + )}
-
+ {/* Section 2: Primary Charts */} +
-

Revenue Over Time

- {financialHistory.length > 1 ? ( +
+

Compute: Capacity vs Demand

+
+ Capacity + Demand +
+
+ {computeHistory.length > 1 ? ( - + - - - + + + + + + + - formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> + `${formatNumber(v)}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> [formatMoney(value), 'Revenue']} + formatter={(value: number, name: string) => { + const label = name === 'tokensPerSecondCapacity' ? 'Capacity' : 'Demand'; + return [`${formatNumber(value)} tok/s`, label]; + }} /> - + + ) : (
- No data yet — start earning revenue + No compute data yet — deploy racks to start tracking
)}
+
+
+

Revenue vs Expenses

+
+ Revenue + Expenses +
+
+ {financialHistory.length > 1 ? ( + + + + formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> + [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']} + /> + + + + + ) : ( +
+ No financial data yet — start earning revenue +
+ )} +
+
+ + {/* Section 3: System Health + Active Operations */} +

System Status

@@ -120,80 +264,139 @@ export function DashboardPage() { bar={inferenceUtil} barColor={inferenceUtil > 0.9 ? 'bg-danger' : inferenceUtil > 0.7 ? 'bg-warning' : 'bg-success'} /> + + {scaleup && ( + + )} + {scaleup && hasDeployedModel && ( + + )} 70 ? 'bg-success' : reputation > 40 ? 'bg-warning' : 'bg-danger'} - /> -
-
- -
-
-

Subscribers Over Time

- {(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? ( - - - - - - - - - - formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> - [formatNumber(value), 'Subscribers']} - /> - - - - ) : ( -
- No subscriber data yet -
- )} -
-

Reputation Over Time

- {(useGameStore.getState().reputation.reputationHistory?.length ?? 0) > 1 ? ( - - - - - - - - - - `${v}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> - [`${value}/100`, 'Reputation']} - /> - - - - ) : ( -
- No reputation data yet -
- )} +

Active Operations

+
+ {/* Section 4: Secondary Charts (scaleup+) */} + {scaleup && ( +
+
+

Subscribers Over Time

+ {(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? ( + + + + + + + + + + formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} /> + [formatNumber(value), 'Subscribers']} + /> + + + + ) : ( +
+ No subscriber data yet +
+ )} +
+ +
+

Market Position

+
+ + + + +
+
+
+ )} + + {/* Section 5: Competitor Snapshot (scaleup+) */} + {scaleup && competitors.filter(r => r.status === 'active').length > 0 && ( +
+

Competitors

+
+ {competitors.filter(r => r.status === 'active').map(rival => ( +
+
+ {rival.name} + + {rival.archetype.replace('-', ' ')} + +
+
+
+ Capability + {rival.estimatedCapability.toFixed(1)} +
+
+
+
+
+
+
+
+ You: {bestDeployedCapability.toFixed(1)} + Them: {rival.estimatedCapability.toFixed(1)} +
+
+
+ Latest: {rival.latestModelName || 'None'} +
+
+ ))} +
+
+ )} + {totalDCs === 0 && (

Get Started

@@ -201,7 +404,7 @@ export function DashboardPage() { Build your first data center to start training AI models.

, + ); + } + + if (activeResearch) { + const researchName = TECH_TREE.find(n => n.id === activeResearch.researchId)?.name ?? activeResearch.researchId; + const progress = activeResearch.progressTicks / activeResearch.totalTicks; + const eta = activeResearch.totalTicks - activeResearch.progressTicks; + items.push( + navigate('research')} + />, + ); + } + + if (constructingDCs > 0) { + items.push( +
navigate('infrastructure')}> + + {constructingDCs} DC{constructingDCs > 1 ? 's' : ''} building +
, + ); + } + + if (deployingRacks > 0) { + items.push( +
navigate('infrastructure')}> + + {deployingRacks} rack batch{deployingRacks > 1 ? 'es' : ''} deploying +
, + ); + } + + if (items.length === 0) { + return ( +
+
+ + All systems idle +
+
+ ); + } + + return
{items}
; +} + +function OperationRow({ + icon: Icon, + label, + detail, + progress, + eta, + onClick, +}: { + icon: typeof Brain; + label: string; + detail: string; + progress: number; + eta: number; + onClick: () => void; +}) { + return ( +
+
+
+ + {label} + {detail} +
+ {formatDuration(eta)} +
+
+
+
+
+ ); +} + +function MarketShareBar({ + label, + color, + segment, +}: { + label: string; + color: string; + segment: { shares: { playerId: string; sharePercent: number }[] }; +}) { + const playerShare = segment.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0; + return ( +
+
+ {label} + {formatPercent(playerShare / 100)} +
+
+
+
+
+ ); +} + function StatCard({ icon: Icon, label, value, subValue, trend, color, onClick, }: { diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 0a9ccc1..517503a 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -1430,6 +1430,11 @@ export const useGameStore = create()( return rest; }, migrate: (_persisted, version) => { + if (version === 9) { + const s = _persisted as Record; + const compute = s.compute as Record; + return { ...s, compute: { ...compute, computeHistory: [] } } as unknown as Store; + } if (version < SAVE_VERSION) { return { ...initialGameState, @@ -1437,7 +1442,7 @@ export const useGameStore = create()( notifications: [{ id: uuid(), title: 'Save Reset', - message: 'Your save was reset due to a major market system overhaul — shared TAM competition, multi-tier pricing, enterprise pipeline, developer ecosystem, and technology obsolescence!', + message: 'Your save was reset due to a major game update. Start fresh and build your AI empire!', type: 'info' as const, tick: 0, read: false, diff --git a/packages/game-engine/src/systems/computeSystem.ts b/packages/game-engine/src/systems/computeSystem.ts index e1d521a..106d811 100644 --- a/packages/game-engine/src/systems/computeSystem.ts +++ b/packages/game-engine/src/systems/computeSystem.ts @@ -1,5 +1,5 @@ import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared'; -import { FLOPS_TO_TOKENS_MULTIPLIER } from '@ai-tycoon/shared'; +import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared'; import type { ResearchBonuses } from './researchBonuses'; export interface CapacityResult { @@ -43,19 +43,36 @@ export function computeCapacity(state: GameState, infrastructure: Infrastructure }; } -export function finalizeCompute(capacity: CapacityResult, totalTokenDemand: number): ComputeState { +export function finalizeCompute(capacity: CapacityResult, totalTokenDemand: number, prevHistory: ComputeState['computeHistory'], tickCount: number): ComputeState { const inferenceUtilization = capacity.tokensPerSecondCapacity > 0 ? Math.min(1, totalTokenDemand / capacity.tokensPerSecondCapacity) : (totalTokenDemand > 0 ? 1 : 0); + const computeHistory = [...prevHistory]; + if (tickCount % COMPUTE_SNAPSHOT_INTERVAL === 0) { + computeHistory.push({ + tick: tickCount, + totalFlops: capacity.totalFlops, + effectiveTrainingFlops: capacity.effectiveTrainingFlops, + effectiveInferenceFlops: capacity.effectiveInferenceFlops, + inferenceUtilization, + tokensPerSecondCapacity: capacity.tokensPerSecondCapacity, + tokensPerSecondDemand: totalTokenDemand, + }); + if (computeHistory.length > MAX_COMPUTE_HISTORY) { + computeHistory.shift(); + } + } + return { ...capacity, tokensPerSecondDemand: totalTokenDemand, inferenceUtilization, + computeHistory, }; } export function processCompute(state: GameState, infrastructure: InfrastructureState): ComputeState { const cap = computeCapacity(state, infrastructure); - return finalizeCompute(cap, state.compute.tokensPerSecondDemand); + return finalizeCompute(cap, state.compute.tokensPerSecondDemand, state.compute.computeHistory, state.meta.tickCount); } diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts index 2011214..54c34a7 100644 --- a/packages/game-engine/src/tick.ts +++ b/packages/game-engine/src/tick.ts @@ -57,7 +57,7 @@ export function processTick(state: GameState): Partial { const capacity = computeCapacity(state, infrastructure, researchBonuses); const market = processMarket(stateWithModels, capacity.tokensPerSecondCapacity, capacity.effectiveInferenceFlops, researchBonuses); - const compute = finalizeCompute(capacity, market.totalTokenDemand); + const compute = finalizeCompute(capacity, market.totalTokenDemand, state.compute.computeHistory, state.meta.tickCount); const talent = processTalent(stateWithModels); const stateWithTalent = { ...stateWithModels, talent }; diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts index 55c303e..56317c8 100644 --- a/packages/shared/src/constants/gameBalance.ts +++ b/packages/shared/src/constants/gameBalance.ts @@ -10,6 +10,8 @@ export const AUTO_SAVE_INTERVAL_TICKS = 60; export const FINANCIAL_SNAPSHOT_INTERVAL = 60; export const MAX_FINANCIAL_HISTORY = 1000; export const MAX_REPUTATION_HISTORY = 500; +export const COMPUTE_SNAPSHOT_INTERVAL = 60; +export const MAX_COMPUTE_HISTORY = 500; export const STARTING_MONEY = 600_000; export const BASE_ENERGY_COST_PER_FLOP = 0.001; diff --git a/packages/shared/src/types/compute.ts b/packages/shared/src/types/compute.ts index 52df024..dec6006 100644 --- a/packages/shared/src/types/compute.ts +++ b/packages/shared/src/types/compute.ts @@ -1,3 +1,13 @@ +export interface ComputeSnapshot { + tick: number; + totalFlops: number; + effectiveTrainingFlops: number; + effectiveInferenceFlops: number; + inferenceUtilization: number; + tokensPerSecondCapacity: number; + tokensPerSecondDemand: number; +} + export interface ComputeState { totalFlops: number; totalTrainingFlops: number; @@ -10,6 +20,7 @@ export interface ComputeState { inferenceUtilization: number; tokensPerSecondCapacity: number; tokensPerSecondDemand: number; + computeHistory: ComputeSnapshot[]; } export const INITIAL_COMPUTE: ComputeState = { @@ -24,4 +35,5 @@ export const INITIAL_COMPUTE: ComputeState = { inferenceUtilization: 0, tokensPerSecondCapacity: 0, tokensPerSecondDemand: 0, + computeHistory: [], }; diff --git a/packages/shared/src/types/gameState.ts b/packages/shared/src/types/gameState.ts index 9002935..05f1502 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 = 9; +export const SAVE_VERSION = 10;