diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 34c794f..0985b18 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -1,13 +1,19 @@ import { useState, useMemo, useCallback } from 'react'; -import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react'; +import { + Plus, Server, MapPin, Zap, HardDrive, Wrench, + ChevronDown, ChevronUp, Thermometer, Shield, + Rocket, Clock, Lock, Trash2, ArrowUpDown, Cpu, Minus, + Activity, DollarSign, +} from 'lucide-react'; import { TutorialHint } from '@/components/game/TutorialHint'; +import { ConfirmModal } from '@/components/common/ConfirmModal'; import { useGameStore } from '@/store'; import { useShallow } from 'zustand/shallow'; 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'; +import type { DCTier, DCTierConfig, RackSkuId, LocationId, RackOrder, PipelineStage, Era, Rack } from '@ai-tycoon/shared'; const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; const collapsedDCs = new Set(); @@ -32,29 +38,130 @@ const STAGE_COLORS: Record = { decommission: 'bg-surface-500', }; -function PipelineKanban() { +// ─── Fleet Summary ───────────────────────────────────────────── + +function FleetStat({ label, value, sub, icon: Icon }: { + label: string; value: string; sub?: string; icon: typeof Server; +}) { + return ( +
+
+ + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function FleetSummary() { + const dataCenters = useGameStore((s) => s.infrastructure.dataCenters); const pipeline = useGameStore((s) => s.infrastructure.rackPipeline); + const stats = useMemo(() => { + const operational = dataCenters.filter(dc => dc.status === 'operational'); + const totalRacks = operational.reduce((sum, dc) => sum + dc.racks.length, 0); + const totalSlots = operational.reduce((sum, dc) => sum + DC_TIER_CONFIGS[dc.tier].rackSlots, 0); + const totalFlops = operational.reduce((sum, dc) => + sum + dc.racks.reduce((s, r) => s + RACK_SKU_CONFIGS[r.skuId].flopsPerRack, 0), 0); + const avgUptime = operational.length > 0 + ? operational.reduce((sum, dc) => sum + dc.currentUptime, 0) / operational.length + : 0; + const totalCost = operational.reduce((sum, dc) => sum + dc.energyCostPerTick + dc.maintenanceCostPerTick, 0); + const inPipeline = pipeline.filter(o => o.stage !== 'decommission').length; + const constructing = dataCenters.length - operational.length; + + return { dcCount: operational.length, constructing, totalRacks, totalSlots, totalFlops, avgUptime, totalCost, inPipeline }; + }, [dataCenters, pipeline]); + + if (dataCenters.length === 0) return null; + + return ( +
+ 0 ? `${stats.constructing} building` : 'operational'} + icon={Server} + /> + + 0 ? `${stats.inPipeline} in pipeline` : undefined} + icon={Cpu} + /> + + +
+ ); +} + +// ─── Pipeline Kanban ─────────────────────────────────────────── + +interface PipelineGroup { + key: string; + skuId: RackSkuId; + stage: PipelineStage; + orders: RackOrder[]; + avgProgress: number; + stageTotal: number; +} + +function PipelineKanban() { + const pipeline = useGameStore((s) => s.infrastructure.rackPipeline); if (pipeline.length === 0) return null; const stages: PipelineStage[] = ['ordered', 'manufacturing', 'receiving', 'installation', 'testing', 'repair', 'decommission']; - const grouped = stages.map(stage => ({ - stage, - orders: pipeline.filter(o => o.stage === stage), - })); + + const grouped = stages.map(stage => { + const stageOrders = pipeline.filter(o => o.stage === stage); + const skuMap = new Map(); + + for (const order of stageOrders) { + if (!skuMap.has(order.skuId)) skuMap.set(order.skuId, []); + skuMap.get(order.skuId)!.push(order); + } + + const groups: PipelineGroup[] = []; + for (const [skuId, orders] of skuMap) { + const avgProgress = orders.reduce((s, o) => s + (o.stageTotal > 0 ? o.stageProgress / o.stageTotal : 0), 0) / orders.length; + groups.push({ key: `${skuId}-${stage}`, skuId, stage, orders, avgProgress, stageTotal: orders[0].stageTotal }); + } + + return { stage, groups, count: stageOrders.length }; + }); + + const activeOrders = pipeline.filter(o => o.stage !== 'decommission'); + const testingOrders = pipeline.filter(o => o.stage === 'testing'); + const nearestToOnline = testingOrders.length > 0 + ? Math.min(...testingOrders.map(o => o.stageTotal - o.stageProgress)) + : null; return (
-

