Initial scaffold: AI Tycoon monorepo with core game loop

Turborepo monorepo with three packages:
- packages/shared: TypeScript types for all 14 game systems + balance constants + formatting utils
- packages/game-engine: Pure TS simulation engine with tick processor, economy, infrastructure, compute, research, market, and reputation systems
- apps/web: React + Vite + Tailwind + Zustand frontend with sidebar dashboard layout, new game screen, dashboard with charts, infrastructure management, and model training pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:53:46 -04:00
commit fdc8e544ae
57 changed files with 4753 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import { useGameStore } from '@/store';
import { MainLayout } from '@/components/layout/MainLayout';
import { NewGameScreen } from '@/components/game/NewGameScreen';
import { useGameLoop } from '@/hooks/useGameLoop';
export function App() {
const companyName = useGameStore((s) => s.meta.companyName);
useGameLoop();
if (!companyName) {
return <NewGameScreen />;
}
return <MainLayout />;
}
@@ -0,0 +1,81 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { useGameStore } from '@/store';
const SUGGESTED_NAMES = [
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
'Prometheus AI', 'Athena Research', 'Cognitron',
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
];
export function NewGameScreen() {
const [name, setName] = useState('');
const startNewGame = useGameStore((s) => s.startNewGame);
const handleStart = () => {
const companyName = name.trim() || SUGGESTED_NAMES[Math.floor(Math.random() * SUGGESTED_NAMES.length)];
startNewGame(companyName);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
<div className="max-w-md w-full mx-4">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<Sparkles className="text-accent-light" size={32} />
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
AI Tycoon
</h1>
</div>
<p className="text-surface-400 text-sm">
Build the world's most powerful AI company. From startup to AGI.
</p>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-surface-300 mb-2">
Name your AI company
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
placeholder={SUGGESTED_NAMES[0]}
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
autoFocus
maxLength={30}
/>
</div>
<div>
<p className="text-xs text-surface-500 mb-2">Or pick one:</p>
<div className="flex flex-wrap gap-2">
{SUGGESTED_NAMES.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
onClick={() => setName(suggestion)}
className="text-xs px-3 py-1.5 rounded-full bg-surface-800 text-surface-300 hover:bg-surface-700 hover:text-surface-100 transition-colors border border-surface-700"
>
{suggestion}
</button>
))}
</div>
</div>
<button
onClick={handleStart}
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors"
>
Found Your Company
</button>
</div>
<p className="text-center text-xs text-surface-600 mt-6">
Manage data centers, train models, serve millions of users, and achieve AGI.
</p>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { useGameStore } from '@/store';
import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ModelsPage } from '@/pages/ModelsPage';
import { SettingsPage } from '@/pages/SettingsPage';
export function MainLayout() {
const activePage = useGameStore((s) => s.activePage);
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
<PageRouter page={activePage} />
</main>
</div>
</div>
);
}
function PageRouter({ page }: { page: string }) {
switch (page) {
case 'dashboard': return <DashboardPage />;
case 'infrastructure': return <InfrastructurePage />;
case 'models': return <ModelsPage />;
case 'settings': return <SettingsPage />;
default: return <PlaceholderPage name={page} />;
}
}
function PlaceholderPage({ name }: { name: string }) {
return (
<div className="flex items-center justify-center h-full text-surface-500">
<div className="text-center">
<h2 className="text-2xl font-bold capitalize mb-2">{name}</h2>
<p className="text-sm">Coming soon...</p>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import {
LayoutDashboard, Server, FlaskConical, Brain,
TrendingUp, Users, Database, Swords, DollarSign, Settings,
} from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store';
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
{ page: 'research', label: 'Research', icon: FlaskConical },
{ page: 'models', label: 'Models', icon: Brain },
{ page: 'market', label: 'Market', icon: TrendingUp },
{ page: 'finance', label: 'Finance', icon: DollarSign },
{ page: 'talent', label: 'Talent', icon: Users, era: 'scaleup' },
{ page: 'data', label: 'Data', icon: Database, era: 'scaleup' },
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
{ page: 'settings', label: 'Settings', icon: Settings },
];
export function Sidebar() {
const activePage = useGameStore((s) => s.activePage);
const setActivePage = useGameStore((s) => s.setActivePage);
const companyName = useGameStore((s) => s.meta.companyName);
const era = useGameStore((s) => s.meta.currentEra);
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(era);
return (
<aside className="w-56 bg-surface-900 border-r border-surface-700 flex flex-col h-screen">
<div className="p-4 border-b border-surface-700">
<h1 className="text-lg font-bold text-accent-light truncate">{companyName}</h1>
<span className="text-xs text-surface-400 uppercase tracking-wider">
{era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI Era'}
</span>
</div>
<nav className="flex-1 py-2 overflow-y-auto">
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
const isActive = activePage === page;
return (
<button
key={page}
onClick={() => setActivePage(page)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
: 'text-surface-300 hover:bg-surface-800 hover:text-surface-100'
}`}
>
<Icon size={18} />
{label}
</button>
);
})}
</nav>
<div className="p-4 border-t border-surface-700 text-xs text-surface-500">
AI Tycoon v0.1
</div>
</aside>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Pause, Play, Bell, Zap } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
const SPEEDS: GameSpeed[] = [1, 2, 5];
export function TopBar() {
const money = useGameStore((s) => s.economy.money);
const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick);
const reputation = useGameStore((s) => s.reputation.score);
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
const isPaused = useGameStore((s) => s.meta.isPaused);
const gameSpeed = useGameStore((s) => s.meta.gameSpeed);
const tickCount = useGameStore((s) => s.meta.tickCount);
const togglePause = useGameStore((s) => s.togglePause);
const setGameSpeed = useGameStore((s) => s.setGameSpeed);
const notifications = useGameStore((s) => s.notifications);
const unreadCount = notifications.filter(n => !n.read).length;
return (
<header className="h-14 bg-surface-900 border-b border-surface-700 flex items-center justify-between px-4">
<div className="flex items-center gap-6">
<KPI label="Cash" value={formatMoney(money)} trend={revenuePerTick > 0 ? 'up' : 'neutral'} />
<KPI label="Revenue/s" value={formatMoney(revenuePerTick)} />
<KPI label="Compute" value={`${formatNumber(totalFlops)} FLOPS`} />
<KPI label="Reputation" value={`${reputation}/100`} />
<KPI label="Time" value={formatDuration(tickCount)} />
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 bg-surface-800 rounded-lg p-1">
<button
onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play size={16} /> : <Pause size={16} />}
</button>
{SPEEDS.map((speed) => (
<button
key={speed}
onClick={() => setGameSpeed(speed)}
className={`px-2 py-1 rounded text-xs font-mono transition-colors ${
gameSpeed === speed && !isPaused
? 'bg-accent text-white'
: 'hover:bg-surface-700 text-surface-400'
}`}
>
{speed}x
</button>
))}
</div>
<button className="relative p-2 rounded hover:bg-surface-800 transition-colors">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
</div>
</header>
);
}
function KPI({ label, value, trend }: { label: string; value: string; trend?: 'up' | 'down' | 'neutral' }) {
return (
<div className="flex flex-col">
<span className="text-xs text-surface-400">{label}</span>
<span className={`text-sm font-mono font-semibold ${
trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-surface-100'
}`}>
{value}
</span>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';
import { GameEngine } from '@ai-tycoon/game-engine';
import { useGameStore } from '@/store';
export function useGameLoop() {
const engineRef = useRef<GameEngine | null>(null);
const companyName = useGameStore((s) => s.meta.companyName);
const gameSpeed = useGameStore((s) => s.meta.gameSpeed);
useEffect(() => {
if (!companyName) return;
const engine = new GameEngine({
getState: () => {
const state = useGameStore.getState();
return {
meta: state.meta,
economy: state.economy,
infrastructure: state.infrastructure,
compute: state.compute,
research: state.research,
models: state.models,
market: state.market,
competitors: state.competitors,
talent: state.talent,
data: state.data,
reputation: state.reputation,
events: state.events,
achievements: state.achievements,
};
},
setState: (partial) => {
useGameStore.getState().updateState(partial);
},
});
engineRef.current = engine;
engine.start();
return () => {
engine.stop();
engineRef.current = null;
};
}, [companyName]);
useEffect(() => {
if (engineRef.current) {
engineRef.current.setSpeed(gameSpeed);
}
}, [gameSpeed]);
}
+25
View File
@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-surface-700;
}
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-surface-900;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-surface-500;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+193
View File
@@ -0,0 +1,193 @@
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
import {
DollarSign, Server, Brain, Users, TrendingUp,
TrendingDown, Minus, Cpu, Zap, Shield,
} from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
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 totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
const trainedModels = useGameStore((s) => s.models.trainedModels);
const activeTraining = useGameStore((s) => s.models.activeTraining);
const subscribers = useGameStore((s) => s.market.consumers.totalSubscribers);
const reputation = useGameStore((s) => s.reputation.score);
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
const financialHistory = useGameStore((s) => s.economy.financialHistory);
const era = useGameStore((s) => s.meta.currentEra);
const netIncome = revenuePerTick - expensesPerTick;
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Dashboard</h2>
<div className="grid grid-cols-4 gap-4">
<StatCard
icon={DollarSign}
label="Cash"
value={formatMoney(money)}
subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`}
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
color="text-green-400"
/>
<StatCard
icon={Server}
label="Data Centers"
value={dataCenters.length.toString()}
subValue={`${formatNumber(totalFlops)} FLOPS`}
color="text-blue-400"
/>
<StatCard
icon={Brain}
label="Models"
value={trainedModels.length.toString()}
subValue={activeTraining ? `Training: ${Math.floor((activeTraining.progressTicks / activeTraining.totalTicks) * 100)}%` : 'Idle'}
color="text-purple-400"
/>
<StatCard
icon={Users}
label="Subscribers"
value={formatNumber(subscribers)}
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumers.satisfaction)}`}
color="text-orange-400"
/>
</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">Revenue Over Time</h3>
{financialHistory.length > 1 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={financialHistory}>
<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>
</defs>
<XAxis dataKey="tick" hide />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#94a3b8' }}
formatter={(value: number) => [formatMoney(value), 'Revenue']}
/>
<Area type="monotone" dataKey="revenue" stroke="#22c55e" fill="url(#revenueGrad)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
No data yet start earning revenue
</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">System Status</h3>
<div className="space-y-4">
<StatusRow
icon={Cpu}
label="Inference Utilization"
value={formatPercent(inferenceUtil)}
bar={inferenceUtil}
barColor={inferenceUtil > 0.9 ? 'bg-danger' : inferenceUtil > 0.7 ? '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"
/>
</div>
</div>
</div>
{dataCenters.length === 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>
<p className="text-surface-400 text-sm mb-4">
Build your first data center to start training AI models.
</p>
<button
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
className="bg-accent hover:bg-accent-dark text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
Build Data Center
</button>
</div>
)}
</div>
);
}
function StatCard({
icon: Icon, label, value, subValue, trend, color,
}: {
icon: typeof DollarSign;
label: string;
value: string;
subValue?: string;
trend?: 'up' | 'down' | 'neutral';
color?: string;
}) {
return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Icon size={16} className={color ?? 'text-surface-400'} />
<span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span>
</div>
<div className="text-2xl font-bold font-mono">{value}</div>
{subValue && (
<div className={`text-xs mt-1 flex items-center gap-1 ${
trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-surface-400'
}`}>
{trend === 'up' && <TrendingUp size={12} />}
{trend === 'down' && <TrendingDown size={12} />}
{trend === 'neutral' && <Minus size={12} />}
{subValue}
</div>
)}
</div>
);
}
function StatusRow({
icon: Icon, label, value, bar, barColor,
}: {
icon: typeof Cpu;
label: string;
value: string;
bar: number;
barColor: string;
}) {
return (
<div>
<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">{label}</span>
</div>
<span className="text-sm font-mono text-surface-200">{value}</span>
</div>
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.min(100, bar * 100)}%` }}
/>
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useState } from 'react';
import { Plus, Server, Cpu, MapPin } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatPercent, GPU_CONFIGS, LOCATION_CONFIGS } from '@ai-tycoon/shared';
import type { GpuType, LocationId } from '@ai-tycoon/shared';
export function InfrastructurePage() {
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
const gpuPrices = useGameStore((s) => s.infrastructure.gpuMarketPrices);
const money = useGameStore((s) => s.economy.money);
const era = useGameStore((s) => s.meta.currentEra);
const buildDataCenter = useGameStore((s) => s.buildDataCenter);
const buyGpu = useGameStore((s) => s.buyGpu);
const [showNewDC, setShowNewDC] = useState(false);
const [newDCName, setNewDCName] = useState('');
const [newDCLocation, setNewDCLocation] = useState<LocationId>('us-west');
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(era);
const availableLocations = Object.values(LOCATION_CONFIGS).filter(
loc => eraOrder.indexOf(loc.availableAt) <= currentEraIdx,
);
const availableGpus = Object.values(GPU_CONFIGS).filter(
gpu => eraOrder.indexOf(gpu.availableAt) <= currentEraIdx,
);
const handleBuildDC = () => {
if (!newDCName.trim()) return;
buildDataCenter(newDCName.trim(), newDCLocation);
setNewDCName('');
setShowNewDC(false);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Infrastructure</h2>
<button
onClick={() => setShowNewDC(true)}
className="flex items-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg transition-colors text-sm"
>
<Plus size={16} />
New Data Center
</button>
</div>
{showNewDC && (
<div className="bg-surface-900 border border-accent/30 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Build New Data Center</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-surface-400 mb-1">Name</label>
<input
type="text"
value={newDCName}
onChange={(e) => setNewDCName(e.target.value)}
placeholder="DC-West-01"
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
autoFocus
/>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Location</label>
<select
value={newDCLocation}
onChange={(e) => setNewDCLocation(e.target.value as LocationId)}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{availableLocations.map(loc => (
<option key={loc.id} value={loc.id}>{loc.name} (Energy: {loc.energyCostMultiplier}x)</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-surface-400">Cost: {formatMoney(10_000)}</span>
<div className="flex gap-2">
<button onClick={() => setShowNewDC(false)} className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200">Cancel</button>
<button
onClick={handleBuildDC}
disabled={money < 10_000 || !newDCName.trim()}
className="px-4 py-2 rounded bg-accent hover:bg-accent-dark text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Build
</button>
</div>
</div>
</div>
)}
{dataCenters.length === 0 ? (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500">
<Server size={48} className="mx-auto mb-4 opacity-50" />
<p>No data centers yet. Build your first one to start hosting AI models.</p>
</div>
) : (
<div className="space-y-4">
{dataCenters.map(dc => (
<div key={dc.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-lg">{dc.name}</h3>
<div className="flex items-center gap-2 text-sm text-surface-400">
<MapPin size={14} />
{LOCATION_CONFIGS[dc.location].name}
</div>
</div>
<div className="text-right text-sm">
<div className="text-surface-400">Uptime: <span className="text-surface-200">{formatPercent(dc.currentUptime)}</span></div>
<div className="text-surface-400">Cost: <span className="text-danger">{formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span></div>
</div>
</div>
<div className="mb-4">
<h4 className="text-xs text-surface-400 uppercase mb-2">GPUs</h4>
{dc.gpus.length === 0 ? (
<p className="text-sm text-surface-500">No GPUs installed</p>
) : (
<div className="grid grid-cols-3 gap-2">
{dc.gpus.map(inv => (
<div key={inv.type} className="bg-surface-800 rounded-lg p-2 text-sm">
<div className="font-medium">{GPU_CONFIGS[inv.type].name}</div>
<div className="text-surface-400 text-xs">
{inv.healthyCount}/{inv.count} healthy · {formatNumber(inv.healthyCount * GPU_CONFIGS[inv.type].flopsPerUnit)} FLOPS
</div>
</div>
))}
</div>
)}
</div>
<div>
<h4 className="text-xs text-surface-400 uppercase mb-2">Buy GPUs</h4>
<div className="flex gap-2 flex-wrap">
{availableGpus.map(gpu => (
<button
key={gpu.type}
onClick={() => buyGpu(dc.id, gpu.type, 1)}
disabled={money < gpuPrices[gpu.type]}
className="flex items-center gap-2 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-3 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Cpu size={14} />
{gpu.name}
<span className="text-surface-400">{formatMoney(gpuPrices[gpu.type])}</span>
</button>
))}
</div>
</div>
</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-3">GPU Market Prices</h3>
<div className="grid grid-cols-3 gap-3">
{availableGpus.map(gpu => (
<div key={gpu.type} className="flex items-center justify-between bg-surface-800 rounded-lg p-3">
<div>
<div className="text-sm font-medium">{gpu.name}</div>
<div className="text-xs text-surface-400">{formatNumber(gpu.flopsPerUnit)} FLOPS/unit</div>
</div>
<div className="text-sm font-mono">{formatMoney(gpuPrices[gpu.type])}</div>
</div>
))}
</div>
</div>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useState } from 'react';
import { Brain, Play, Rocket, Settings2 } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
export function ModelsPage() {
const trainedModels = useGameStore((s) => s.models.trainedModels);
const activeTraining = useGameStore((s) => s.models.activeTraining);
const productLines = useGameStore((s) => s.models.productLines);
const totalFlops = useGameStore((s) => s.compute.totalFlops);
const trainingAlloc = useGameStore((s) => s.compute.trainingAllocation);
const totalData = useGameStore((s) => s.data.totalTrainingTokens);
const startTraining = useGameStore((s) => s.startTraining);
const deployModel = useGameStore((s) => s.deployModel);
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
const [modelName, setModelName] = useState('');
const trainingFlops = totalFlops * trainingAlloc;
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity;
const estimatedCapability = Math.min(100, Math.log(1 + trainingFlops * 0.5) * 10 + Math.log(1 + totalData / 1e9) * 5);
const handleStartTraining = () => {
if (activeTraining || trainingFlops === 0) return;
const name = modelName.trim() || `Model v${trainedModels.length + 1}`;
startTraining({
modelName: name,
generation: trainedModels.length + 1,
allocatedCompute: trainingFlops,
allocatedDataTokens: totalData,
totalTicks: estimatedTicks,
estimatedCapability,
});
setModelName('');
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Models</h2>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3">Compute Allocation</h3>
<div className="flex items-center gap-4">
<span className="text-sm text-surface-400 w-20">Training</span>
<input
type="range"
min={0}
max={100}
value={trainingAlloc * 100}
onChange={(e) => setTrainingAllocation(Number(e.target.value) / 100)}
className="flex-1 accent-accent"
/>
<span className="text-sm text-surface-400 w-20 text-right">Inference</span>
</div>
<div className="flex justify-between text-xs text-surface-500 mt-1">
<span>{formatPercent(trainingAlloc)}</span>
<span>{formatPercent(1 - trainingAlloc)}</span>
</div>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Train New Model</h3>
{activeTraining ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{activeTraining.modelName}</span>
<span className="text-sm text-surface-400">
{formatPercent(activeTraining.progressTicks / activeTraining.totalTicks)} complete
</span>
</div>
<div className="h-2 bg-surface-800 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${(activeTraining.progressTicks / activeTraining.totalTicks) * 100}%` }}
/>
</div>
<div className="text-xs text-surface-500 mt-1">
ETA: {formatDuration(activeTraining.totalTicks - activeTraining.progressTicks)}
</div>
</div>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs text-surface-400 mb-1">Model Name</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={`Model v${trainedModels.length + 1}`}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="bg-surface-800 rounded-lg p-3">
<div className="text-xs text-surface-400">Training Compute</div>
<div className="font-mono">{formatNumber(trainingFlops)} FLOPS</div>
</div>
<div className="bg-surface-800 rounded-lg p-3">
<div className="text-xs text-surface-400">Training Data</div>
<div className="font-mono">{formatNumber(totalData)} tokens</div>
</div>
<div className="bg-surface-800 rounded-lg p-3">
<div className="text-xs text-surface-400">Est. Time</div>
<div className="font-mono">{trainingFlops > 0 ? formatDuration(estimatedTicks) : 'N/A'}</div>
</div>
</div>
<div className="text-sm text-surface-400">
Estimated capability score: <span className="text-accent-light font-mono">{estimatedCapability.toFixed(1)}/100</span>
</div>
<button
onClick={handleStartTraining}
disabled={trainingFlops === 0}
className="flex items-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play size={16} />
Start Training
</button>
</div>
)}
</div>
{trainedModels.length > 0 && (
<div className="space-y-3">
<h3 className="font-semibold">Trained Models</h3>
{trainedModels.map(model => (
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{model.name}</h4>
<div className="text-xs text-surface-400">
Gen {model.generation} · Benchmark: {model.benchmarkScore.toFixed(1)}/100 · Safety: {model.safetyScore.toFixed(0)}/100
</div>
</div>
<div className="flex items-center gap-2">
{model.isDeployed ? (
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
) : (
<>
{productLines.filter(pl => pl.type === 'text-api' || pl.type === 'chat-product').map(pl => (
<button
key={pl.id}
onClick={() => deployModel(model.id, pl.id)}
className="flex items-center gap-1 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded px-3 py-1.5 text-xs"
>
<Rocket size={12} />
Deploy to {pl.name}
</button>
))}
</>
)}
</div>
</div>
</div>
))}
</div>
)}
<div className="space-y-3">
<h3 className="font-semibold">Product Lines</h3>
{productLines.map(pl => (
<div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{pl.name}</h4>
<div className="text-xs text-surface-400">
{pl.modelId ? `Running: ${trainedModels.find(m => m.id === pl.modelId)?.name ?? 'Unknown'}` : 'No model deployed'}
</div>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${pl.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}>
{pl.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
))}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useGameStore } from '@/store';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
const companyName = useGameStore((s) => s.meta.companyName);
const handleReset = () => {
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
localStorage.removeItem('ai-tycoon-save');
window.location.reload();
}
};
const handleExport = () => {
const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state;
const blob = new Blob([JSON.stringify(gameState)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-tycoon-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Settings</h2>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Game</h3>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">Sound Effects</div>
<div className="text-xs text-surface-400">Play UI sounds and notifications</div>
</div>
<ToggleSwitch checked={settings.soundEnabled} onChange={() => {}} />
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">Music</div>
<div className="text-xs text-surface-400">Background music (coming soon)</div>
</div>
<ToggleSwitch checked={settings.soundEnabled} onChange={() => {}} />
</div>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Save Data</h3>
<div className="flex gap-3">
<button
onClick={handleExport}
className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm"
>
Export Save
</button>
<button
onClick={handleReset}
className="px-4 py-2 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm"
>
Reset Progress
</button>
</div>
</div>
</div>
);
}
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<button
onClick={onChange}
className={`w-10 h-6 rounded-full transition-colors ${checked ? 'bg-accent' : 'bg-surface-600'}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-transform mx-1 mt-1 ${checked ? 'translate-x-4' : ''}`} />
</button>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
GameState, Era, GameSpeed, GameSettings,
EconomyState, InfrastructureState, ComputeState,
ResearchState, ModelsState, MarketState,
CompetitorState, TalentState, DataState,
ReputationState, EventState, AchievementState,
DataCenter, GpuType, GpuInventory, TrainingJob,
} from '@ai-tycoon/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
INITIAL_REPUTATION, INITIAL_EVENTS, INITIAL_ACHIEVEMENTS,
GPU_CONFIGS,
} from '@ai-tycoon/shared';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'settings';
interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
}
export interface GameNotification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
}
interface Actions {
setActivePage: (page: ActivePage) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void;
startNewGame: (companyName: string) => void;
setGameSpeed: (speed: GameSpeed) => void;
togglePause: () => void;
setTrainingAllocation: (ratio: number) => void;
buyGpu: (dataCenterId: string, gpuType: GpuType, count: number) => void;
buildDataCenter: (name: string, location: DataCenter['location']) => void;
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => void;
deployModel: (modelId: string, productLineId: string) => void;
setProductPricing: (productLineId: string, field: string, value: number) => void;
toggleProductLine: (productLineId: string) => void;
updateState: (partial: Partial<GameState>) => void;
}
type Store = GameState & UIState & Actions;
const initialGameState: GameState = {
meta: {
saveVersion: SAVE_VERSION,
companyName: '',
currentEra: 'startup',
tickCount: 0,
lastTickTimestamp: Date.now(),
gameSpeed: 1,
isPaused: true,
createdAt: Date.now(),
totalPlayTime: 0,
settings: INITIAL_SETTINGS,
},
economy: INITIAL_ECONOMY,
infrastructure: INITIAL_INFRASTRUCTURE,
compute: INITIAL_COMPUTE,
research: INITIAL_RESEARCH,
models: INITIAL_MODELS,
market: INITIAL_MARKET,
competitors: INITIAL_COMPETITORS,
talent: INITIAL_TALENT,
data: INITIAL_DATA,
reputation: INITIAL_REPUTATION,
events: INITIAL_EVENTS,
achievements: INITIAL_ACHIEVEMENTS,
};
export const useGameStore = create<Store>()(
persist(
(set, get) => ({
...initialGameState,
activePage: 'dashboard' as ActivePage,
notifications: [],
setActivePage: (page) => set({ activePage: page }),
addNotification: (n) => set((s) => ({
notifications: [
{ ...n, id: crypto.randomUUID(), read: false },
...s.notifications.slice(0, 49),
],
})),
dismissNotification: (id) => set((s) => ({
notifications: s.notifications.map(n =>
n.id === id ? { ...n, read: true } : n,
),
})),
startNewGame: (companyName) => set({
...initialGameState,
meta: {
...initialGameState.meta,
companyName,
isPaused: false,
createdAt: Date.now(),
lastTickTimestamp: Date.now(),
},
activePage: 'dashboard',
notifications: [],
}),
setGameSpeed: (speed) => set((s) => ({
meta: { ...s.meta, gameSpeed: speed },
})),
togglePause: () => set((s) => ({
meta: { ...s.meta, isPaused: !s.meta.isPaused },
})),
setTrainingAllocation: (ratio) => set((s) => ({
compute: { ...s.compute, trainingAllocation: ratio, inferenceAllocation: 1 - ratio },
})),
buyGpu: (dataCenterId, gpuType, count) => set((s) => {
const price = s.infrastructure.gpuMarketPrices[gpuType] * count;
if (s.economy.money < price) return s;
const dataCenters = s.infrastructure.dataCenters.map(dc => {
if (dc.id !== dataCenterId) return dc;
const existingIdx = dc.gpus.findIndex(g => g.type === gpuType);
let gpus: GpuInventory[];
if (existingIdx >= 0) {
gpus = dc.gpus.map((g, i) =>
i === existingIdx
? { ...g, count: g.count + count, healthyCount: g.healthyCount + count }
: g,
);
} else {
gpus = [...dc.gpus, { type: gpuType, count, healthyCount: count, failedCount: 0 }];
}
return { ...dc, gpus };
});
return {
economy: { ...s.economy, money: s.economy.money - price },
infrastructure: { ...s.infrastructure, dataCenters },
};
}),
buildDataCenter: (name, location) => set((s) => {
const buildCost = 10_000;
if (s.economy.money < buildCost) return s;
const dc: DataCenter = {
id: crypto.randomUUID(),
name,
location,
gpus: [],
maxCapacity: 100,
coolingLevel: 0.5,
redundancyLevel: 0.3,
currentUptime: 1,
energyCostPerTick: 0,
maintenanceCostPerTick: 0,
};
return {
economy: { ...s.economy, money: s.economy.money - buildCost },
infrastructure: {
...s.infrastructure,
dataCenters: [...s.infrastructure.dataCenters, dc],
},
};
}),
startTraining: (job) => set((s) => ({
models: {
...s.models,
activeTraining: { ...job, progressTicks: 0 },
},
})),
deployModel: (modelId, productLineId) => set((s) => ({
models: {
...s.models,
trainedModels: s.models.trainedModels.map(m =>
m.id === modelId ? { ...m, isDeployed: true } : m,
),
productLines: s.models.productLines.map(pl =>
pl.id === productLineId ? { ...pl, modelId, isActive: true } : pl,
),
},
})),
setProductPricing: (productLineId, field, value) => set((s) => ({
models: {
...s.models,
productLines: s.models.productLines.map(pl =>
pl.id === productLineId
? { ...pl, pricing: { ...pl.pricing, [field]: value } }
: pl,
),
},
})),
toggleProductLine: (productLineId) => set((s) => ({
models: {
...s.models,
productLines: s.models.productLines.map(pl =>
pl.id === productLineId ? { ...pl, isActive: !pl.isActive } : pl,
),
},
})),
updateState: (partial) => set((s) => {
const newState: Partial<Store> = {};
for (const key of Object.keys(partial) as (keyof GameState)[]) {
const value = partial[key];
const current = s[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value) && typeof current === 'object' && current !== null) {
(newState as Record<string, unknown>)[key] = { ...current, ...value };
} else {
(newState as Record<string, unknown>)[key] = value;
}
}
return newState;
}),
}),
{
name: 'ai-tycoon-save',
partialize: (state) => {
const { activePage, notifications, ...rest } = state;
return rest;
},
},
),
);