Overhaul infrastructure: replace GPU model with rack-centric system
CI / build-and-push (push) Successful in 33s
CI / build-and-push (push) Successful in 33s
Replace flat GPU buying with a realistic data center + rack pipeline: - 4 DC tiers (small/medium/large/mega) with construction time, dual capacity constraints (rack slots + power budget kW), and era/research gating - 10 predefined rack SKUs from consumer GPUs through custom ASICs, each with unique FLOPS, power draw, cost, and pipeline timings - 6-stage procurement pipeline (order → mfg → receive → install → test → production) with Kanban UI, talent-influenced speed bonuses - Test failures (5-25% base rate) reduced by cooling, ops talent, and QA research; auto-repair with cost and re-test cycle - Production failures at low per-tick rate, racks sent to repair pipeline - Cooling and redundancy upgrades per DC (reduce failure rates) - 4 new tech tree nodes (DC Engineering II/III/IV, Quality Assurance) - Save version bump (1→2) with migration that resets old saves - Updated economy system to account for rack repair costs - Redesigned Infrastructure page with pipeline Kanban, capacity bars, rack ordering, and DC upgrade panels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
const reputation = useGameStore((s) => s.reputation.score);
|
||||
const achievements = useGameStore((s) => s.achievements.unlocked.length);
|
||||
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters.length);
|
||||
const totalRacks = useGameStore((s) => s.infrastructure.totalRackCount);
|
||||
|
||||
const eraLabel = era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI';
|
||||
const hours = Math.floor(totalPlayTime / 3600);
|
||||
@@ -32,7 +33,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
`Valuation: ${formatMoney(valuation)}`,
|
||||
`Subscribers: ${formatNumber(subscribers)} | Models: ${models}`,
|
||||
`Best Model: ${bestModel.toFixed(1)}/100 | Reputation: ${reputation}/100`,
|
||||
`Data Centers: ${dataCenters} | Achievements: ${achievements}/${ACHIEVEMENT_DEFINITIONS.length}`,
|
||||
`Data Centers: ${dataCenters} | Racks: ${totalRacks} | Achievements: ${achievements}/${ACHIEVEMENT_DEFINITIONS.length}`,
|
||||
].join('\n');
|
||||
|
||||
const handleCopy = () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DashboardPage() {
|
||||
|
||||
{dataCenters.length === 0 && (
|
||||
<TutorialHint id="welcome">
|
||||
Welcome to AI Tycoon! Start by building a data center in the Infrastructure tab, then buy GPUs to begin training your first AI model.
|
||||
Welcome to AI Tycoon! Start by building a data center in the Infrastructure tab, then order racks to begin training your first AI model.
|
||||
</TutorialHint>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,41 +1,342 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Server, Cpu, MapPin } from 'lucide-react';
|
||||
import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield } 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';
|
||||
import {
|
||||
formatMoney, formatNumber, formatPercent,
|
||||
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
|
||||
} from '@ai-tycoon/shared';
|
||||
import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared';
|
||||
|
||||
export function InfrastructurePage() {
|
||||
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
|
||||
const gpuPrices = useGameStore((s) => s.infrastructure.gpuMarketPrices);
|
||||
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
|
||||
const STAGE_LABELS: Record<PipelineStage, string> = {
|
||||
ordered: 'Ordered',
|
||||
manufacturing: 'Manufacturing',
|
||||
receiving: 'Receiving',
|
||||
installation: 'Installation',
|
||||
testing: 'Testing',
|
||||
repair: 'Repair',
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<PipelineStage, string> = {
|
||||
ordered: 'bg-surface-600',
|
||||
manufacturing: 'bg-blue-500',
|
||||
receiving: 'bg-cyan-500',
|
||||
installation: 'bg-violet-500',
|
||||
testing: 'bg-amber-500',
|
||||
repair: 'bg-danger',
|
||||
};
|
||||
|
||||
function PipelineKanban() {
|
||||
const pipeline = useGameStore((s) => s.infrastructure.rackPipeline);
|
||||
|
||||
if (pipeline.length === 0) return null;
|
||||
|
||||
const stages: PipelineStage[] = ['ordered', 'manufacturing', 'receiving', 'installation', 'testing', 'repair'];
|
||||
const grouped = stages.map(stage => ({
|
||||
stage,
|
||||
orders: pipeline.filter(o => o.stage === stage),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-surface-300 uppercase mb-3">Rack Pipeline</h3>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{grouped.map(({ stage, orders }) => (
|
||||
<div key={stage}>
|
||||
<div className="text-[10px] text-surface-400 uppercase mb-1.5 text-center">
|
||||
{STAGE_LABELS[stage]} ({orders.length})
|
||||
</div>
|
||||
<div className="space-y-1.5 min-h-[60px]">
|
||||
{orders.map(order => (
|
||||
<PipelineCard key={order.id} order={order} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineCard({ order }: { order: RackOrder }) {
|
||||
const sku = RACK_SKU_CONFIGS[order.skuId];
|
||||
const progress = order.stageTotal > 0 ? order.stageProgress / order.stageTotal : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-surface-800 border border-surface-600 rounded p-1.5 text-[11px]">
|
||||
<div className="font-medium truncate">{sku.name}</div>
|
||||
<div className="w-full bg-surface-700 rounded-full h-1 mt-1">
|
||||
<div
|
||||
className={`h-1 rounded-full ${STAGE_COLORS[order.stage]}`}
|
||||
style={{ width: `${Math.min(100, progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{order.repairCount > 0 && (
|
||||
<div className="text-danger mt-0.5 flex items-center gap-0.5">
|
||||
<Wrench size={8} /> {order.repairCount}x
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacityBar({ label, used, max, unit, icon: Icon }: {
|
||||
label: string; used: number; max: number; unit: string;
|
||||
icon: typeof HardDrive;
|
||||
}) {
|
||||
const pct = max > 0 ? used / max : 0;
|
||||
const color = pct > 0.9 ? 'bg-danger' : pct > 0.7 ? 'bg-amber-500' : 'bg-accent';
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-[11px] mb-0.5">
|
||||
<span className="text-surface-400 flex items-center gap-1"><Icon size={12} />{label}</span>
|
||||
<span className="text-surface-300">{formatNumber(used)}/{formatNumber(max)} {unit}</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-700 rounded-full h-2">
|
||||
<div className={`h-2 rounded-full ${color} transition-all`} style={{ width: `${Math.min(100, pct * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataCenterCard({ dcId }: { dcId: string }) {
|
||||
const dc = useGameStore((s) => s.infrastructure.dataCenters.find(d => d.id === dcId))!;
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
||||
const orderRack = useGameStore((s) => s.orderRack);
|
||||
const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
||||
const currentEraIdx = ERA_ORDER.indexOf(era);
|
||||
|
||||
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(sku => {
|
||||
if (ERA_ORDER.indexOf(sku.era) > currentEraIdx) return false;
|
||||
if (sku.requiredResearch && !completedResearch.includes(sku.requiredResearch)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (dc.status === 'constructing') {
|
||||
const pct = dc.constructionTotal > 0 ? dc.constructionProgress / dc.constructionTotal : 0;
|
||||
return (
|
||||
<div className="bg-surface-900 border border-amber-500/30 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{dc.name}</h3>
|
||||
<div className="text-xs text-amber-400">Under Construction — {tierConfig.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-surface-400">
|
||||
<MapPin size={12} />
|
||||
{LOCATION_CONFIGS[dc.location].name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-surface-700 rounded-full h-3">
|
||||
<div className="h-3 rounded-full bg-amber-500 transition-all" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-surface-400 mt-1 text-right">
|
||||
{Math.round(pct * 100)}% — {dc.constructionTotal - dc.constructionProgress}s remaining
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg">{dc.name}</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/20 text-accent-light uppercase">{dc.tier}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-surface-400">
|
||||
<span className="flex items-center gap-1"><MapPin size={12} />{LOCATION_CONFIGS[dc.location].name}</span>
|
||||
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
|
||||
<span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-surface-400 hover:text-surface-200">
|
||||
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-3">
|
||||
<CapacityBar label="Slots" used={dc.usedSlots} max={tierConfig.rackSlots} unit="" icon={HardDrive} />
|
||||
<CapacityBar label="Power" used={dc.usedPowerKW} max={tierConfig.powerBudgetKW} unit="kW" icon={Zap} />
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{dc.racks.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Production Racks ({dc.racks.length})</h4>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{dc.racks.map(rack => {
|
||||
const sku = RACK_SKU_CONFIGS[rack.skuId];
|
||||
return (
|
||||
<div key={rack.id} className={`text-[11px] rounded p-1.5 border ${
|
||||
rack.isHealthy
|
||||
? 'bg-surface-800 border-surface-600'
|
||||
: 'bg-danger/10 border-danger/30'
|
||||
}`}>
|
||||
<div className="font-medium truncate">{sku.name}</div>
|
||||
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Order Racks</h4>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{availableSkus.map(sku => {
|
||||
const canAfford = money >= sku.baseCost;
|
||||
const hasSlot = dc.usedSlots < tierConfig.rackSlots;
|
||||
const hasPower = dc.usedPowerKW + sku.powerDrawKW <= tierConfig.powerBudgetKW;
|
||||
const disabled = !canAfford || !hasSlot || !hasPower;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sku.id}
|
||||
onClick={() => orderRack(dc.id, sku.id)}
|
||||
disabled={disabled}
|
||||
className="bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-left"
|
||||
title={!hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : ''}
|
||||
>
|
||||
<div className="font-medium">{sku.name}</div>
|
||||
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Upgrades</h4>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => upgradeDataCenter(dc.id, 'cooling')}
|
||||
disabled={dc.coolingLevel >= 1.0 || money < tierConfig.baseCost * 0.25}
|
||||
className="flex items-center gap-1.5 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-3 py-2 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Thermometer size={14} />
|
||||
Cooling Lv{Math.round(dc.coolingLevel * 10)}
|
||||
<span className="text-surface-400">{formatMoney(tierConfig.baseCost * 0.25)}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => upgradeDataCenter(dc.id, 'redundancy')}
|
||||
disabled={dc.redundancyLevel >= 1.0 || money < tierConfig.baseCost * 0.25}
|
||||
className="flex items-center gap-1.5 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-3 py-2 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Shield size={14} />
|
||||
Redundancy Lv{Math.round(dc.redundancyLevel * 10)}
|
||||
<span className="text-surface-400">{formatMoney(tierConfig.baseCost * 0.25)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildDCPanel({ onClose }: { onClose: () => void }) {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
||||
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 [name, setName] = useState('');
|
||||
const [location, setLocation] = useState<LocationId>('us-west');
|
||||
const [tier, setTier] = useState<DCTier>('small');
|
||||
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(era);
|
||||
const currentEraIdx = ERA_ORDER.indexOf(era);
|
||||
|
||||
const availableLocations = Object.values(LOCATION_CONFIGS).filter(
|
||||
loc => eraOrder.indexOf(loc.availableAt) <= currentEraIdx,
|
||||
loc => ERA_ORDER.indexOf(loc.availableAt) <= currentEraIdx,
|
||||
);
|
||||
|
||||
const availableGpus = Object.values(GPU_CONFIGS).filter(
|
||||
gpu => eraOrder.indexOf(gpu.availableAt) <= currentEraIdx,
|
||||
);
|
||||
const availableTiers = Object.values(DC_TIER_CONFIGS).filter(t => {
|
||||
if (ERA_ORDER.indexOf(t.requiredEra) > currentEraIdx) return false;
|
||||
if (t.requiredResearch && !completedResearch.includes(t.requiredResearch)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleBuildDC = () => {
|
||||
if (!newDCName.trim()) return;
|
||||
buildDataCenter(newDCName.trim(), newDCLocation);
|
||||
setNewDCName('');
|
||||
setShowNewDC(false);
|
||||
const tierConfig = DC_TIER_CONFIGS[tier];
|
||||
|
||||
const handleBuild = () => {
|
||||
if (!name.trim()) return;
|
||||
buildDataCenter(name.trim(), location, tier);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(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={location}
|
||||
onChange={(e) => setLocation(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>
|
||||
<label className="block text-xs text-surface-400 mb-1">Tier</label>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as DCTier)}
|
||||
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"
|
||||
>
|
||||
{availableTiers.map(t => (
|
||||
<option key={t.tier} value={t.tier}>{t.name} ({t.rackSlots} slots, {t.powerBudgetKW}kW)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-surface-400">
|
||||
<span>Cost: {formatMoney(tierConfig.baseCost)} · Build time: {tierConfig.buildTimeTicks}s</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200">Cancel</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={money < tierConfig.baseCost || !name.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>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructurePage() {
|
||||
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
|
||||
const [showNewDC, setShowNewDC] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Infrastructure</h2>
|
||||
<button
|
||||
@@ -47,51 +348,11 @@ export function InfrastructurePage() {
|
||||
</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>
|
||||
)}
|
||||
{showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
|
||||
|
||||
{dataCenters.length === 0 ? (
|
||||
<PipelineKanban />
|
||||
|
||||
{dataCenters.length === 0 && !showNewDC ? (
|
||||
<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>
|
||||
@@ -99,75 +360,10 @@ export function InfrastructurePage() {
|
||||
) : (
|
||||
<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>
|
||||
<DataCenterCard key={dc.id} dcId={dc.id} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+104
-37
@@ -6,8 +6,8 @@ import type {
|
||||
ResearchState, ModelsState, MarketState,
|
||||
CompetitorState, TalentState, DataState,
|
||||
ReputationState, EventState, AchievementState,
|
||||
DataCenter, GpuType, GpuInventory, TrainingJob,
|
||||
ActiveResearch, EventConsequence, OwnedDataset,
|
||||
DataCenter, DCTier, RackSkuId, TrainingJob,
|
||||
ActiveResearch, EventConsequence, OwnedDataset, LocationId,
|
||||
} from '@ai-tycoon/shared';
|
||||
import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared';
|
||||
import {
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
|
||||
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
|
||||
INITIAL_REPUTATION, INITIAL_EVENTS, INITIAL_ACHIEVEMENTS,
|
||||
GPU_CONFIGS,
|
||||
DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
|
||||
PIPELINE_ORDER_BASE_TICKS, DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
|
||||
FUNDING_ROUNDS,
|
||||
OPEN_SOURCE_REPUTATION_BOOST,
|
||||
uuid,
|
||||
@@ -48,8 +49,9 @@ interface Actions {
|
||||
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;
|
||||
buildDataCenter: (name: string, location: LocationId, tier: DCTier) => void;
|
||||
orderRack: (dataCenterId: string, skuId: RackSkuId) => void;
|
||||
upgradeDataCenter: (dataCenterId: string, upgrade: 'cooling' | 'redundancy') => void;
|
||||
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => void;
|
||||
deployModel: (modelId: string) => void;
|
||||
setProductPricing: (productLineId: string, field: string, value: number) => void;
|
||||
@@ -146,51 +148,37 @@ export const useGameStore = create<Store>()(
|
||||
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;
|
||||
buildDataCenter: (name, location, tier) => set((s) => {
|
||||
const tierConfig = DC_TIER_CONFIGS[tier];
|
||||
if (s.economy.money < tierConfig.baseCost) 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 };
|
||||
});
|
||||
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
if (eraOrder.indexOf(s.meta.currentEra) < eraOrder.indexOf(tierConfig.requiredEra)) return s;
|
||||
if (tierConfig.requiredResearch && !s.research.completedResearch.includes(tierConfig.requiredResearch)) return s;
|
||||
|
||||
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 isFirstDC = s.infrastructure.dataCenters.length === 0;
|
||||
const buildTime = isFirstDC ? tierConfig.firstBuildTimeTicks : tierConfig.buildTimeTicks;
|
||||
|
||||
const dc: DataCenter = {
|
||||
id: uuid(),
|
||||
name,
|
||||
location,
|
||||
gpus: [],
|
||||
maxCapacity: 100,
|
||||
coolingLevel: 0.5,
|
||||
redundancyLevel: 0.3,
|
||||
tier,
|
||||
status: 'constructing',
|
||||
constructionProgress: 0,
|
||||
constructionTotal: buildTime,
|
||||
racks: [],
|
||||
coolingLevel: 0,
|
||||
redundancyLevel: 0,
|
||||
currentUptime: 1,
|
||||
energyCostPerTick: 0,
|
||||
maintenanceCostPerTick: 0,
|
||||
usedSlots: 0,
|
||||
usedPowerKW: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
economy: { ...s.economy, money: s.economy.money - buildCost },
|
||||
economy: { ...s.economy, money: s.economy.money - tierConfig.baseCost },
|
||||
infrastructure: {
|
||||
...s.infrastructure,
|
||||
dataCenters: [...s.infrastructure.dataCenters, dc],
|
||||
@@ -198,6 +186,67 @@ export const useGameStore = create<Store>()(
|
||||
};
|
||||
}),
|
||||
|
||||
orderRack: (dataCenterId, skuId) => set((s) => {
|
||||
const sku = RACK_SKU_CONFIGS[skuId];
|
||||
if (s.economy.money < sku.baseCost) return s;
|
||||
|
||||
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
if (eraOrder.indexOf(s.meta.currentEra) < eraOrder.indexOf(sku.era)) return s;
|
||||
if (sku.requiredResearch && !s.research.completedResearch.includes(sku.requiredResearch)) return s;
|
||||
|
||||
const dc = s.infrastructure.dataCenters.find(d => d.id === dataCenterId);
|
||||
if (!dc || dc.status !== 'operational') return s;
|
||||
|
||||
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
||||
if (dc.usedSlots >= tierConfig.rackSlots) return s;
|
||||
if (dc.usedPowerKW + sku.powerDrawKW > tierConfig.powerBudgetKW) return s;
|
||||
|
||||
const order = {
|
||||
id: uuid(),
|
||||
skuId,
|
||||
dataCenterId,
|
||||
stage: 'ordered' as const,
|
||||
stageProgress: 0,
|
||||
stageTotal: PIPELINE_ORDER_BASE_TICKS,
|
||||
totalCost: sku.baseCost,
|
||||
repairCount: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
economy: { ...s.economy, money: s.economy.money - sku.baseCost },
|
||||
infrastructure: {
|
||||
...s.infrastructure,
|
||||
rackPipeline: [...s.infrastructure.rackPipeline, order],
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
upgradeDataCenter: (dataCenterId, upgrade) => set((s) => {
|
||||
const dc = s.infrastructure.dataCenters.find(d => d.id === dataCenterId);
|
||||
if (!dc || dc.status !== 'operational') return s;
|
||||
|
||||
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
||||
const cost = tierConfig.baseCost * DC_UPGRADE_COST_FRACTION;
|
||||
if (s.economy.money < cost) return s;
|
||||
|
||||
const currentLevel = upgrade === 'cooling' ? dc.coolingLevel : dc.redundancyLevel;
|
||||
if (currentLevel >= 1.0) return s;
|
||||
|
||||
const dataCenters = s.infrastructure.dataCenters.map(d => {
|
||||
if (d.id !== dataCenterId) return d;
|
||||
return {
|
||||
...d,
|
||||
[upgrade === 'cooling' ? 'coolingLevel' : 'redundancyLevel']:
|
||||
Math.min(1.0, currentLevel + DC_UPGRADE_INCREMENT),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
economy: { ...s.economy, money: s.economy.money - cost },
|
||||
infrastructure: { ...s.infrastructure, dataCenters },
|
||||
};
|
||||
}),
|
||||
|
||||
startTraining: (job) => set((s) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
@@ -419,10 +468,28 @@ export const useGameStore = create<Store>()(
|
||||
}),
|
||||
{
|
||||
name: 'ai-tycoon-save',
|
||||
version: SAVE_VERSION,
|
||||
partialize: (state) => {
|
||||
const { activePage, notifications, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
migrate: (_persisted, version) => {
|
||||
if (version < SAVE_VERSION) {
|
||||
return {
|
||||
...initialGameState,
|
||||
activePage: 'dashboard' as const,
|
||||
notifications: [{
|
||||
id: uuid(),
|
||||
title: 'Save Reset',
|
||||
message: 'Your save was reset due to a major infrastructure overhaul. Enjoy the new rack-based system!',
|
||||
type: 'info' as const,
|
||||
tick: 0,
|
||||
read: false,
|
||||
}],
|
||||
} as unknown as Store;
|
||||
}
|
||||
return _persisted as Store;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user