Rack Pipeline

+
+

Rack Pipeline

+
+ {activeOrders.length} order{activeOrders.length !== 1 ? 's' : ''} in pipeline + {nearestToOnline !== null && ( + + + Next online in ~{nearestToOnline}s + + )} +
+
- {grouped.map(({ stage, orders }) => ( + {grouped.map(({ stage, groups, count }) => (
- {STAGE_LABELS[stage]} ({orders.length}) + {STAGE_LABELS[stage]} ({count})
- {orders.map(order => ( - + {groups.map(group => ( + ))}
@@ -64,31 +171,41 @@ function PipelineKanban() { ); } -function PipelineCard({ order }: { order: RackOrder }) { - const sku = RACK_SKU_CONFIGS[order.skuId]; - const progress = order.stageTotal > 0 ? order.stageProgress / order.stageTotal : 0; +function PipelineGroupCard({ group }: { group: PipelineGroup }) { + const sku = RACK_SKU_CONFIGS[group.skuId]; + const remaining = Math.round(group.stageTotal * (1 - group.avgProgress)); + const hasRepairs = group.orders.some(o => o.repairCount > 0); return (
-
{sku.name}
+
+ {sku.name} + {group.orders.length > 1 && ×{group.orders.length}} +
- {order.repairCount > 0 && ( -
- {order.repairCount}x -
- )} +
+ + {remaining}s + + {hasRepairs && ( + + + + )} +
); } +// ─── Capacity Bar ────────────────────────────────────────────── + function CapacityBar({ label, used, max, unit, icon: Icon }: { - label: string; used: number; max: number; unit: string; - icon: typeof HardDrive; + 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'; @@ -106,17 +223,431 @@ function CapacityBar({ label, used, max, unit, icon: Icon }: { ); } -function DataCenterCard({ dcId }: { dcId: string }) { - const dc = useGameStore((s) => s.infrastructure.dataCenters.find(d => d.id === dcId))!; - const pipelineForDc = useGameStore(useShallow((s) => s.infrastructure.rackPipeline.filter(o => o.dataCenterId === dcId))); +// ─── Rack Inventory Table ────────────────────────────────────── + +type SortField = 'sku' | 'flops' | 'power' | 'health'; +type SortDir = 'asc' | 'desc'; + +function SortableHeader({ field, label, align, sortField, sortDir, onToggle }: { + field: SortField; label: string; align?: 'right'; + sortField: SortField; sortDir: SortDir; onToggle: (f: SortField) => void; +}) { + return ( + onToggle(field)} + > + + {label} + {sortField === field ? ( + sortDir === 'asc' ? : + ) : ( + + )} + + + ); +} + +function RackTable({ racks, dcId }: { racks: Rack[]; dcId: string }) { + const decommissionRack = useGameStore((s) => s.decommissionRack); + const [selected, setSelected] = useState>(new Set()); + const [sortField, setSortField] = useState('sku'); + const [sortDir, setSortDir] = useState('asc'); + const [showDecomConfirm, setShowDecomConfirm] = useState(false); + + const sortedRacks = useMemo(() => { + const sorted = [...racks]; + sorted.sort((a, b) => { + const skuA = RACK_SKU_CONFIGS[a.skuId]; + const skuB = RACK_SKU_CONFIGS[b.skuId]; + let cmp = 0; + switch (sortField) { + case 'sku': cmp = skuA.name.localeCompare(skuB.name); break; + case 'flops': cmp = skuA.flopsPerRack - skuB.flopsPerRack; break; + case 'power': cmp = skuA.powerDrawKW - skuB.powerDrawKW; break; + case 'health': cmp = (a.isHealthy ? 1 : 0) - (b.isHealthy ? 1 : 0); break; + } + return sortDir === 'desc' ? -cmp : cmp; + }); + return sorted; + }, [racks, sortField, sortDir]); + + const toggleSort = (field: SortField) => { + if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortField(field); setSortDir('asc'); } + }; + + const toggleSelect = (id: string) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (selected.size === racks.length) setSelected(new Set()); + else setSelected(new Set(racks.map(r => r.id))); + }; + + const handleBulkDecom = () => { + for (const rackId of selected) { + decommissionRack(dcId, rackId); + } + setSelected(new Set()); + setShowDecomConfirm(false); + }; + + const decomImpact = useMemo(() => { + let flops = 0, power = 0; + for (const rackId of selected) { + const rack = racks.find(r => r.id === rackId); + if (rack) { + const sku = RACK_SKU_CONFIGS[rack.skuId]; + flops += sku.flopsPerRack; + power += sku.powerDrawKW; + } + } + return { flops, power }; + }, [selected, racks]); + + if (racks.length === 0) { + return ( +
+ + No racks installed yet. +
+ ); + } + + return ( +
+ + + + + + + + + + + + {sortedRacks.map(rack => { + const sku = RACK_SKU_CONFIGS[rack.skuId]; + const isSelected = selected.has(rack.id); + return ( + + + + + + + + ); + })} + +
+ 0} + onChange={toggleAll} + className="accent-accent" + /> +
+ toggleSelect(rack.id)} + className="accent-accent" + /> + {sku.name}{formatNumber(sku.flopsPerRack)}{sku.powerDrawKW}kW + {rack.isHealthy ? ( + + + Healthy + + ) : ( + + + Faulted + + )} +
+ + {selected.size > 0 && ( +
+ + {selected.size} rack{selected.size !== 1 ? 's' : ''} selected + + ({formatNumber(decomImpact.flops)} FLOPS · {decomImpact.power}kW) + + + +
+ )} + + {showDecomConfirm && ( + setShowDecomConfirm(false)} + /> + )} +
+ ); +} + +// ─── Launch Racks Panel ──────────────────────────────────────── + +function LaunchRacksPanel({ dcId, tierConfig, liveUsedSlots, liveUsedPower }: { + dcId: string; + tierConfig: DCTierConfig; + liveUsedSlots: number; + liveUsedPower: number; +}) { 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 decommissionRack = useGameStore((s) => s.decommissionRack); + + const [selectedSku, setSelectedSku] = useState(null); + const [quantity, setQuantity] = useState(1); + const currentEraIdx = ERA_ORDER.indexOf(era); + + const allSkus = useMemo(() => { + return Object.values(RACK_SKU_CONFIGS).map(sku => { + const eraLocked = ERA_ORDER.indexOf(sku.era) > currentEraIdx; + const researchLocked = !!(sku.requiredResearch && !completedResearch.includes(sku.requiredResearch)); + const available = !eraLocked && !researchLocked; + const lockReason = eraLocked + ? `Requires ${sku.era} era` + : researchLocked + ? `Requires "${sku.requiredResearch}"` + : null; + return { ...sku, available, lockReason }; + }); + }, [currentEraIdx, completedResearch]); + + const selected = selectedSku ? RACK_SKU_CONFIGS[selectedSku] : null; + + const { maxQuantity, constraintInfo } = useMemo(() => { + if (!selected) return { maxQuantity: 0, constraintInfo: null }; + const bySlots = tierConfig.rackSlots - liveUsedSlots; + const byPower = Math.floor((tierConfig.powerBudgetKW - liveUsedPower) / selected.powerDrawKW); + const byMoney = Math.floor(money / selected.baseCost); + const max = Math.max(0, Math.min(bySlots, byPower, byMoney)); + + let info: { text: string; color: string } | null = null; + if (max === 0) { + if (byMoney <= 0) info = { text: 'Insufficient funds', color: 'text-danger' }; + else if (bySlots <= 0) info = { text: 'No rack slots available', color: 'text-danger' }; + else info = { text: 'Exceeds power budget', color: 'text-danger' }; + } else { + const limiting = max === byMoney ? 'budget' : max === bySlots ? 'slots' : max === byPower ? 'power' : null; + if (limiting) info = { text: `Max ${max} (${limiting} limit)`, color: 'text-surface-400' }; + } + + return { maxQuantity: max, constraintInfo: info }; + }, [selected, tierConfig, liveUsedSlots, liveUsedPower, money]); + + const handleSelectSku = (skuId: RackSkuId) => { + setSelectedSku(skuId); + setQuantity(1); + }; + + const handleLaunch = () => { + if (!selectedSku || quantity <= 0) return; + for (let i = 0; i < quantity; i++) { + orderRack(dcId, selectedSku); + } + setQuantity(1); + setSelectedSku(null); + }; + + const totalCost = selected ? selected.baseCost * quantity : 0; + const newSlots = liveUsedSlots + quantity; + const newPower = selected ? liveUsedPower + selected.powerDrawKW * quantity : liveUsedPower; + + return ( +
+
+ + + + + + + + + + + + {allSkus.map(sku => { + const isSelected = selectedSku === sku.id; + return ( + sku.available && handleSelectSku(sku.id)} + > + + + + + + + + ); + })} + +
+ SKUGPUsFLOPSPowerCost
+ {sku.available ? ( + handleSelectSku(sku.id)} + className="accent-accent" + /> + ) : ( + + )} + +
{sku.name}
+ {sku.lockReason &&
{sku.lockReason}
} +
{sku.gpuCount}{formatNumber(sku.flopsPerRack)}{sku.powerDrawKW}kW{formatMoney(sku.baseCost)}
+ + {selectedSku && ( +
+ Quantity: +
+ + { + const v = parseInt(e.target.value) || 1; + setQuantity(Math.max(1, Math.min(maxQuantity, v))); + }} + className="w-16 text-center bg-surface-800 border-y border-surface-600 py-1 text-sm font-mono focus:outline-none" + min={1} + max={maxQuantity} + /> + +
+ {constraintInfo && ( + {constraintInfo.text} + )} +
+ )} +
+ +
+

