Redesign Infrastructure page with AWS-style provisioning UX
CI / build-and-push (push) Successful in 37s
CI / build-and-push (push) Successful in 37s
Replace basic admin panel with cloud console-style interactions: - Fleet summary bar with aggregate stats (DCs, racks, FLOPS, uptime, cost) - Launch Racks provisioning panel with SKU table, quantity stepper, live cost sidebar, and review-before-launch flow - Rack inventory table with sortable columns, checkbox multi-select, and bulk decommission with confirmation modal - Pipeline kanban grouping (same SKU/stage collapsed) with ETA display - Tabbed DC cards (Inventory | Launch | Upgrades) to reduce scroll - Build DC panel with cost breakdown and capacity preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>();
|
||||
@@ -32,29 +38,130 @@ const STAGE_COLORS: Record<PipelineStage, string> = {
|
||||
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 (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon size={14} className="text-surface-400" />
|
||||
<span className="text-[11px] text-surface-400 uppercase">{label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold font-mono">{value}</div>
|
||||
{sub && <div className="text-[11px] text-surface-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<FleetStat
|
||||
label="Data Centers"
|
||||
value={`${stats.dcCount}`}
|
||||
sub={stats.constructing > 0 ? `${stats.constructing} building` : 'operational'}
|
||||
icon={Server}
|
||||
/>
|
||||
<FleetStat label="Total Racks" value={`${stats.totalRacks}`} sub={`of ${stats.totalSlots} slots`} icon={HardDrive} />
|
||||
<FleetStat
|
||||
label="Total FLOPS"
|
||||
value={formatNumber(stats.totalFlops)}
|
||||
sub={stats.inPipeline > 0 ? `${stats.inPipeline} in pipeline` : undefined}
|
||||
icon={Cpu}
|
||||
/>
|
||||
<FleetStat label="Avg Uptime" value={formatPercent(stats.avgUptime)} icon={Activity} />
|
||||
<FleetStat label="Infra Cost" value={`${formatMoney(stats.totalCost)}/s`} icon={DollarSign} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<RackSkuId, RackOrder[]>();
|
||||
|
||||
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 (
|
||||
<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="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-surface-300 uppercase">Rack Pipeline</h3>
|
||||
<div className="flex items-center gap-3 text-[11px] text-surface-400">
|
||||
<span>{activeOrders.length} order{activeOrders.length !== 1 ? 's' : ''} in pipeline</span>
|
||||
{nearestToOnline !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
Next online in ~{nearestToOnline}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{grouped.map(({ stage, orders }) => (
|
||||
{grouped.map(({ stage, groups, count }) => (
|
||||
<div key={stage}>
|
||||
<div className="text-[10px] text-surface-400 uppercase mb-1.5 text-center">
|
||||
{STAGE_LABELS[stage]} ({orders.length})
|
||||
{STAGE_LABELS[stage]} ({count})
|
||||
</div>
|
||||
<div className="space-y-1.5 min-h-[60px]">
|
||||
{orders.map(order => (
|
||||
<PipelineCard key={order.id} order={order} />
|
||||
{groups.map(group => (
|
||||
<PipelineGroupCard key={group.key} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<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="font-medium truncate">
|
||||
{sku.name}
|
||||
{group.orders.length > 1 && <span className="text-accent ml-1">×{group.orders.length}</span>}
|
||||
</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)}%` }}
|
||||
className={`h-1 rounded-full ${STAGE_COLORS[group.stage]}`}
|
||||
style={{ width: `${Math.min(100, group.avgProgress * 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 className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-surface-500 flex items-center gap-0.5">
|
||||
<Clock size={8} />{remaining}s
|
||||
</span>
|
||||
{hasRepairs && (
|
||||
<span className="text-danger flex items-center gap-0.5">
|
||||
<Wrench size={8} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<th
|
||||
className={`text-[11px] text-surface-400 uppercase py-2 px-2 cursor-pointer hover:text-surface-200 select-none ${
|
||||
align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
onClick={() => onToggle(field)}
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1 ${align === 'right' ? 'justify-end' : ''}`}>
|
||||
{label}
|
||||
{sortField === field ? (
|
||||
sortDir === 'asc' ? <ChevronUp size={10} /> : <ChevronDown size={10} />
|
||||
) : (
|
||||
<ArrowUpDown size={10} className="opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function RackTable({ racks, dcId }: { racks: Rack[]; dcId: string }) {
|
||||
const decommissionRack = useGameStore((s) => s.decommissionRack);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sortField, setSortField] = useState<SortField>('sku');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 (
|
||||
<div className="text-center py-8 text-surface-500 text-sm">
|
||||
<Server size={32} className="mx-auto mb-2 opacity-30" />
|
||||
No racks installed yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700">
|
||||
<th className="w-8 py-2 px-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === racks.length && racks.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="accent-accent"
|
||||
/>
|
||||
</th>
|
||||
<SortableHeader field="sku" label="SKU" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
|
||||
<SortableHeader field="flops" label="FLOPS" align="right" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
|
||||
<SortableHeader field="power" label="Power" align="right" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
|
||||
<SortableHeader field="health" label="Health" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRacks.map(rack => {
|
||||
const sku = RACK_SKU_CONFIGS[rack.skuId];
|
||||
const isSelected = selected.has(rack.id);
|
||||
return (
|
||||
<tr
|
||||
key={rack.id}
|
||||
className={`border-b border-surface-800 hover:bg-surface-800/50 transition-colors ${
|
||||
isSelected ? 'bg-accent/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-1.5 px-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(rack.id)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 font-medium">{sku.name}</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-surface-300">{formatNumber(sku.flopsPerRack)}</td>
|
||||
<td className="py-1.5 px-2 text-right font-mono text-surface-300">{sku.powerDrawKW}kW</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{rack.isHealthy ? (
|
||||
<span className="flex items-center gap-1 text-success text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" />
|
||||
Healthy
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-warning text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-warning inline-block" />
|
||||
Faulted
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center justify-between mt-3 px-3 py-2 bg-danger/5 border border-danger/20 rounded-lg">
|
||||
<span className="text-sm text-surface-300">
|
||||
{selected.size} rack{selected.size !== 1 ? 's' : ''} selected
|
||||
<span className="text-surface-500 ml-2">
|
||||
({formatNumber(decomImpact.flops)} FLOPS · {decomImpact.power}kW)
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowDecomConfirm(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm font-medium transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Decommission Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDecomConfirm && (
|
||||
<ConfirmModal
|
||||
title={`Decommission ${selected.size} Rack${selected.size !== 1 ? 's' : ''}?`}
|
||||
message={`This will remove ${formatNumber(decomImpact.flops)} FLOPS and free ${decomImpact.power}kW of power. Racks will enter the decommission pipeline.`}
|
||||
confirmLabel="Decommission"
|
||||
danger
|
||||
onConfirm={handleBulkDecom}
|
||||
onCancel={() => setShowDecomConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<RackSkuId | null>(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 (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700">
|
||||
<th className="w-8 py-2 px-2" />
|
||||
<th className="text-left text-[11px] text-surface-400 uppercase py-2 px-2">SKU</th>
|
||||
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">GPUs</th>
|
||||
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">FLOPS</th>
|
||||
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">Power</th>
|
||||
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allSkus.map(sku => {
|
||||
const isSelected = selectedSku === sku.id;
|
||||
return (
|
||||
<tr
|
||||
key={sku.id}
|
||||
className={`border-b border-surface-800 transition-colors ${
|
||||
sku.available
|
||||
? isSelected ? 'bg-accent/10' : 'hover:bg-surface-800/50 cursor-pointer'
|
||||
: 'opacity-40'
|
||||
}`}
|
||||
onClick={() => sku.available && handleSelectSku(sku.id)}
|
||||
>
|
||||
<td className="py-2 px-2">
|
||||
{sku.available ? (
|
||||
<input
|
||||
type="radio"
|
||||
name={`sku-${dcId}`}
|
||||
checked={isSelected}
|
||||
onChange={() => handleSelectSku(sku.id)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
) : (
|
||||
<Lock size={12} className="text-surface-500" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="font-medium">{sku.name}</div>
|
||||
{sku.lockReason && <div className="text-[10px] text-surface-500">{sku.lockReason}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-surface-300">{sku.gpuCount}</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-surface-300">{formatNumber(sku.flopsPerRack)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-surface-300">{sku.powerDrawKW}kW</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-surface-300">{formatMoney(sku.baseCost)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{selectedSku && (
|
||||
<div className="flex items-center gap-4 mt-3 px-2">
|
||||
<span className="text-sm text-surface-400">Quantity:</span>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setQuantity(q => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1}
|
||||
className="px-2 py-1 bg-surface-800 border border-surface-600 rounded-l hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(q => Math.min(maxQuantity, q + 1))}
|
||||
disabled={quantity >= maxQuantity}
|
||||
className="px-2 py-1 bg-surface-800 border border-surface-600 rounded-r hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{constraintInfo && (
|
||||
<span className={`text-xs ${constraintInfo.color}`}>{constraintInfo.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-800 border border-surface-600 rounded-lg p-4 space-y-3 h-fit">
|
||||
<h4 className="text-xs text-surface-400 uppercase font-semibold">Order Summary</h4>
|
||||
|
||||
{selectedSku && selected ? (
|
||||
<>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Instance</span>
|
||||
<span className="font-medium">{selected.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Quantity</span>
|
||||
<span className="font-mono">{quantity}</span>
|
||||
</div>
|
||||
<div className="border-t border-surface-700 pt-2 flex justify-between">
|
||||
<span className="text-surface-400">Total Cost</span>
|
||||
<span className="font-mono font-bold text-accent-light">{formatMoney(totalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between text-surface-400">
|
||||
<span>Slots</span>
|
||||
<span>
|
||||
{liveUsedSlots}/{tierConfig.rackSlots}
|
||||
<span className="text-accent ml-1">→ {newSlots}/{tierConfig.rackSlots}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-surface-400">
|
||||
<span>Power</span>
|
||||
<span>
|
||||
{liveUsedPower.toFixed(1)}kW
|
||||
<span className="text-accent ml-1">→ {newPower.toFixed(1)}kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-surface-400">
|
||||
<span>FLOPS added</span>
|
||||
<span className="text-accent">+{formatNumber(selected.flopsPerRack * quantity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLaunch}
|
||||
disabled={maxQuantity === 0 || quantity <= 0}
|
||||
className="w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Rocket size={16} />
|
||||
Launch {quantity} Rack{quantity !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-6 text-surface-500 text-sm">
|
||||
Select a rack SKU to configure your order.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<DCTab>(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 }) {
|
||||
<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 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} />
|
||||
@@ -158,12 +690,18 @@ function DataCenterCard({ dcId }: { dcId: string }) {
|
||||
<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
|
||||
{Math.round(pct * 100)}% — {dc.constructionTotal - dc.constructionProgress}s remaining
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -174,14 +712,25 @@ function DataCenterCard({ dcId }: { dcId: string }) {
|
||||
</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>{dc.racks.length} rack{dc.racks.length !== 1 ? 's' : ''}</span>
|
||||
{activePipeline.length > 0 && <span className="text-blue-400">{activePipeline.length} in pipeline</span>}
|
||||
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
|
||||
<span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleLaunchClick}
|
||||
className="flex items-center gap-1.5 bg-accent/10 hover:bg-accent/20 text-accent-light px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
<Rocket size={14} />
|
||||
Launch
|
||||
</button>
|
||||
<button onClick={toggleExpanded} className="text-surface-400 hover:text-surface-200">
|
||||
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-3">
|
||||
<CapacityBar label="Slots" used={liveUsedSlots} max={tierConfig.rackSlots} unit="" icon={HardDrive} />
|
||||
@@ -190,111 +739,91 @@ function DataCenterCard({ dcId }: { dcId: string }) {
|
||||
|
||||
{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'
|
||||
}`}>
|
||||
{confirmDecom === rack.id ? (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-danger text-[10px]">Remove?</span>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="flex gap-1 border-b border-surface-700 mb-3">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
onClick={() => { decommissionRack(dc.id, rack.id); setConfirmDecom(null); }}
|
||||
className="px-1 py-0.5 rounded bg-danger/20 text-danger hover:bg-danger/30 text-[10px]"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-accent text-accent-light'
|
||||
: 'border-transparent text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDecom(null)}
|
||||
className="px-1 py-0.5 rounded hover:bg-surface-700 text-surface-400 text-[10px]"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmDecom(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>
|
||||
</>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="ml-1.5 text-xs text-surface-500">({tab.count})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'inventory' && (
|
||||
<RackTable racks={dc.racks} dcId={dc.id} />
|
||||
)}
|
||||
|
||||
<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;
|
||||
const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : '';
|
||||
{activeTab === 'launch' && (
|
||||
<LaunchRacksPanel
|
||||
dcId={dc.id}
|
||||
tierConfig={tierConfig}
|
||||
liveUsedSlots={liveUsedSlots}
|
||||
liveUsedPower={liveUsedPower}
|
||||
/>
|
||||
)}
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="font-medium">{sku.name}</div>
|
||||
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div>
|
||||
{disabled && reason && <div className="text-warning mt-0.5">{reason}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Upgrades</h4>
|
||||
<div className="flex gap-2">
|
||||
{activeTab === 'upgrades' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<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"
|
||||
className="flex-1 flex items-center gap-2 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-4 py-3 text-sm 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>
|
||||
<Thermometer size={16} className="text-cyan-400" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Cooling Level {Math.round(dc.coolingLevel * 10)}/10</div>
|
||||
<div className="text-xs text-surface-400">
|
||||
{dc.coolingLevel >= 1.0 ? 'Maxed out' : `Upgrade for ${formatMoney(tierConfig.baseCost * 0.25)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="w-20 bg-surface-700 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full bg-cyan-400 transition-all" style={{ width: `${dc.coolingLevel * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
className="flex-1 flex items-center gap-2 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-4 py-3 text-sm 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>
|
||||
<Shield size={16} className="text-green-400" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Redundancy Level {Math.round(dc.redundancyLevel * 10)}/10</div>
|
||||
<div className="text-xs text-surface-400">
|
||||
{dc.redundancyLevel >= 1.0 ? 'Maxed out' : `Upgrade for ${formatMoney(tierConfig.baseCost * 0.25)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="w-20 bg-surface-700 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full bg-green-400 transition-all" style={{ width: `${dc.redundancyLevel * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-surface-500">
|
||||
Cooling reduces hardware failure rates. Redundancy improves uptime during outages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 => (
|
||||
<option key={t.tier} value={t.tier}>{t.name} ({t.rackSlots} slots, {t.powerBudgetKW}kW)</option>
|
||||
{tierAvailability.map(({ config: t, available, lockReason }) => (
|
||||
<option key={t.tier} value={t.tier} disabled={!available}>
|
||||
{t.name} ({t.rackSlots} slots, {t.powerBudgetKW}kW){lockReason ? ` — ${lockReason}` : ''}
|
||||
</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">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-surface-800 rounded-lg p-3 space-y-1.5">
|
||||
<div className="text-xs text-surface-400 uppercase font-semibold">Cost Breakdown</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Build cost</span>
|
||||
<span className="font-mono">{formatMoney(tierConfig.baseCost)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Build time</span>
|
||||
<span className="font-mono">{tierConfig.buildTimeTicks}s</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-surface-700 pt-1.5">
|
||||
<span className="text-surface-400">Est. operating cost</span>
|
||||
<span className="font-mono">~{formatMoney(estimatedOpCost)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-800 rounded-lg p-3 space-y-1.5">
|
||||
<div className="text-xs text-surface-400 uppercase font-semibold">Capacity</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Rack slots</span>
|
||||
<span className="font-mono">{tierConfig.rackSlots}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-surface-400">Power budget</span>
|
||||
<span className="font-mono">{tierConfig.powerBudgetKW}kW</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end 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
|
||||
Build ({formatMoney(tierConfig.baseCost)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
</TutorialHint>
|
||||
|
||||
<FleetSummary />
|
||||
|
||||
{showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
|
||||
|
||||
<PipelineKanban />
|
||||
|
||||
Reference in New Issue
Block a user