Overhaul dashboard into command center with compute tracking, era-gated sections
CI / build-and-push (push) Successful in 37s

Add compute history time-series (capacity vs demand chart), revenue vs expenses
dual-line chart, enhanced system status (training allocation, network uptime,
model freshness), active operations panel, market position bars, and competitor
snapshot. Stat cards expand from 3 to 6 as player progresses through eras.
Graceful v9→v10 save migration preserves existing games.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:45:16 -04:00
parent 901db02a6b
commit 283c7c7932
7 changed files with 487 additions and 103 deletions
+445 -97
View File
@@ -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 (
<div className="space-y-6">
@@ -45,7 +112,8 @@ export function DashboardPage() {
</TutorialHint>
)}
<div className="grid grid-cols-4 gap-4">
{/* Section 1: Stat Cards */}
<div className={`grid gap-4 ${scaleup ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : 'grid-cols-3'}`}>
<StatCard
icon={DollarSign}
label="Cash"
@@ -53,63 +121,139 @@ export function DashboardPage() {
subValue={`${netIncome >= 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 && (
<StatCard
icon={TrendingUp}
label="Revenue"
value={`${formatMoney(revenuePerTick)}/s`}
subValue={`${formatMoney(revenuePerTick * 3600)}/hr`}
trend={revenueTrend}
color="text-emerald-400"
onClick={() => navigate('finance')}
/>
)}
<StatCard
icon={Server}
label="Data Centers"
value={totalDCs.toString()}
subValue={`${formatNumber(totalFlops)} FLOPS`}
color="text-blue-400"
onClick={() => 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')}
/>
<StatCard
icon={Brain}
label="Models"
value={baseModels.length.toString()}
subValue={activePipelines.filter(p => 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')}
/>
<StatCard
icon={Users}
label="Subscribers"
value={formatNumber(subscribers)}
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumerTiers.satisfaction)}`}
color="text-orange-400"
onClick={() => useGameStore.getState().setActivePage('market')}
onClick={() => navigate('models')}
/>
{scaleup && (
<StatCard
icon={Users}
label="Subscribers"
value={formatNumber(subscribers)}
subValue={`Satisfaction: ${formatPercent(satisfaction)}`}
color="text-orange-400"
onClick={() => navigate('market')}
/>
)}
{scaleup && (
<StatCard
icon={Shield}
label="Reputation"
value={`${reputation}/100`}
trend={repTrend}
color="text-violet-400"
/>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Section 2: Primary Charts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Revenue Over Time</h3>
{financialHistory.length > 1 ? (
<div className="flex items-center gap-4 mb-4">
<h3 className="text-sm font-medium text-surface-400">Compute: Capacity vs Demand</h3>
<div className="flex items-center gap-3 ml-auto text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-400" />Capacity</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400" />Demand</span>
</div>
</div>
{computeHistory.length > 1 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={financialHistory}>
<AreaChart data={computeHistory}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
<linearGradient id="computeCapGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" stopOpacity={0.3} />
<stop offset="100%" stopColor="#22d3ee" stopOpacity={0} />
</linearGradient>
<linearGradient id="computeDemGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="tick" hide />
<YAxis width={50} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<YAxis width={55} tickFormatter={(v: number) => `${formatNumber(v)}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#94a3b8' }}
formatter={(value: number) => [formatMoney(value), 'Revenue']}
formatter={(value: number, name: string) => {
const label = name === 'tokensPerSecondCapacity' ? 'Capacity' : 'Demand';
return [`${formatNumber(value)} tok/s`, label];
}}
/>
<Area type="monotone" dataKey="revenue" stroke="#22c55e" fill="url(#revenueGrad)" />
<Area type="monotone" dataKey="tokensPerSecondCapacity" stroke="#22d3ee" fill="url(#computeCapGrad)" />
<Area type="monotone" dataKey="tokensPerSecondDemand" stroke="#f59e0b" fill="url(#computeDemGrad)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
No data yet start earning revenue
No compute data yet deploy racks to start tracking
</div>
)}
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center gap-4 mb-4">
<h3 className="text-sm font-medium text-surface-400">Revenue vs Expenses</h3>
<div className="flex items-center gap-3 ml-auto text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />Revenue</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />Expenses</span>
</div>
</div>
{financialHistory.length > 1 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={financialHistory}>
<XAxis dataKey="tick" hide />
<YAxis width={55} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#94a3b8' }}
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
/>
<Line type="monotone" dataKey="revenue" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="expenses" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
No financial data yet start earning revenue
</div>
)}
</div>
</div>
{/* Section 3: System Health + Active Operations */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">System Status</h3>
<div className="space-y-4">
@@ -120,80 +264,139 @@ export function DashboardPage() {
bar={inferenceUtil}
barColor={inferenceUtil > 0.9 ? 'bg-danger' : inferenceUtil > 0.7 ? 'bg-warning' : 'bg-success'}
/>
<StatusRow
icon={Zap}
label="Training Allocation"
value={formatPercent(trainingAllocation)}
bar={trainingAllocation}
barColor="bg-cyan-500"
/>
{scaleup && (
<StatusRow
icon={Wifi}
label="Network Uptime"
value={formatPercent(totalUptime)}
bar={totalUptime}
barColor={totalUptime < 0.95 ? 'bg-danger' : totalUptime < 0.99 ? 'bg-warning' : 'bg-success'}
/>
)}
{scaleup && hasDeployedModel && (
<StatusRow
icon={Sparkles}
label="Model Freshness"
value={formatPercent(modelFreshness)}
bar={modelFreshness}
barColor={modelFreshness < 0.3 ? 'bg-danger' : modelFreshness < 0.6 ? 'bg-warning' : 'bg-success'}
/>
)}
<StatusRow
icon={Shield}
label="Reputation"
value={`${reputation}/100`}
bar={reputation / 100}
barColor={reputation > 70 ? 'bg-success' : reputation > 40 ? 'bg-warning' : 'bg-danger'}
/>
<StatusRow
icon={Zap}
label="Compute"
value={`${formatNumber(totalFlops)} FLOPS`}
bar={Math.min(1, totalFlops / 100)}
barColor="bg-accent"
barColor={reputation < 40 ? 'bg-danger' : reputation < 70 ? 'bg-warning' : 'bg-success'}
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
<defs>
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="tick" hide />
<YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
/>
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
No subscriber data yet
</div>
)}
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Reputation Over Time</h3>
{(useGameStore.getState().reputation.reputationHistory?.length ?? 0) > 1 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={useGameStore.getState().reputation.reputationHistory}>
<defs>
<linearGradient id="repGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a855f7" stopOpacity={0.3} />
<stop offset="100%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="tick" hide />
<YAxis width={30} domain={[0, 100]} tickFormatter={(v: number) => `${v}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number) => [`${value}/100`, 'Reputation']}
/>
<Area type="monotone" dataKey="score" stroke="#a855f7" fill="url(#repGrad)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
No reputation data yet
</div>
)}
<h3 className="text-sm font-medium text-surface-400 mb-4">Active Operations</h3>
<ActiveOperations
pipelines={activePipelines}
activeResearch={activeResearch}
constructingDCs={constructingDCs}
deployingRacks={deployingRacks}
navigate={navigate}
/>
</div>
</div>
{/* Section 4: Secondary Charts (scaleup+) */}
{scaleup && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
<defs>
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="tick" hide />
<YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
/>
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
No subscriber data yet
</div>
)}
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Market Position</h3>
<div className="space-y-3">
<MarketShareBar label="Consumer" color="bg-orange-400" segment={tam.segments.consumer} />
<MarketShareBar label="Developer" color="bg-blue-400" segment={tam.segments.developer} />
<MarketShareBar label="Enterprise" color="bg-purple-400" segment={tam.segments.enterprise} />
<MarketShareBar label="Government" color="bg-surface-400" segment={tam.segments.government} />
</div>
</div>
</div>
)}
{/* Section 5: Competitor Snapshot (scaleup+) */}
{scaleup && competitors.filter(r => r.status === 'active').length > 0 && (
<div>
<h3 className="text-sm font-medium text-surface-400 mb-3">Competitors</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{competitors.filter(r => r.status === 'active').map(rival => (
<div key={rival.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{rival.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-700 text-surface-400">
{rival.archetype.replace('-', ' ')}
</span>
</div>
<div className="mb-2">
<div className="flex items-center justify-between text-xs text-surface-400 mb-1">
<span>Capability</span>
<span className="font-mono">{rival.estimatedCapability.toFixed(1)}</span>
</div>
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${Math.min(100, bestDeployedCapability)}%` }}
/>
</div>
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden mt-1">
<div
className="h-full bg-danger/70 rounded-full"
style={{ width: `${Math.min(100, rival.estimatedCapability)}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-surface-500 mt-0.5">
<span>You: {bestDeployedCapability.toFixed(1)}</span>
<span>Them: {rival.estimatedCapability.toFixed(1)}</span>
</div>
</div>
<div className="text-xs text-surface-500">
Latest: <span className="text-surface-300">{rival.latestModelName || 'None'}</span>
</div>
</div>
))}
</div>
</div>
)}
{totalDCs === 0 && (
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 text-center">
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
@@ -201,7 +404,7 @@ export function DashboardPage() {
Build your first data center to start training AI models.
</p>
<button
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
onClick={() => navigate('infrastructure')}
className="bg-accent hover:bg-accent-dark text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
Build Data Center
@@ -212,6 +415,151 @@ export function DashboardPage() {
);
}
function ActiveOperations({
pipelines,
activeResearch,
constructingDCs,
deployingRacks,
navigate,
}: {
pipelines: { modelName: string; status: string; currentStage: string; stages: Record<string, { progressTicks: number; totalTicks: number; isComplete: boolean }> }[];
activeResearch: { researchId: string; progressTicks: number; totalTicks: number } | null;
constructingDCs: number;
deployingRacks: number;
navigate: (page: ActivePage) => void;
}) {
const activePipes = pipelines.filter(p => p.status === 'active');
const items: React.ReactNode[] = [];
for (let i = 0; i < Math.min(2, activePipes.length); i++) {
const p = activePipes[i];
const stage = p.stages[p.currentStage as keyof typeof p.stages];
const progress = stage ? stage.progressTicks / stage.totalTicks : 0;
const eta = stage ? stage.totalTicks - stage.progressTicks : 0;
items.push(
<OperationRow
key={`train-${i}`}
icon={Brain}
label={p.modelName}
detail={p.currentStage}
progress={progress}
eta={eta}
onClick={() => navigate('models')}
/>,
);
}
if (activePipes.length > 2) {
items.push(
<button key="more-train" onClick={() => navigate('models')} className="text-xs text-accent hover:text-accent-light transition-colors">
+{activePipes.length - 2} more training...
</button>,
);
}
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(
<OperationRow
key="research"
icon={FlaskConical}
label={researchName}
detail="researching"
progress={progress}
eta={eta}
onClick={() => navigate('research')}
/>,
);
}
if (constructingDCs > 0) {
items.push(
<div key="construction" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
<Building2 size={14} className="text-surface-400" />
<span className="text-sm text-surface-300">{constructingDCs} DC{constructingDCs > 1 ? 's' : ''} building</span>
</div>,
);
}
if (deployingRacks > 0) {
items.push(
<div key="deploy" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
<HardDrive size={14} className="text-surface-400" />
<span className="text-sm text-surface-300">{deployingRacks} rack batch{deployingRacks > 1 ? 'es' : ''} deploying</span>
</div>,
);
}
if (items.length === 0) {
return (
<div className="h-full flex items-center justify-center text-surface-500 text-sm min-h-[100px]">
<div className="text-center">
<Clock size={20} className="mx-auto mb-2 text-surface-600" />
All systems idle
</div>
</div>
);
}
return <div className="space-y-3">{items}</div>;
}
function OperationRow({
icon: Icon,
label,
detail,
progress,
eta,
onClick,
}: {
icon: typeof Brain;
label: string;
detail: string;
progress: number;
eta: number;
onClick: () => void;
}) {
return (
<div className="cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 py-1 transition-colors" onClick={onClick}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Icon size={14} className="text-surface-400" />
<span className="text-sm text-surface-300 truncate max-w-[140px]">{label}</span>
<span className="text-[10px] text-surface-500">{detail}</span>
</div>
<span className="text-[10px] font-mono text-surface-500">{formatDuration(eta)}</span>
</div>
<div className="h-1 bg-surface-800 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all duration-500" style={{ width: `${Math.min(100, progress * 100)}%` }} />
</div>
</div>
);
}
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 (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-surface-400">{label}</span>
<span className="text-xs font-mono text-surface-300">{formatPercent(playerShare / 100)}</span>
</div>
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${color}`} style={{ width: `${Math.min(100, playerShare)}%` }} />
</div>
</div>
);
}
function StatCard({
icon: Icon, label, value, subValue, trend, color, onClick,
}: {
+6 -1
View File
@@ -1430,6 +1430,11 @@ export const useGameStore = create<Store>()(
return rest;
},
migrate: (_persisted, version) => {
if (version === 9) {
const s = _persisted as Record<string, unknown>;
const compute = s.compute as Record<string, unknown>;
return { ...s, compute: { ...compute, computeHistory: [] } } as unknown as Store;
}
if (version < SAVE_VERSION) {
return {
...initialGameState,
@@ -1437,7 +1442,7 @@ export const useGameStore = create<Store>()(
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,
@@ -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);
}
+1 -1
View File
@@ -57,7 +57,7 @@ export function processTick(state: GameState): Partial<GameState> {
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 };
@@ -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;
+12
View File
@@ -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: [],
};
+1 -1
View File
@@ -52,4 +52,4 @@ export const INITIAL_SETTINGS: GameSettings = {
musicVolume: 0.5,
};
export const SAVE_VERSION = 9;
export const SAVE_VERSION = 10;