Order Summary

+ + {selectedSku && selected ? ( + <> +
+
+ Instance + {selected.name} +
+
+ Quantity + {quantity} +
+
+ Total Cost + {formatMoney(totalCost)} +
+
+ +
+
+ Slots + + {liveUsedSlots}/{tierConfig.rackSlots} + → {newSlots}/{tierConfig.rackSlots} + +
+
+ Power + + {liveUsedPower.toFixed(1)}kW + → {newPower.toFixed(1)}kW + +
+
+ FLOPS added + +{formatNumber(selected.flopsPerRack * quantity)} +
+
+ + + + ) : ( +
+ Select a rack SKU to configure your order. +
+ )} +
+
+ ); +} + +// ─── Data Center Card ────────────────────────────────────────── + +type DCTab = 'inventory' | 'launch' | 'upgrades'; + +function DataCenterCard({ dcId }: { dcId: string }) { + const dc = useGameStore((s) => s.infrastructure.dataCenters.find(d => d.id === dcId))!; + const pipelineForDc = useGameStore(useShallow((s) => s.infrastructure.rackPipeline.filter(o => o.dataCenterId === dcId))); + const money = useGameStore((s) => s.economy.money); const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter); const [expanded, setExpanded] = useState(!collapsedDCs.has(dcId)); - const [confirmDecom, setConfirmDecom] = useState(null); + const [activeTab, setActiveTab] = useState(dc.racks.length === 0 ? 'launch' : 'inventory'); const toggleExpanded = useCallback(() => { setExpanded(prev => { @@ -128,17 +659,18 @@ function DataCenterCard({ dcId }: { dcId: string }) { }, [dcId]); const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const currentEraIdx = ERA_ORDER.indexOf(era); const activePipeline = pipelineForDc.filter(o => o.stage !== 'decommission'); const liveUsedSlots = dc.racks.length + activePipeline.length; const liveUsedPower = dc.racks.reduce((s, r) => s + RACK_SKU_CONFIGS[r.skuId].powerDrawKW, 0) + activePipeline.reduce((s, o) => s + RACK_SKU_CONFIGS[o.skuId].powerDrawKW, 0); - 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; - }); + const handleLaunchClick = useCallback(() => { + if (!expanded) { + collapsedDCs.delete(dcId); + setExpanded(true); + } + setActiveTab('launch'); + }, [expanded, dcId]); if (dc.status === 'constructing') { const pct = dc.constructionTotal > 0 ? dc.constructionProgress / dc.constructionTotal : 0; @@ -147,7 +679,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {

