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:
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Tycoon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-surface-950 text-surface-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@ai-tycoon/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-tycoon/shared": "workspace:*",
|
||||
"@ai-tycoon/game-engine": "workspace:*",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"zustand": "^5.0.0",
|
||||
"lucide-react": "^0.475.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-tycoon/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#6366f1',
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
},
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@ai-tycoon/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user