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
+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>
);
}