24278297f0
CI / build-and-push (push) Successful in 39s
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>
385 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|