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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.turbo/
*.tsbuildinfo
.env
.env.local
.DS_Store
+16
View File
@@ -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>
+32
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+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;
},
},
),
);
+37
View File
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@ai-tycoon/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+12
View File
@@ -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'),
},
},
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "ai-tycoon",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"typecheck": "turbo typecheck",
"lint": "turbo lint",
"clean": "turbo clean"
},
"devDependencies": {
"turbo": "^2.5.0",
"typescript": "^5.8.0"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ai-tycoon/game-engine",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"typescript": "^5.8.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { GameState } from '@ai-tycoon/shared';
import { processTick } from './tick';
export interface GameEngineCallbacks {
getState: () => GameState;
setState: (partial: Partial<GameState>) => void;
onTick?: (tickCount: number) => void;
onEraChange?: (era: GameState['meta']['currentEra']) => void;
}
export class GameEngine {
private callbacks: GameEngineCallbacks;
private lastFrameTime = 0;
private accumulator = 0;
private animFrameId: number | null = null;
private tickIntervalMs = 1000;
constructor(callbacks: GameEngineCallbacks) {
this.callbacks = callbacks;
}
start(): void {
if (this.animFrameId !== null) return;
this.lastFrameTime = performance.now();
this.accumulator = 0;
this.loop(this.lastFrameTime);
}
stop(): void {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
}
setSpeed(speed: number): void {
this.tickIntervalMs = 1000 / speed;
}
processOfflineTicks(missedTicks: number): { revenue: number; expenses: number; ticksProcessed: number } {
let totalRevenue = 0;
let totalExpenses = 0;
for (let i = 0; i < missedTicks; i++) {
const state = this.callbacks.getState();
const result = processTick(state);
this.callbacks.setState(result);
totalRevenue += result.economy?.revenuePerTick ?? 0;
totalExpenses += result.economy?.expensesPerTick ?? 0;
}
return { revenue: totalRevenue, expenses: totalExpenses, ticksProcessed: missedTicks };
}
private loop = (now: number): void => {
const delta = now - this.lastFrameTime;
this.lastFrameTime = now;
const state = this.callbacks.getState();
if (!state.meta.isPaused) {
this.accumulator += delta;
let ticksThisFrame = 0;
const maxTicksPerFrame = 10;
while (this.accumulator >= this.tickIntervalMs && ticksThisFrame < maxTicksPerFrame) {
const currentState = this.callbacks.getState();
const result = processTick(currentState);
this.callbacks.setState(result);
this.accumulator -= this.tickIntervalMs;
ticksThisFrame++;
this.callbacks.onTick?.(currentState.meta.tickCount + 1);
}
if (this.accumulator > this.tickIntervalMs * maxTicksPerFrame) {
this.accumulator = 0;
}
}
this.animFrameId = requestAnimationFrame(this.loop);
};
}
+2
View File
@@ -0,0 +1,2 @@
export { GameEngine } from './engine';
export { processTick } from './tick';
@@ -0,0 +1,24 @@
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
export function processCompute(state: GameState, infrastructure: InfrastructureState): ComputeState {
const totalFlops = infrastructure.totalFlops;
const trainingAllocation = state.compute.trainingAllocation;
const inferenceAllocation = 1 - trainingAllocation;
const inferenceFlops = totalFlops * inferenceAllocation;
const tokensPerSecondCapacity = inferenceFlops * 10;
const tokensPerSecondDemand = state.compute.tokensPerSecondDemand;
const inferenceUtilization = tokensPerSecondCapacity > 0
? Math.min(1, tokensPerSecondDemand / tokensPerSecondCapacity)
: 0;
return {
totalFlops,
trainingAllocation,
inferenceAllocation,
inferenceUtilization,
tokensPerSecondCapacity,
tokensPerSecondDemand,
};
}
@@ -0,0 +1,45 @@
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY } from '@ai-tycoon/shared';
import type { MarketTickResult } from './marketSystem';
export function processEconomy(
state: GameState,
market: MarketTickResult,
infrastructure: InfrastructureState,
): EconomyState {
const revenue = market.apiRevenue + market.subscriptionRevenue;
const infraExpenses = infrastructure.dataCenters.reduce((sum, dc) => {
return sum + dc.energyCostPerTick + dc.maintenanceCostPerTick;
}, 0);
const talentExpenses = state.talent.totalSalaryPerTick;
const dataExpenses = state.data.partnerships.reduce((sum, p) => sum + p.costPerTick, 0);
const expenses = infraExpenses + talentExpenses + dataExpenses;
const money = state.economy.money + revenue - expenses;
const financialHistory = [...state.economy.financialHistory];
if (state.meta.tickCount % FINANCIAL_SNAPSHOT_INTERVAL === 0) {
financialHistory.push({
tick: state.meta.tickCount,
money,
revenue,
expenses,
valuation: state.economy.funding.valuation,
});
if (financialHistory.length > MAX_FINANCIAL_HISTORY) {
financialHistory.shift();
}
}
return {
...state.economy,
money: Math.max(0, money),
totalRevenue: state.economy.totalRevenue + revenue,
totalExpenses: state.economy.totalExpenses + expenses,
revenuePerTick: revenue,
expensesPerTick: expenses,
financialHistory,
};
}
@@ -0,0 +1,72 @@
import type { GameState, InfrastructureState } from '@ai-tycoon/shared';
import {
GPU_CONFIGS,
LOCATION_CONFIGS,
GPU_PRICE_VOLATILITY,
GPU_FAILURE_RATE_BASE,
REDUNDANCY_FAILURE_REDUCTION,
BASE_ENERGY_COST_PER_FLOP,
BASE_MAINTENANCE_PER_GPU,
} from '@ai-tycoon/shared';
import type { GpuType } from '@ai-tycoon/shared';
export function processInfrastructure(state: GameState): InfrastructureState {
const dataCenters = state.infrastructure.dataCenters.map(dc => {
const location = LOCATION_CONFIGS[dc.location];
const gpus = dc.gpus.map(inv => {
const failureRate = GPU_FAILURE_RATE_BASE * (1 - dc.redundancyLevel * REDUNDANCY_FAILURE_REDUCTION);
let newFailed = inv.failedCount;
for (let i = 0; i < inv.healthyCount; i++) {
if (Math.random() < failureRate) newFailed++;
}
const healthyCount = Math.max(0, inv.count - newFailed);
return { ...inv, healthyCount, failedCount: newFailed };
});
let totalFlops = 0;
let totalPower = 0;
let totalGpuCount = 0;
for (const inv of gpus) {
const config = GPU_CONFIGS[inv.type];
totalFlops += inv.healthyCount * config.flopsPerUnit;
totalPower += inv.healthyCount * config.basePowerDraw;
totalGpuCount += inv.count;
}
const energyCostPerTick = totalPower * BASE_ENERGY_COST_PER_FLOP * location.energyCostMultiplier;
const maintenanceCostPerTick = totalGpuCount * BASE_MAINTENANCE_PER_GPU;
const currentUptime = totalGpuCount > 0
? gpus.reduce((s, inv) => s + inv.healthyCount, 0) / totalGpuCount
: 1;
return { ...dc, gpus, energyCostPerTick, maintenanceCostPerTick, currentUptime };
});
const gpuMarketPrices = { ...state.infrastructure.gpuMarketPrices };
for (const gpuType of Object.keys(gpuMarketPrices) as GpuType[]) {
const basePrice = GPU_CONFIGS[gpuType].basePrice;
const variation = (Math.random() - 0.5) * 2 * GPU_PRICE_VOLATILITY;
const currentPrice = gpuMarketPrices[gpuType];
const newPrice = currentPrice * (1 + variation);
gpuMarketPrices[gpuType] = Math.max(basePrice * 0.7, Math.min(basePrice * 1.5, newPrice));
}
let totalFlops = 0;
let totalUptime = 0;
let dcCount = 0;
for (const dc of dataCenters) {
for (const inv of dc.gpus) {
totalFlops += inv.healthyCount * GPU_CONFIGS[inv.type].flopsPerUnit;
}
totalUptime += dc.currentUptime;
dcCount++;
}
return {
dataCenters,
gpuMarketPrices,
totalFlops,
totalUptime: dcCount > 0 ? totalUptime / dcCount : 1,
};
}
@@ -0,0 +1,67 @@
import type { GameState, MarketState, ComputeState } from '@ai-tycoon/shared';
import {
CONSUMER_BASE_GROWTH,
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
CONSUMER_BASE_CHURN,
API_TOKENS_PER_REQUEST,
} from '@ai-tycoon/shared';
export interface MarketTickResult {
marketState: MarketState;
apiRevenue: number;
subscriptionRevenue: number;
}
export function processMarket(state: GameState, compute: ComputeState): MarketTickResult {
const bestModel = state.models.trainedModels
.filter(m => m.isDeployed)
.sort((a, b) => b.benchmarkScore - a.benchmarkScore)[0];
const modelQuality = bestModel ? bestModel.benchmarkScore / 100 : 0;
const chatProduct = state.models.productLines.find(p => p.type === 'chat-product');
const textApi = state.models.productLines.find(p => p.type === 'text-api');
const consumers = { ...state.market.consumers };
if (chatProduct?.isActive && bestModel) {
const growthRate = CONSUMER_BASE_GROWTH + modelQuality * CONSUMER_QUALITY_GROWTH_MULTIPLIER;
const churnRate = CONSUMER_BASE_CHURN * (1 + (1 - consumers.satisfaction));
consumers.growthRatePerTick = growthRate;
consumers.churnRatePerTick = churnRate;
const newSubs = consumers.totalSubscribers * growthRate;
const lostSubs = consumers.totalSubscribers * churnRate;
consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs);
if (consumers.totalSubscribers < 10 && modelQuality > 0) {
consumers.totalSubscribers += 1;
}
consumers.satisfaction = Math.min(1, Math.max(0,
0.3 + modelQuality * 0.5 + (1 - compute.inferenceUtilization) * 0.2,
));
}
const subscriptionRevenue = chatProduct?.isActive
? consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 30 / 24 / 3600)
: 0;
const enterprise = { ...state.market.enterprise };
let apiRevenue = 0;
if (textApi?.isActive && bestModel) {
let totalTokens = 0;
for (const contract of enterprise.activeContracts) {
totalTokens += contract.tokensPerTick;
apiRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken;
}
enterprise.totalApiCallsPerTick = totalTokens / API_TOKENS_PER_REQUEST;
}
return {
marketState: {
...state.market,
consumers,
enterprise,
},
apiRevenue,
subscriptionRevenue,
};
}
@@ -0,0 +1,27 @@
import type { GameState, ReputationState } from '@ai-tycoon/shared';
import { MAX_REPUTATION_HISTORY } from '@ai-tycoon/shared';
export function processReputation(state: GameState): ReputationState {
const { safetyRecord, publicPerception, employeeSatisfaction, regulatoryStanding } = state.reputation;
const score = Math.round(
safetyRecord * 0.3 +
publicPerception * 0.3 +
employeeSatisfaction * 0.2 +
regulatoryStanding * 0.2,
);
const reputationHistory = [...state.reputation.reputationHistory];
if (state.meta.tickCount % 120 === 0) {
reputationHistory.push({ tick: state.meta.tickCount, score });
if (reputationHistory.length > MAX_REPUTATION_HISTORY) {
reputationHistory.shift();
}
}
return {
...state.reputation,
score,
reputationHistory,
};
}
@@ -0,0 +1,29 @@
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
export function processResearch(state: GameState, compute: ComputeState): ResearchState {
const active = state.research.activeResearch;
if (!active) return state.research;
const researcherBoost = state.talent.departments.research.headcount *
state.talent.departments.research.effectiveness;
const speedMultiplier = 1 + researcherBoost * 0.1;
const newProgress = active.progressTicks + speedMultiplier;
if (newProgress >= active.totalTicks) {
return {
...state.research,
completedResearch: [...state.research.completedResearch, active.researchId],
activeResearch: null,
researchPoints: state.research.researchPoints + 1,
};
}
return {
...state.research,
activeResearch: {
...active,
progressTicks: newProgress,
},
};
}
+33
View File
@@ -0,0 +1,33 @@
import type { GameState } from '@ai-tycoon/shared';
import { processEconomy } from './systems/economySystem';
import { processInfrastructure } from './systems/infrastructureSystem';
import { processCompute } from './systems/computeSystem';
import { processResearch } from './systems/researchSystem';
import { processMarket } from './systems/marketSystem';
import { processReputation } from './systems/reputationSystem';
export function processTick(state: GameState): Partial<GameState> {
const infrastructure = processInfrastructure(state);
const compute = processCompute(state, infrastructure);
const research = processResearch(state, compute);
const market = processMarket(state, compute);
const reputation = processReputation(state);
const economy = processEconomy(state, market, infrastructure);
const tickCount = state.meta.tickCount + 1;
return {
meta: {
...state.meta,
tickCount,
lastTickTimestamp: Date.now(),
totalPlayTime: state.meta.totalPlayTime + 1,
},
economy,
infrastructure,
compute,
research,
market: market.marketState,
reputation,
};
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ai-tycoon/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@ai-tycoon/shared",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"typescript": "^5.8.0"
}
}
@@ -0,0 +1,42 @@
export const TICK_INTERVAL_MS = 1000;
export const MAX_OFFLINE_TICKS = 86_400;
export const OFFLINE_EFFICIENCY = 0.8;
export const FAST_FORWARD_BATCH_SIZE = 100;
export const AUTO_SAVE_INTERVAL_TICKS = 60;
export const FINANCIAL_SNAPSHOT_INTERVAL = 60;
export const MAX_FINANCIAL_HISTORY = 1000;
export const MAX_EVENT_HISTORY = 50;
export const MAX_REPUTATION_HISTORY = 500;
export const STARTING_MONEY = 50_000;
export const BASE_ENERGY_COST_PER_FLOP = 0.001;
export const BASE_MAINTENANCE_PER_GPU = 0.5;
export const TRAINING_BASE_TICKS = 120;
export const TRAINING_COMPUTE_MULTIPLIER = 0.8;
export const TRAINING_DATA_QUALITY_WEIGHT = 0.3;
export const CAPABILITY_FORMULA = {
computeWeight: 0.4,
dataWeight: 0.3,
researcherWeight: 0.2,
efficiencyWeight: 0.1,
};
export const CONSUMER_BASE_GROWTH = 0.002;
export const CONSUMER_QUALITY_GROWTH_MULTIPLIER = 0.01;
export const CONSUMER_PRICE_ELASTICITY = -0.5;
export const CONSUMER_BASE_CHURN = 0.001;
export const API_TOKENS_PER_REQUEST = 500;
export const API_REVENUE_PER_MTOK = 1.0;
export const ERA_THRESHOLDS = {
scaleup: { revenue: 10_000, capability: 15, reputation: 30 },
bigtech: { revenue: 1_000_000, capability: 50, reputation: 60 },
agi: { revenue: 100_000_000, capability: 90, reputation: 70 },
};
export const GPU_PRICE_VOLATILITY = 0.02;
export const GPU_FAILURE_RATE_BASE = 0.0001;
export const REDUNDANCY_FAILURE_REDUCTION = 0.5;
+15
View File
@@ -0,0 +1,15 @@
export * from './types/gameState';
export * from './types/economy';
export * from './types/infrastructure';
export * from './types/compute';
export * from './types/research';
export * from './types/models';
export * from './types/market';
export * from './types/competitors';
export * from './types/talent';
export * from './types/data';
export * from './types/reputation';
export * from './types/events';
export * from './types/achievements';
export * from './utils/formatting';
export * from './constants/gameBalance';
+28
View File
@@ -0,0 +1,28 @@
export interface AchievementState {
unlocked: UnlockedAchievement[];
progress: Record<string, number>;
}
export interface UnlockedAchievement {
id: string;
unlockedAtTick: number;
}
export interface AchievementDefinition {
id: string;
name: string;
description: string;
icon: string;
condition: AchievementCondition;
}
export interface AchievementCondition {
field: string;
operator: 'gt' | 'gte' | 'eq';
value: number;
}
export const INITIAL_ACHIEVEMENTS: AchievementState = {
unlocked: [],
progress: {},
};
+35
View File
@@ -0,0 +1,35 @@
export interface CompetitorState {
rivals: Competitor[];
industryBenchmark: number;
}
export interface Competitor {
id: string;
name: string;
archetype: CompetitorArchetype;
personality: CompetitorPersonality;
status: 'active' | 'acquired' | 'failed';
estimatedCapability: number;
estimatedRevenue: number;
estimatedUsers: number;
reputation: number;
latestModelName: string;
completedMilestones: string[];
nextMilestoneAtTick: number;
}
export type CompetitorArchetype = 'safety-first' | 'move-fast' | 'big-tech' | 'open-source' | 'stealth-startup';
export interface CompetitorPersonality {
aggression: number;
safetyFocus: number;
openSourceTendency: number;
marketingFocus: number;
researchFocus: number;
riskTolerance: number;
}
export const INITIAL_COMPETITORS: CompetitorState = {
rivals: [],
industryBenchmark: 0,
};
+17
View File
@@ -0,0 +1,17 @@
export interface ComputeState {
totalFlops: number;
trainingAllocation: number;
inferenceAllocation: number;
inferenceUtilization: number;
tokensPerSecondCapacity: number;
tokensPerSecondDemand: number;
}
export const INITIAL_COMPUTE: ComputeState = {
totalFlops: 0,
trainingAllocation: 0.5,
inferenceAllocation: 0.5,
inferenceUtilization: 0,
tokensPerSecondCapacity: 0,
tokensPerSecondDemand: 0,
};
+47
View File
@@ -0,0 +1,47 @@
export interface DataState {
ownedDatasets: OwnedDataset[];
userDataGenerationRate: number;
totalTrainingTokens: number;
partnerships: DataPartnership[];
}
export interface OwnedDataset {
id: string;
name: string;
domain: DataDomain;
sizeTokens: number;
quality: number;
legalRisk: number;
acquiredAtTick: number;
}
export type DataDomain = 'web' | 'books' | 'code' | 'scientific' | 'conversation'
| 'multilingual' | 'images' | 'video' | 'audio' | 'synthetic';
export interface DataPartnership {
id: string;
partnerName: string;
domain: DataDomain;
tokensPerTick: number;
costPerTick: number;
exclusivity: boolean;
durationTicks: number;
startTick: number;
}
export const INITIAL_DATA: DataState = {
ownedDatasets: [
{
id: 'web-crawl-basic',
name: 'Basic Web Crawl',
domain: 'web',
sizeTokens: 1_000_000_000,
quality: 0.3,
legalRisk: 0.2,
acquiredAtTick: 0,
},
],
userDataGenerationRate: 0,
totalTrainingTokens: 1_000_000_000,
partnerships: [],
};
+63
View File
@@ -0,0 +1,63 @@
export interface EconomyState {
money: number;
totalRevenue: number;
totalExpenses: number;
revenuePerTick: number;
expensesPerTick: number;
funding: FundingState;
financialHistory: FinancialSnapshot[];
}
export interface FundingState {
totalRaised: number;
currentRound: FundingRound | null;
completedRounds: CompletedFundingRound[];
founderEquity: number;
valuation: number;
isPublic: boolean;
}
export type FundingRoundType = 'seed' | 'seriesA' | 'seriesB' | 'seriesC' | 'seriesD' | 'ipo';
export interface FundingRound {
type: FundingRoundType;
targetAmount: number;
dilution: number;
requirements: {
minRevenue?: number;
minUsers?: number;
minReputation?: number;
};
}
export interface CompletedFundingRound {
type: FundingRoundType;
amount: number;
dilution: number;
completedAtTick: number;
}
export interface FinancialSnapshot {
tick: number;
money: number;
revenue: number;
expenses: number;
valuation: number;
}
export const INITIAL_ECONOMY: EconomyState = {
money: 50_000,
totalRevenue: 0,
totalExpenses: 0,
revenuePerTick: 0,
expensesPerTick: 0,
funding: {
totalRaised: 0,
currentRound: null,
completedRounds: [],
founderEquity: 1.0,
valuation: 100_000,
isPublic: false,
},
financialHistory: [],
};
+74
View File
@@ -0,0 +1,74 @@
import type { Era } from './gameState';
export interface EventState {
activeEvents: ActiveEvent[];
eventHistory: EventHistoryEntry[];
eventCooldowns: Record<string, number>;
eventOccurrences: Record<string, number>;
}
export interface ActiveEvent {
eventId: string;
instanceId: string;
triggeredAtTick: number;
expiresAtTick: number;
title: string;
description: string;
category: EventCategory;
choices: EventChoice[];
defaultChoiceIndex: number;
}
export type EventCategory = 'industry' | 'regulatory' | 'pr' | 'internal' | 'market';
export interface EventChoice {
label: string;
description: string;
consequences: EventConsequence[];
}
export interface EventConsequence {
type: 'money' | 'reputation' | 'compute' | 'talent' | 'research_speed'
| 'regulation' | 'competitor' | 'unlock' | 'lock' | 'chain_event';
value: number;
target?: string;
delay?: number;
}
export interface EventHistoryEntry {
eventId: string;
instanceId: string;
title: string;
category: EventCategory;
tick: number;
chosenOptionIndex: number;
}
export interface EventDefinition {
id: string;
title: string;
descriptionTemplate: string;
category: EventCategory;
eras: Era[];
weight: number;
cooldownTicks: number;
maxOccurrences: number;
prerequisites: string[];
conditions: EventCondition[];
choices: EventChoice[];
defaultChoiceIndex: number;
expiryTicks: number;
}
export interface EventCondition {
field: string;
operator: 'gt' | 'lt' | 'gte' | 'lte' | 'eq';
value: number;
}
export const INITIAL_EVENTS: EventState = {
activeEvents: [],
eventHistory: [],
eventCooldowns: {},
eventOccurrences: {},
};
+63
View File
@@ -0,0 +1,63 @@
import type { EconomyState } from './economy';
import type { InfrastructureState } from './infrastructure';
import type { ComputeState } from './compute';
import type { ResearchState } from './research';
import type { ModelsState } from './models';
import type { MarketState } from './market';
import type { CompetitorState } from './competitors';
import type { TalentState } from './talent';
import type { DataState } from './data';
import type { ReputationState } from './reputation';
import type { EventState } from './events';
import type { AchievementState } from './achievements';
export interface GameState {
meta: GameMeta;
economy: EconomyState;
infrastructure: InfrastructureState;
compute: ComputeState;
research: ResearchState;
models: ModelsState;
market: MarketState;
competitors: CompetitorState;
talent: TalentState;
data: DataState;
reputation: ReputationState;
events: EventState;
achievements: AchievementState;
}
export interface GameMeta {
saveVersion: number;
companyName: string;
currentEra: Era;
tickCount: number;
lastTickTimestamp: number;
gameSpeed: GameSpeed;
isPaused: boolean;
createdAt: number;
totalPlayTime: number;
settings: GameSettings;
}
export type Era = 'startup' | 'scaleup' | 'bigtech' | 'agi';
export type GameSpeed = 1 | 2 | 5;
export interface GameSettings {
autoSaveInterval: number;
notificationsEnabled: boolean;
soundEnabled: boolean;
musicVolume: number;
sfxVolume: number;
}
export const INITIAL_SETTINGS: GameSettings = {
autoSaveInterval: 60,
notificationsEnabled: true,
soundEnabled: true,
musicVolume: 0.5,
sfxVolume: 0.7,
};
export const SAVE_VERSION = 1;
@@ -0,0 +1,84 @@
import type { Era } from './gameState';
export interface InfrastructureState {
dataCenters: DataCenter[];
gpuMarketPrices: Record<GpuType, number>;
totalFlops: number;
totalUptime: number;
}
export interface DataCenter {
id: string;
name: string;
location: LocationId;
gpus: GpuInventory[];
maxCapacity: number;
coolingLevel: number;
redundancyLevel: number;
currentUptime: number;
energyCostPerTick: number;
maintenanceCostPerTick: number;
}
export interface GpuInventory {
type: GpuType;
count: number;
healthyCount: number;
failedCount: number;
}
export type GpuType = 'consumer' | 't4' | 'a100' | 'h100' | 'b200' | 'custom';
export type LocationId = 'us-west' | 'us-east' | 'eu-west' | 'eu-north' | 'asia-east' | 'asia-south' | 'middle-east';
export interface LocationConfig {
id: LocationId;
name: string;
energyCostMultiplier: number;
latencyTier: number;
regulatoryStrictness: number;
politicalStability: number;
availableAt: Era;
}
export interface GpuConfig {
type: GpuType;
name: string;
flopsPerUnit: number;
basePowerDraw: number;
basePrice: number;
availableAt: Era;
}
export const GPU_CONFIGS: Record<GpuType, GpuConfig> = {
consumer: { type: 'consumer', name: 'Consumer GPU', flopsPerUnit: 1, basePowerDraw: 0.3, basePrice: 800, availableAt: 'startup' },
t4: { type: 't4', name: 'NVIDIA T4', flopsPerUnit: 8, basePowerDraw: 0.5, basePrice: 5_000, availableAt: 'startup' },
a100: { type: 'a100', name: 'NVIDIA A100', flopsPerUnit: 40, basePowerDraw: 1.0, basePrice: 15_000, availableAt: 'scaleup' },
h100: { type: 'h100', name: 'NVIDIA H100', flopsPerUnit: 120, basePowerDraw: 1.2, basePrice: 35_000, availableAt: 'scaleup' },
b200: { type: 'b200', name: 'NVIDIA B200', flopsPerUnit: 400, basePowerDraw: 1.5, basePrice: 50_000, availableAt: 'bigtech' },
custom: { type: 'custom', name: 'Custom ASIC', flopsPerUnit: 800, basePowerDraw: 1.0, basePrice: 80_000, availableAt: 'agi' },
};
export const LOCATION_CONFIGS: Record<LocationId, LocationConfig> = {
'us-west': { id: 'us-west', name: 'US West (Oregon)', energyCostMultiplier: 1.0, latencyTier: 1, regulatoryStrictness: 0.3, politicalStability: 0.9, availableAt: 'startup' },
'us-east': { id: 'us-east', name: 'US East (Virginia)', energyCostMultiplier: 1.1, latencyTier: 1, regulatoryStrictness: 0.3, politicalStability: 0.9, availableAt: 'startup' },
'eu-west': { id: 'eu-west', name: 'EU West (Ireland)', energyCostMultiplier: 1.3, latencyTier: 2, regulatoryStrictness: 0.7, politicalStability: 0.85, availableAt: 'scaleup' },
'eu-north': { id: 'eu-north', name: 'EU North (Sweden)', energyCostMultiplier: 0.8, latencyTier: 2, regulatoryStrictness: 0.6, politicalStability: 0.95, availableAt: 'scaleup' },
'asia-east': { id: 'asia-east', name: 'Asia East (Tokyo)', energyCostMultiplier: 1.4, latencyTier: 3, regulatoryStrictness: 0.4, politicalStability: 0.9, availableAt: 'scaleup' },
'asia-south': { id: 'asia-south', name: 'Asia South (Mumbai)', energyCostMultiplier: 0.6, latencyTier: 3, regulatoryStrictness: 0.2, politicalStability: 0.7, availableAt: 'bigtech' },
'middle-east': { id: 'middle-east', name: 'Middle East (UAE)', energyCostMultiplier: 0.5, latencyTier: 3, regulatoryStrictness: 0.1, politicalStability: 0.6, availableAt: 'bigtech' },
};
export const INITIAL_INFRASTRUCTURE: InfrastructureState = {
dataCenters: [],
gpuMarketPrices: {
consumer: 800,
t4: 5_000,
a100: 15_000,
h100: 35_000,
b200: 50_000,
custom: 80_000,
},
totalFlops: 0,
totalUptime: 1,
};
+73
View File
@@ -0,0 +1,73 @@
export interface MarketState {
consumers: ConsumerMarket;
enterprise: EnterpriseMarket;
overloadPolicy: OverloadPolicy;
openSourcedModels: string[];
}
export interface ConsumerMarket {
totalSubscribers: number;
churnRatePerTick: number;
growthRatePerTick: number;
satisfaction: number;
viralCoefficient: number;
}
export interface EnterpriseMarket {
activeContracts: EnterpriseContract[];
pendingRFPs: EnterpriseRFP[];
totalApiCallsPerTick: number;
averageTokensPerCall: number;
}
export interface EnterpriseContract {
id: string;
customerName: string;
segment: 'startup' | 'mid-market' | 'enterprise' | 'government';
tokensPerTick: number;
pricePerMToken: number;
slaUptime: number;
startTick: number;
durationTicks: number;
satisfaction: number;
}
export interface EnterpriseRFP {
id: string;
customerName: string;
segment: 'startup' | 'mid-market' | 'enterprise' | 'government';
requiredCapability: number;
offeredPricePerMToken: number;
requiredSlaUptime: number;
expiresAtTick: number;
}
export interface OverloadPolicy {
maxQueueDepth: number;
rateLimitPerCustomer: number;
degradeQualityUnderLoad: boolean;
prioritizeEnterprise: boolean;
}
export const INITIAL_MARKET: MarketState = {
consumers: {
totalSubscribers: 0,
churnRatePerTick: 0.001,
growthRatePerTick: 0,
satisfaction: 0.5,
viralCoefficient: 0,
},
enterprise: {
activeContracts: [],
pendingRFPs: [],
totalApiCallsPerTick: 0,
averageTokensPerCall: 500,
},
overloadPolicy: {
maxQueueDepth: 100,
rateLimitPerCustomer: 1000,
degradeQualityUnderLoad: false,
prioritizeEnterprise: true,
},
openSourcedModels: [],
};
+106
View File
@@ -0,0 +1,106 @@
export interface ModelsState {
trainedModels: TrainedModel[];
activeTraining: TrainingJob | null;
productLines: ProductLine[];
}
export interface TrainedModel {
id: string;
name: string;
generation: number;
parameterCount: number;
trainingDataSize: number;
capabilities: ModelCapabilities;
safetyScore: number;
benchmarkScore: number;
tuning: ModelTuning;
isDeployed: boolean;
trainedAtTick: number;
}
export interface ModelCapabilities {
reasoning: number;
coding: number;
creative: number;
multimodal: number;
agents: number;
speed: number;
}
export interface ModelTuning {
preset: TuningPreset;
verbosity?: number;
safetyLevel?: number;
creativity?: number;
speedQuality?: number;
refusalRate?: number;
}
export type TuningPreset = 'helpful-safe' | 'max-capability' | 'enterprise' | 'creative';
export interface TrainingJob {
modelName: string;
generation: number;
allocatedCompute: number;
allocatedDataTokens: number;
progressTicks: number;
totalTicks: number;
estimatedCapability: number;
}
export interface ProductLine {
id: string;
type: ProductLineType;
name: string;
modelId: string | null;
isActive: boolean;
pricing: ProductPricing;
}
export type ProductLineType = 'text-api' | 'chat-product' | 'image' | 'code' | 'agents';
export interface ProductPricing {
inputTokenPrice: number;
outputTokenPrice: number;
thinkingTokenBudget: number;
cachingEnabled: boolean;
subscriptionPrice: number;
freeTokenAllowance: number;
}
export const INITIAL_MODELS: ModelsState = {
trainedModels: [],
activeTraining: null,
productLines: [
{
id: 'text-api',
type: 'text-api',
name: 'Text API',
modelId: null,
isActive: false,
pricing: {
inputTokenPrice: 1.0,
outputTokenPrice: 3.0,
thinkingTokenBudget: 0,
cachingEnabled: false,
subscriptionPrice: 0,
freeTokenAllowance: 0,
},
},
{
id: 'chat-product',
type: 'chat-product',
name: 'Chat Product',
modelId: null,
isActive: false,
pricing: {
inputTokenPrice: 0,
outputTokenPrice: 0,
thinkingTokenBudget: 0,
cachingEnabled: false,
subscriptionPrice: 20,
freeTokenAllowance: 10_000,
},
},
],
};
+22
View File
@@ -0,0 +1,22 @@
export interface ReputationState {
score: number;
safetyRecord: number;
publicPerception: number;
employeeSatisfaction: number;
regulatoryStanding: number;
reputationHistory: ReputationSnapshot[];
}
export interface ReputationSnapshot {
tick: number;
score: number;
}
export const INITIAL_REPUTATION: ReputationState = {
score: 50,
safetyRecord: 50,
publicPerception: 50,
employeeSatisfaction: 70,
regulatoryStanding: 50,
reputationHistory: [],
};
+45
View File
@@ -0,0 +1,45 @@
import type { Era } from './gameState';
export interface ResearchState {
completedResearch: string[];
activeResearch: ActiveResearch | null;
researchPoints: number;
}
export interface ActiveResearch {
researchId: string;
progressTicks: number;
totalTicks: number;
allocatedResearchers: number;
allocatedCompute: number;
}
export interface ResearchNode {
id: string;
name: string;
description: string;
era: Era;
category: 'generation' | 'efficiency' | 'safety' | 'specialization' | 'infrastructure';
branch?: 'reasoning' | 'coding' | 'creative' | 'multimodal' | 'agents';
prerequisites: string[];
cost: {
researchPoints: number;
compute: number;
ticks: number;
};
effects: ResearchEffect[];
}
export interface ResearchEffect {
type: 'unlock_gpu' | 'unlock_model_tier' | 'efficiency_boost'
| 'capability_boost' | 'cost_reduction' | 'unlock_feature'
| 'unlock_product_line' | 'safety_boost';
target: string;
value: number;
}
export const INITIAL_RESEARCH: ResearchState = {
completedResearch: [],
activeResearch: null,
researchPoints: 0,
};
+49
View File
@@ -0,0 +1,49 @@
export interface TalentState {
departments: Record<DepartmentId, Department>;
keyHires: KeyHire[];
hiringPipeline: HiringCandidate[];
totalSalaryPerTick: number;
}
export type DepartmentId = 'research' | 'engineering' | 'operations' | 'sales';
export interface Department {
id: DepartmentId;
headcount: number;
budget: number;
effectiveness: number;
morale: number;
}
export interface KeyHire {
id: string;
name: string;
department: DepartmentId;
specialAbility: string;
effects: { type: string; value: number }[];
salary: number;
hiredAtTick: number;
loyalty: number;
}
export interface HiringCandidate {
id: string;
name: string;
department: DepartmentId;
quality: number;
salaryCost: number;
expiresAtTick: number;
isKeyHire: boolean;
}
export const INITIAL_TALENT: TalentState = {
departments: {
research: { id: 'research', headcount: 2, budget: 5_000, effectiveness: 0.5, morale: 0.8 },
engineering: { id: 'engineering', headcount: 3, budget: 7_000, effectiveness: 0.5, morale: 0.8 },
operations: { id: 'operations', headcount: 1, budget: 3_000, effectiveness: 0.5, morale: 0.8 },
sales: { id: 'sales', headcount: 0, budget: 0, effectiveness: 0, morale: 0.8 },
},
keyHires: [],
hiringPipeline: [],
totalSalaryPerTick: 0,
};
+33
View File
@@ -0,0 +1,33 @@
export function formatNumber(n: number): string {
if (n < 0) return `-${formatNumber(-n)}`;
if (n < 1_000) return Math.floor(n).toString();
if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}K`;
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n < 1_000_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
return `${(n / 1_000_000_000_000).toFixed(1)}T`;
}
export function formatMoney(n: number): string {
if (n < 0) return `-$${formatNumber(-n)}`;
return `$${formatNumber(n)}`;
}
export function formatPercent(n: number, decimals = 1): string {
return `${(n * 100).toFixed(decimals)}%`;
}
export function formatTokens(n: number): string {
return `${formatNumber(n)} tok`;
}
export function formatFlops(n: number): string {
return `${formatNumber(n)} FLOPS`;
}
export function formatDuration(ticks: number): string {
if (ticks < 60) return `${ticks}s`;
if (ticks < 3600) return `${Math.floor(ticks / 60)}m ${ticks % 60}s`;
const hours = Math.floor(ticks / 3600);
const minutes = Math.floor((ticks % 3600) / 60);
return `${hours}h ${minutes}m`;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ai-tycoon/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "ESNext",
"outDir": "dist",
"rootDir": "src"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@ai-tycoon/tsconfig",
"private": true,
"files": ["base.json", "react.json", "node.json"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"noEmit": true
}
}
+2090
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {},
"clean": {
"cache": false
}
}
}