Files
AIHostingTycoon/apps/web/src/pages/InfrastructurePage.tsx
T
josh 24278297f0
CI / build-and-push (push) Successful in 39s
Add rack decommission pipeline stage
Racks can now be marked for decommission from the DC view. The rack
leaves production immediately (freeing slot and power), enters the
pipeline as a timed decommission order, and is removed when complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 20:15:48 -04:00

385 lines
16 KiB
TypeScript

import { useState } from 'react';
import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react';
import { useGameStore } from '@/store';
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';
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',
decommission: 'Decom',
};
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',
decommission: 'bg-surface-500',
};
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),
}));
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-7 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 pipelineForDc = useGameStore((s) => s.infrastructure.rackPipeline.filter(o => o.dataCenterId === 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 decommissionRack = useGameStore((s) => s.decommissionRack);
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 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;
});
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={liveUsedSlots} max={tierConfig.rackSlots} unit="" icon={HardDrive} />
<CapacityBar label="Power" used={liveUsedPower} 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 relative group ${
rack.isHealthy
? 'bg-surface-800 border-surface-600'
: 'bg-danger/10 border-danger/30'
}`}>
<button
onClick={() => decommissionRack(dc.id, rack.id)}
className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all"
title="Decommission rack"
>
<X size={10} />
</button>
<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 = liveUsedSlots < tierConfig.rackSlots;
const hasPower = liveUsedPower + 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 [name, setName] = useState('');
const [location, setLocation] = useState<LocationId>('us-west');
const [tier, setTier] = useState<DCTier>('small');
const currentEraIdx = ERA_ORDER.indexOf(era);
const availableLocations = Object.values(LOCATION_CONFIGS).filter(
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 tierConfig = DC_TIER_CONFIGS[tier];
const handleBuild = () => {
if (!name.trim()) return;
buildDataCenter(name.trim(), location, tier);
onClose();
};
return (
<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
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 && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
<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>
</div>
) : (
<div className="space-y-4">
{dataCenters.map(dc => (
<DataCenterCard key={dc.id} dcId={dc.id} />
))}
</div>
)}
</div>
);
}