{dc.name}

-
Under Construction — {tierConfig.name}
+
Under Construction — {tierConfig.name}
@@ -158,12 +690,18 @@ function DataCenterCard({ dcId }: { dcId: string }) {
- {Math.round(pct * 100)}% — {dc.constructionTotal - dc.constructionProgress}s remaining + {Math.round(pct * 100)}% — {dc.constructionTotal - dc.constructionProgress}s remaining
); } + const tabs: { id: DCTab; label: string; count?: number }[] = [ + { id: 'inventory', label: 'Inventory', count: dc.racks.length }, + { id: 'launch', label: 'Launch Racks' }, + { id: 'upgrades', label: 'Upgrades' }, + ]; + return (
@@ -174,13 +712,24 @@ function DataCenterCard({ dcId }: { dcId: string }) {
{LOCATION_CONFIGS[dc.location].name} + {dc.racks.length} rack{dc.racks.length !== 1 ? 's' : ''} + {activePipeline.length > 0 && {activePipeline.length} in pipeline} Uptime: {formatPercent(dc.currentUptime)} Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s
- +
+ + +
@@ -190,111 +739,91 @@ function DataCenterCard({ dcId }: { dcId: string }) { {expanded && ( <> - {dc.racks.length > 0 && ( -
-

Production Racks ({dc.racks.length})

-
- {dc.racks.map(rack => { - const sku = RACK_SKU_CONFIGS[rack.skuId]; - return ( -
- {confirmDecom === rack.id ? ( -
- Remove? -
- - -
-
- ) : ( - <> - -
{sku.name}
-
{formatNumber(sku.flopsPerRack)} FLOPS
- - )} -
- ); - })} -
-
+
+ {tabs.map(tab => ( + + ))} +
+ + {activeTab === 'inventory' && ( + )} -
-

Order Racks

-
- {availableSkus.map(sku => { - const canAfford = money >= sku.baseCost; - const hasSlot = liveUsedSlots < tierConfig.rackSlots; - const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW; - const disabled = !canAfford || !hasSlot || !hasPower; - const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : ''; + {activeTab === 'launch' && ( + + )} - return ( - - ); - })} + {activeTab === 'upgrades' && ( +
+
+ + +
+

+ Cooling reduces hardware failure rates. Redundancy improves uptime during outages. +

-
- -
-

Upgrades

-
- - -
-
+ )} )}
); } +// ─── Build Data Center Panel ─────────────────────────────────── + function BuildDCPanel({ onClose }: { onClose: () => void }) { const money = useGameStore((s) => s.economy.money); const era = useGameStore((s) => s.meta.currentEra); @@ -311,13 +840,23 @@ function BuildDCPanel({ onClose }: { onClose: () => void }) { loc => ERA_ORDER.indexOf(loc.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 tierAvailability = useMemo(() => { + return Object.values(DC_TIER_CONFIGS).map(t => { + const eraLocked = ERA_ORDER.indexOf(t.requiredEra) > currentEraIdx; + const researchLocked = !!(t.requiredResearch && !completedResearch.includes(t.requiredResearch)); + const available = !eraLocked && !researchLocked; + const lockReason = eraLocked + ? `Requires ${t.requiredEra} era` + : researchLocked + ? `Requires "${t.requiredResearch}"` + : null; + return { config: t, available, lockReason }; + }); + }, [currentEraIdx, completedResearch]); const tierConfig = DC_TIER_CONFIGS[tier]; + const locationConfig = LOCATION_CONFIGS[location]; + const estimatedOpCost = tierConfig.baseEnergyCostPerTick * locationConfig.energyCostMultiplier; const handleBuild = () => { if (!name.trim()) return; @@ -359,29 +898,60 @@ function BuildDCPanel({ onClose }: { onClose: () => void }) { 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 => ( - + {tierAvailability.map(({ config: t, available, lockReason }) => ( + ))}
-
- Cost: {formatMoney(tierConfig.baseCost)} · Build time: {tierConfig.buildTimeTicks}s -
- - + +
+
+
Cost Breakdown
+
+ Build cost + {formatMoney(tierConfig.baseCost)} +
+
+ Build time + {tierConfig.buildTimeTicks}s +
+
+ Est. operating cost + ~{formatMoney(estimatedOpCost)}/s +
+
+
Capacity
+
+ Rack slots + {tierConfig.rackSlots} +
+
+ Power budget + {tierConfig.powerBudgetKW}kW +
+
+
+ +
+ +
); } +// ─── Page ────────────────────────────────────────────────────── + export function InfrastructurePage() { const dataCenters = useGameStore((s) => s.infrastructure.dataCenters); const [showNewDC, setShowNewDC] = useState(false); @@ -403,6 +973,8 @@ export function InfrastructurePage() { Choose a location and tier for your data center, then order GPU racks to add compute capacity. Racks go through a build pipeline before they come online. + + {showNewDC && setShowNewDC(false)} />}