Add bulk fill and staggered retrofit at campus/cluster level
CI / build-and-push (push) Successful in 40s
CI / build-and-push (push) Successful in 40s
Campus level: "Fill All DCs" instantly fills all operational DCs with selected SKU in one click. "Retrofit Campus" queues a staggered retrofit with configurable concurrency (1/10%/25%/custom) so only a fraction of DCs go offline at a time, preserving capacity during the upgrade. Cluster level: "Fill All DCs" fills across all campuses in one action. The game engine automatically advances the retrofit queue each tick, promoting pending DCs as active ones complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,11 @@ import {
|
|||||||
Rocket, Clock, Lock, Cpu,
|
Rocket, Clock, Lock, Cpu,
|
||||||
Activity, DollarSign, Globe, Building2, Layers,
|
Activity, DollarSign, Globe, Building2, Layers,
|
||||||
Network, ArrowLeft, RefreshCw, ChevronDown,
|
Network, ArrowLeft, RefreshCw, ChevronDown,
|
||||||
|
X, CheckCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||||
import { useGameStore, type InfraNav } from '@/store';
|
import { useGameStore, type InfraNav, computeFillForDC } from '@/store';
|
||||||
import {
|
import {
|
||||||
formatMoney, formatNumber, formatPercent,
|
formatMoney, formatNumber, formatPercent,
|
||||||
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
|
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
|
||||||
@@ -337,17 +338,132 @@ function BuildClusterModal({ onClose }: { onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Cluster Fill All Modal ─────────────────────────────────────
|
||||||
|
|
||||||
|
function ClusterFillAllModal({ cluster, money, era, research, onConfirm, onClose }: {
|
||||||
|
cluster: Cluster;
|
||||||
|
money: number;
|
||||||
|
era: Era;
|
||||||
|
research: string[];
|
||||||
|
onConfirm: (skuId: RackSkuId) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
||||||
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
||||||
|
if (s.requiredResearch && !research.includes(s.requiredResearch)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDCs = cluster.campuses.flatMap(c => c.dataCenters);
|
||||||
|
const mostCommonSku = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const dc of allDCs) {
|
||||||
|
if (dc.rackSkuId) counts[dc.rackSkuId] = (counts[dc.rackSkuId] || 0) + 1;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(counts);
|
||||||
|
return entries.length > 0 ? entries.sort((a, b) => b[1] - a[1])[0][0] as RackSkuId : availableSkus[0]?.id ?? null;
|
||||||
|
}, [allDCs, availableSkus]);
|
||||||
|
|
||||||
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(mostCommonSku);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!selectedSku) return { campuses: [], totalQty: 0, totalCost: 0 };
|
||||||
|
let remaining = money;
|
||||||
|
const campuses = cluster.campuses.map(campus => {
|
||||||
|
if (campus.status !== 'operational') return { campus, dcs: [], totalQty: 0, totalCost: 0 };
|
||||||
|
const dcs = campus.dataCenters.map(dc => {
|
||||||
|
const { qty, cost } = computeFillForDC(dc, selectedSku, remaining);
|
||||||
|
if (qty > 0) remaining -= cost;
|
||||||
|
return { dc, qty, cost };
|
||||||
|
});
|
||||||
|
return { campus, dcs, totalQty: dcs.reduce((s, d) => s + d.qty, 0), totalCost: dcs.reduce((s, d) => s + d.cost, 0) };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
campuses,
|
||||||
|
totalQty: campuses.reduce((s, c) => s + c.totalQty, 0),
|
||||||
|
totalCost: campuses.reduce((s, c) => s + c.totalCost, 0),
|
||||||
|
};
|
||||||
|
}, [selectedSku, money, cluster.campuses]);
|
||||||
|
|
||||||
|
const fillableDCCount = preview.campuses.reduce((s, c) => s + c.dcs.filter(d => d.qty > 0).length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Rocket size={18} className="text-accent" /> Fill All DCs — {cluster.name}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Rack SKU</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{availableSkus.map(s => (
|
||||||
|
<label key={s.id} className={`flex items-center gap-3 p-2.5 rounded-lg cursor-pointer border text-sm ${selectedSku === s.id ? 'border-accent bg-accent/10' : 'border-surface-600 hover:border-surface-500'}`}>
|
||||||
|
<input type="radio" name="clusterFillSku" checked={selectedSku === s.id} onChange={() => setSelectedSku(s.id)} className="accent-accent" />
|
||||||
|
<span className="font-medium">{s.name}</span>
|
||||||
|
<span className="text-xs text-surface-400 ml-auto">{formatMoney(s.baseCost)}/rack</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSku && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Preview by campus</label>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{preview.campuses.map(({ campus, totalQty, totalCost }) => (
|
||||||
|
<div key={campus.id} className="flex items-center justify-between text-xs px-2 py-1.5 rounded bg-surface-800">
|
||||||
|
<span className="truncate flex-1 mr-2">{campus.name} ({campus.dataCenters.length} DCs)</span>
|
||||||
|
{totalQty > 0 ? (
|
||||||
|
<span className="text-green-400 font-mono shrink-0">+{totalQty} racks ({formatMoney(totalCost)})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-surface-500 shrink-0">No change</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Total racks:</span><span className="font-mono">{formatNumber(preview.totalQty)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Total cost:</span><span className="font-mono">{formatMoney(preview.totalCost)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Remaining budget:</span><span className="font-mono">{formatMoney(money - preview.totalCost)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedSku && onConfirm(selectedSku)}
|
||||||
|
disabled={!selectedSku || preview.totalQty === 0}
|
||||||
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
||||||
|
Fill {fillableDCCount} DCs ({formatNumber(preview.totalQty)} racks, {formatMoney(preview.totalCost)})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Cluster Detail View ────────────────────────────────────────
|
// ─── Cluster Detail View ────────────────────────────────────────
|
||||||
|
|
||||||
function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
||||||
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
||||||
const setNav = useGameStore((s) => s.setInfraNav);
|
const setNav = useGameStore((s) => s.setInfraNav);
|
||||||
|
const fillCluster = useGameStore((s) => s.fillClusterToCapacity);
|
||||||
|
const money = useGameStore((s) => s.economy.money);
|
||||||
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
|
const research = useGameStore((s) => s.research.completedResearch);
|
||||||
const [showBuild, setShowBuild] = useState(false);
|
const [showBuild, setShowBuild] = useState(false);
|
||||||
|
const [showFillAll, setShowFillAll] = useState(false);
|
||||||
|
|
||||||
if (!cluster) return <div className="text-surface-400">Cluster not found.</div>;
|
if (!cluster) return <div className="text-surface-400">Cluster not found.</div>;
|
||||||
|
|
||||||
const location = LOCATION_CONFIGS[cluster.locationId];
|
const location = LOCATION_CONFIGS[cluster.locationId];
|
||||||
|
|
||||||
|
const allDCs = cluster.campuses.flatMap(c => c.dataCenters);
|
||||||
|
const hasOperationalDCs = allDCs.some(dc => dc.status === 'operational');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -358,10 +474,18 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
|||||||
<p className="text-sm text-surface-400">{location.name} — {location.energyCostMultiplier}x energy cost</p>
|
<p className="text-sm text-surface-400">{location.name} — {location.energyCostMultiplier}x energy cost</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowBuild(true)}
|
<div className="flex gap-2">
|
||||||
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
{hasOperationalDCs && (
|
||||||
<Plus size={16} /> New Campus
|
<button onClick={() => setShowFillAll(true)}
|
||||||
</button>
|
className="flex items-center gap-1.5 bg-surface-700 hover:bg-surface-600 text-white rounded-lg px-3 py-2 text-sm">
|
||||||
|
<Rocket size={14} /> Fill All DCs
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowBuild(true)}
|
||||||
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
|
<Plus size={16} /> New Campus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cluster.campuses.length === 0 && (
|
{cluster.campuses.length === 0 && (
|
||||||
@@ -383,6 +507,9 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
|||||||
{campus.status === 'constructing' && (
|
{campus.status === 'constructing' && (
|
||||||
<span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building...</span>
|
<span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building...</span>
|
||||||
)}
|
)}
|
||||||
|
{campus.retrofitQueue && (
|
||||||
|
<span className="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded">Retrofitting</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight size={16} className="text-surface-400" />
|
<ChevronRight size={16} className="text-surface-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -416,6 +543,17 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBuild && <BuildCampusModal clusterId={clusterId} onClose={() => setShowBuild(false)} />}
|
{showBuild && <BuildCampusModal clusterId={clusterId} onClose={() => setShowBuild(false)} />}
|
||||||
|
|
||||||
|
{showFillAll && (
|
||||||
|
<ClusterFillAllModal
|
||||||
|
cluster={cluster}
|
||||||
|
money={money}
|
||||||
|
era={era}
|
||||||
|
research={research}
|
||||||
|
onConfirm={(skuId) => { fillCluster(clusterId, skuId); setShowFillAll(false); }}
|
||||||
|
onClose={() => setShowFillAll(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,7 +625,283 @@ function BuildCampusModal({ clusterId, onClose }: { clusterId: string; onClose:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Campus Detail View ─────────────────────────────────────────
|
// ─── Campus Bulk Action Components ─────────────────────────────
|
||||||
|
|
||||||
|
function FillAllDCsModal({ campus, money, era, research, onConfirm, onClose }: {
|
||||||
|
campus: Campus;
|
||||||
|
money: number;
|
||||||
|
era: Era;
|
||||||
|
research: string[];
|
||||||
|
onConfirm: (skuId: RackSkuId) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
||||||
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
||||||
|
if (s.requiredResearch && !research.includes(s.requiredResearch)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mostCommonSku = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const dc of campus.dataCenters) {
|
||||||
|
if (dc.rackSkuId) counts[dc.rackSkuId] = (counts[dc.rackSkuId] || 0) + 1;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(counts);
|
||||||
|
return entries.length > 0 ? entries.sort((a, b) => b[1] - a[1])[0][0] as RackSkuId : availableSkus[0]?.id ?? null;
|
||||||
|
}, [campus.dataCenters, availableSkus]);
|
||||||
|
|
||||||
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(mostCommonSku);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!selectedSku) return { dcs: [], totalQty: 0, totalCost: 0 };
|
||||||
|
let remaining = money;
|
||||||
|
const dcs = campus.dataCenters.map(dc => {
|
||||||
|
if (dc.status !== 'operational') return { dc, qty: 0, cost: 0, reason: dc.status === 'constructing' ? 'Constructing' : 'Retrofitting' as string | null };
|
||||||
|
if (dc.status === 'operational' && dc.rackSkuId && dc.rackSkuId !== selectedSku) return { dc, qty: 0, cost: 0, reason: `Different SKU (${RACK_SKU_CONFIGS[dc.rackSkuId].name})` };
|
||||||
|
const { qty, cost } = computeFillForDC(dc, selectedSku, remaining);
|
||||||
|
if (qty === 0) {
|
||||||
|
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
||||||
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots);
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
const isFull = maxCompute - (dc.computeRacksOnline + pipelineCount) <= 0;
|
||||||
|
return { dc, qty: 0, cost: 0, reason: isFull ? 'Already full' : 'No budget' };
|
||||||
|
}
|
||||||
|
remaining -= cost;
|
||||||
|
return { dc, qty, cost, reason: null };
|
||||||
|
});
|
||||||
|
return { dcs, totalQty: dcs.reduce((s, d) => s + d.qty, 0), totalCost: dcs.reduce((s, d) => s + d.cost, 0) };
|
||||||
|
}, [selectedSku, money, campus.dataCenters]);
|
||||||
|
|
||||||
|
const fillableDCCount = preview.dcs.filter(d => d.qty > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Rocket size={18} className="text-accent" /> Fill All DCs</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Rack SKU</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{availableSkus.map(s => (
|
||||||
|
<label key={s.id} className={`flex items-center gap-3 p-2.5 rounded-lg cursor-pointer border text-sm ${selectedSku === s.id ? 'border-accent bg-accent/10' : 'border-surface-600 hover:border-surface-500'}`}>
|
||||||
|
<input type="radio" name="fillSku" checked={selectedSku === s.id} onChange={() => setSelectedSku(s.id)} className="accent-accent" />
|
||||||
|
<span className="font-medium">{s.name}</span>
|
||||||
|
<span className="text-xs text-surface-400 ml-auto">{formatMoney(s.baseCost)}/rack</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSku && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Preview ({fillableDCCount} of {campus.dataCenters.length} DCs)</label>
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{preview.dcs.map(({ dc, qty, cost, reason }) => (
|
||||||
|
<div key={dc.id} className="flex items-center justify-between text-xs px-2 py-1.5 rounded bg-surface-800">
|
||||||
|
<span className="truncate flex-1 mr-2">{dc.name}</span>
|
||||||
|
{reason ? (
|
||||||
|
<span className="text-surface-500 shrink-0">{reason}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-400 font-mono shrink-0">+{qty} racks ({formatMoney(cost)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Total racks:</span><span className="font-mono">{formatNumber(preview.totalQty)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Total cost:</span><span className="font-mono">{formatMoney(preview.totalCost)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Remaining budget:</span><span className="font-mono">{formatMoney(money - preview.totalCost)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedSku && onConfirm(selectedSku)}
|
||||||
|
disabled={!selectedSku || preview.totalQty === 0}
|
||||||
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
||||||
|
Fill {fillableDCCount} DCs ({formatNumber(preview.totalQty)} racks, {formatMoney(preview.totalCost)})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetrofitCampusModal({ campus, era, research, onConfirm, onClose }: {
|
||||||
|
campus: Campus;
|
||||||
|
era: Era;
|
||||||
|
research: string[];
|
||||||
|
onConfirm: (skuId: RackSkuId, maxConcurrent: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(null);
|
||||||
|
const [concurrencyMode, setConcurrencyMode] = useState<'1' | '10' | '25' | 'custom'>('10');
|
||||||
|
const [customCount, setCustomCount] = useState(1);
|
||||||
|
|
||||||
|
const currentSkuIds = [...new Set(campus.dataCenters.filter(dc => dc.rackSkuId).map(dc => dc.rackSkuId!))];
|
||||||
|
|
||||||
|
const targetSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
||||||
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
||||||
|
if (s.requiredResearch && !research.includes(s.requiredResearch)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligible = useMemo(() => {
|
||||||
|
if (!selectedSku) return { count: 0, skipped: 0 };
|
||||||
|
let count = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const dc of campus.dataCenters) {
|
||||||
|
if (dc.status !== 'operational' || !dc.rackSkuId || dc.rackSkuId === selectedSku) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
if (dc.computeRacksOnline + pipelineCount <= 0) { skipped++; continue; }
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count, skipped };
|
||||||
|
}, [selectedSku, campus.dataCenters]);
|
||||||
|
|
||||||
|
const maxConcurrent = useMemo(() => {
|
||||||
|
if (eligible.count === 0) return 1;
|
||||||
|
switch (concurrencyMode) {
|
||||||
|
case '1': return 1;
|
||||||
|
case '10': return Math.max(1, Math.ceil(eligible.count * 0.1));
|
||||||
|
case '25': return Math.max(1, Math.ceil(eligible.count * 0.25));
|
||||||
|
case 'custom': return Math.max(1, Math.min(customCount, eligible.count));
|
||||||
|
}
|
||||||
|
}, [concurrencyMode, customCount, eligible.count]);
|
||||||
|
|
||||||
|
const capacityMaintained = eligible.count > 0 ? Math.round(((eligible.count - maxConcurrent) / eligible.count) * 100) : 100;
|
||||||
|
|
||||||
|
const estimatedBatches = eligible.count > 0 ? Math.ceil(eligible.count / maxConcurrent) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><RefreshCw size={18} className="text-violet-400" /> Retrofit Campus</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Target SKU</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{targetSkus.map(s => {
|
||||||
|
const isCurrentOnly = currentSkuIds.length === 1 && currentSkuIds[0] === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id}
|
||||||
|
onClick={() => !isCurrentOnly && setSelectedSku(s.id)}
|
||||||
|
disabled={isCurrentOnly}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left text-sm transition-colors ${
|
||||||
|
selectedSku === s.id ? 'border-violet-500 bg-violet-500/10' :
|
||||||
|
isCurrentOnly ? 'border-surface-700 opacity-40 cursor-not-allowed' :
|
||||||
|
'border-surface-600 hover:border-surface-500 cursor-pointer'
|
||||||
|
}`}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{s.name}</div>
|
||||||
|
<div className="text-xs text-surface-400">{s.flopsPerRack} FLOPS | {s.powerDrawKW} kW | {formatMoney(s.baseCost)}/rack</div>
|
||||||
|
</div>
|
||||||
|
{isCurrentOnly && <span className="text-xs text-surface-500">Current</span>}
|
||||||
|
{selectedSku === s.id && <CheckCircle size={16} className="text-violet-400" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSku && eligible.count > 0 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Concurrency</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{([
|
||||||
|
{ key: '1' as const, label: '1 at a time' },
|
||||||
|
{ key: '10' as const, label: `10% = ${Math.max(1, Math.ceil(eligible.count * 0.1))}` },
|
||||||
|
{ key: '25' as const, label: `25% = ${Math.max(1, Math.ceil(eligible.count * 0.25))}` },
|
||||||
|
{ key: 'custom' as const, label: 'Custom' },
|
||||||
|
]).map(opt => (
|
||||||
|
<button key={opt.key} onClick={() => setConcurrencyMode(opt.key)}
|
||||||
|
className={`flex-1 py-2 text-xs rounded-lg border transition-colors ${
|
||||||
|
concurrencyMode === opt.key ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-surface-600 text-surface-400 hover:border-surface-500'
|
||||||
|
}`}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{concurrencyMode === 'custom' && (
|
||||||
|
<input type="number" min={1} max={eligible.count} value={customCount}
|
||||||
|
onChange={e => setCustomCount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="mt-2 w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1.5">
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">DCs to retrofit:</span><span>{eligible.count} of {campus.dataCenters.length}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Concurrent retrofits:</span><span>{maxConcurrent}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-surface-400">Estimated batches:</span><span>{estimatedBatches}</span></div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-surface-400">Capacity maintained:</span>
|
||||||
|
<span className={capacityMaintained >= 75 ? 'text-green-400' : capacityMaintained >= 50 ? 'text-amber-400' : 'text-red-400'}>~{capacityMaintained}%</span>
|
||||||
|
</div>
|
||||||
|
{eligible.skipped > 0 && (
|
||||||
|
<div className="text-xs text-surface-500 mt-1">{eligible.skipped} DCs skipped (constructing, empty, or already target SKU)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedSku && onConfirm(selectedSku, maxConcurrent)}
|
||||||
|
disabled={!selectedSku || eligible.count === 0}
|
||||||
|
className="flex-1 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
||||||
|
Start Campus Retrofit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampusRetrofitProgress({ campus, onCancel }: { campus: Campus; onCancel: () => void }) {
|
||||||
|
const queue = campus.retrofitQueue;
|
||||||
|
if (!queue) return null;
|
||||||
|
|
||||||
|
const total = queue.pendingDCIds.length + queue.activeDCIds.length + queue.completedDCIds.length;
|
||||||
|
const completed = queue.completedDCIds.length;
|
||||||
|
const active = queue.activeDCIds.length;
|
||||||
|
const pending = queue.pendingDCIds.length;
|
||||||
|
const pct = total > 0 ? (completed / total) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-violet-500/10 border border-violet-500/30 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw size={16} className="text-violet-400 animate-spin" style={{ animationDuration: '3s' }} />
|
||||||
|
<span className="text-sm font-medium">Campus Retrofit — {RACK_SKU_CONFIGS[queue.targetSkuId].name}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onCancel} className="flex items-center gap-1 text-xs text-surface-400 hover:text-red-400 transition-colors">
|
||||||
|
<X size={12} /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-violet-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-surface-400">
|
||||||
|
<span>{completed} of {total} complete</span>
|
||||||
|
<span>{active} active | {pending} pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Campus Detail View ────────────────────────────────────────���
|
||||||
|
|
||||||
function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId: string }) {
|
function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId: string }) {
|
||||||
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
||||||
@@ -495,15 +909,34 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
|
|||||||
const setNav = useGameStore((s) => s.setInfraNav);
|
const setNav = useGameStore((s) => s.setInfraNav);
|
||||||
const buildDC = useGameStore((s) => s.buildDataCenter);
|
const buildDC = useGameStore((s) => s.buildDataCenter);
|
||||||
const addDCs = useGameStore((s) => s.addDCsToCampus);
|
const addDCs = useGameStore((s) => s.addDCsToCampus);
|
||||||
|
const fillCampus = useGameStore((s) => s.fillCampusToCapacity);
|
||||||
|
const startRetrofit = useGameStore((s) => s.startCampusRetrofit);
|
||||||
|
const cancelRetrofit = useGameStore((s) => s.cancelCampusRetrofit);
|
||||||
const money = useGameStore((s) => s.economy.money);
|
const money = useGameStore((s) => s.economy.money);
|
||||||
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
|
const research = useGameStore((s) => s.research.completedResearch);
|
||||||
|
|
||||||
const [showAddDC, setShowAddDC] = useState(false);
|
const [showAddDC, setShowAddDC] = useState(false);
|
||||||
const [dcName, setDcName] = useState('');
|
const [dcName, setDcName] = useState('');
|
||||||
const [bulkCount, setBulkCount] = useState(1);
|
const [bulkCount, setBulkCount] = useState(1);
|
||||||
|
const [showFillAll, setShowFillAll] = useState(false);
|
||||||
|
const [showRetrofit, setShowRetrofit] = useState(false);
|
||||||
|
|
||||||
if (!campus || !cluster) return <div className="text-surface-400">Campus not found.</div>;
|
if (!campus || !cluster) return <div className="text-surface-400">Campus not found.</div>;
|
||||||
|
|
||||||
const tierConfig = DC_TIER_CONFIGS[campus.dcTier];
|
const tierConfig = DC_TIER_CONFIGS[campus.dcTier];
|
||||||
|
const operationalDCs = campus.dataCenters.filter(dc => dc.status === 'operational');
|
||||||
|
const hasRetrofitQueue = !!campus.retrofitQueue;
|
||||||
|
|
||||||
|
const fillableDCs = operationalDCs.filter(dc => {
|
||||||
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots);
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
return maxCompute - (dc.computeRacksOnline + pipelineCount) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const retrofitEligibleDCs = operationalDCs.filter(dc =>
|
||||||
|
dc.rackSkuId && (dc.computeRacksOnline + dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0)) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -516,6 +949,20 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{operationalDCs.length > 0 && !hasRetrofitQueue && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setShowFillAll(true)}
|
||||||
|
disabled={fillableDCs.length === 0}
|
||||||
|
className="flex items-center gap-1.5 bg-surface-700 hover:bg-surface-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg px-3 py-2 text-sm">
|
||||||
|
<Rocket size={14} /> Fill All DCs
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowRetrofit(true)}
|
||||||
|
disabled={retrofitEligibleDCs.length === 0}
|
||||||
|
className="flex items-center gap-1.5 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg px-3 py-2 text-sm">
|
||||||
|
<RefreshCw size={14} /> Retrofit Campus
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button onClick={() => setShowAddDC(true)}
|
<button onClick={() => setShowAddDC(true)}
|
||||||
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
<Plus size={16} /> Add DC
|
<Plus size={16} /> Add DC
|
||||||
@@ -523,6 +970,11 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Campus Retrofit Progress Banner */}
|
||||||
|
{campus.retrofitQueue && (
|
||||||
|
<CampusRetrofitProgress campus={campus} onCancel={() => cancelRetrofit(campusId)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{campus.dataCenters.length === 0 && (
|
{campus.dataCenters.length === 0 && (
|
||||||
<TutorialHint id="first-dc">
|
<TutorialHint id="first-dc">
|
||||||
Add a data center to this campus. Once built, you can deploy racks to start generating compute.
|
Add a data center to this campus. Once built, you can deploy racks to start generating compute.
|
||||||
@@ -530,46 +982,56 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{campus.dataCenters.map(dc => (
|
{campus.dataCenters.map(dc => {
|
||||||
<button key={dc.id}
|
const queueStatus = campus.retrofitQueue
|
||||||
onClick={() => (dc.status === 'operational' || dc.status === 'retrofitting') && setNav({
|
? campus.retrofitQueue.pendingDCIds.includes(dc.id) ? 'queued'
|
||||||
level: 'datacenter', clusterId, campusId, datacenterId: dc.id,
|
: campus.retrofitQueue.completedDCIds.includes(dc.id) ? 'retrofit-done'
|
||||||
})}
|
: null
|
||||||
className="bg-surface-800 border border-surface-700 rounded-xl p-4 text-left hover:border-accent/50 transition-colors">
|
: null;
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Server size={16} className="text-violet-400" />
|
|
||||||
<span className="font-bold">{dc.name}</span>
|
|
||||||
{dc.rackSkuId && <span className="text-xs bg-surface-700 text-surface-300 px-2 py-0.5 rounded">{RACK_SKU_CONFIGS[dc.rackSkuId].name}</span>}
|
|
||||||
{dc.status === 'constructing' && <span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building</span>}
|
|
||||||
{dc.status === 'retrofitting' && <span className="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded">Retrofitting</span>}
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={14} className="text-surface-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dc.status === 'constructing' ? (
|
return (
|
||||||
<div>
|
<button key={dc.id}
|
||||||
<div className="flex justify-between text-xs mb-1">
|
onClick={() => (dc.status === 'operational' || dc.status === 'retrofitting') && setNav({
|
||||||
<span className="text-surface-400">Construction</span>
|
level: 'datacenter', clusterId, campusId, datacenterId: dc.id,
|
||||||
<span className="font-mono">{Math.floor((dc.constructionProgress / dc.constructionTotal) * 100)}%</span>
|
})}
|
||||||
</div>
|
className="bg-surface-800 border border-surface-700 rounded-xl p-4 text-left hover:border-accent/50 transition-colors">
|
||||||
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="h-full bg-amber-500 rounded-full transition-all" style={{ width: `${(dc.constructionProgress / dc.constructionTotal) * 100}%` }} />
|
<div className="flex items-center gap-2">
|
||||||
|
<Server size={16} className="text-violet-400" />
|
||||||
|
<span className="font-bold">{dc.name}</span>
|
||||||
|
{dc.rackSkuId && <span className="text-xs bg-surface-700 text-surface-300 px-2 py-0.5 rounded">{RACK_SKU_CONFIGS[dc.rackSkuId].name}</span>}
|
||||||
|
{dc.status === 'constructing' && <span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building</span>}
|
||||||
|
{dc.status === 'retrofitting' && <span className="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded">Retrofitting</span>}
|
||||||
|
{queueStatus === 'queued' && <span className="text-xs bg-surface-600 text-surface-300 px-2 py-0.5 rounded">Queued</span>}
|
||||||
|
{queueStatus === 'retrofit-done' && <span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded flex items-center gap-1"><CheckCircle size={10} /> Done</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronRight size={14} className="text-surface-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
{dc.status === 'constructing' ? (
|
||||||
<DeploymentProgressBar dc={dc} />
|
<div>
|
||||||
<NetworkHealthIndicator dc={dc} />
|
<div className="flex justify-between text-xs mb-1">
|
||||||
{dc.deploymentCohorts.length > 0 && <CohortStageBreakdown cohorts={dc.deploymentCohorts} />}
|
<span className="text-surface-400">Construction</span>
|
||||||
<div className="flex justify-between text-xs text-surface-400">
|
<span className="font-mono">{Math.floor((dc.constructionProgress / dc.constructionTotal) * 100)}%</span>
|
||||||
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
|
</div>
|
||||||
<span>Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-500 rounded-full transition-all" style={{ width: `${(dc.constructionProgress / dc.constructionTotal) * 100}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="space-y-2">
|
||||||
</button>
|
<DeploymentProgressBar dc={dc} />
|
||||||
))}
|
<NetworkHealthIndicator dc={dc} />
|
||||||
|
{dc.deploymentCohorts.length > 0 && <CohortStageBreakdown cohorts={dc.deploymentCohorts} />}
|
||||||
|
<div className="flex justify-between text-xs text-surface-400">
|
||||||
|
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
|
||||||
|
<span>Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddDC && (
|
{showAddDC && (
|
||||||
@@ -612,6 +1074,27 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFillAll && (
|
||||||
|
<FillAllDCsModal
|
||||||
|
campus={campus}
|
||||||
|
money={money}
|
||||||
|
era={era}
|
||||||
|
research={research}
|
||||||
|
onConfirm={(skuId) => { fillCampus(campusId, skuId); setShowFillAll(false); }}
|
||||||
|
onClose={() => setShowFillAll(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRetrofit && (
|
||||||
|
<RetrofitCampusModal
|
||||||
|
campus={campus}
|
||||||
|
era={era}
|
||||||
|
research={research}
|
||||||
|
onConfirm={(skuId, maxConcurrent) => { startRetrofit(campusId, skuId, maxConcurrent); setShowRetrofit(false); }}
|
||||||
|
onClose={() => setShowRetrofit(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-1
@@ -9,7 +9,7 @@ import type {
|
|||||||
Cluster, Campus, DataCenter, DCTier, RackSkuId, TrainingJob,
|
Cluster, Campus, DataCenter, DCTier, RackSkuId, TrainingJob,
|
||||||
ActiveResearch, OwnedDataset, LocationId,
|
ActiveResearch, OwnedDataset, LocationId,
|
||||||
DeploymentCohort, PipelineStage,
|
DeploymentCohort, PipelineStage,
|
||||||
NetworkHealthState,
|
NetworkHealthState, CampusRetrofitQueue,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared';
|
import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared';
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +79,10 @@ interface Actions {
|
|||||||
addDCsToCampus: (campusId: string, count: number) => void;
|
addDCsToCampus: (campusId: string, count: number) => void;
|
||||||
retrofitDC: (dataCenterId: string, newSkuId: RackSkuId) => void;
|
retrofitDC: (dataCenterId: string, newSkuId: RackSkuId) => void;
|
||||||
cancelRetrofit: (dataCenterId: string) => void;
|
cancelRetrofit: (dataCenterId: string) => void;
|
||||||
|
fillCampusToCapacity: (campusId: string, skuId: RackSkuId) => void;
|
||||||
|
fillClusterToCapacity: (clusterId: string, skuId: RackSkuId) => void;
|
||||||
|
startCampusRetrofit: (campusId: string, targetSkuId: RackSkuId, maxConcurrent: number) => void;
|
||||||
|
cancelCampusRetrofit: (campusId: string) => void;
|
||||||
upgradeDataCenter: (dataCenterId: string, upgrade: 'cooling' | 'redundancy') => void;
|
upgradeDataCenter: (dataCenterId: string, upgrade: 'cooling' | 'redundancy') => void;
|
||||||
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => void;
|
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => void;
|
||||||
deployModel: (modelId: string) => void;
|
deployModel: (modelId: string) => void;
|
||||||
@@ -166,6 +170,62 @@ function updateDCInInfra(infra: InfrastructureState, dcId: string, updater: (dc:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateClusterInInfra(infra: InfrastructureState, clusterId: string, updater: (cluster: Cluster) => Cluster): InfrastructureState {
|
||||||
|
return {
|
||||||
|
...infra,
|
||||||
|
clusters: infra.clusters.map(cluster =>
|
||||||
|
cluster.id === clusterId ? updater(cluster) : cluster,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFillForDC(
|
||||||
|
dc: DataCenter,
|
||||||
|
skuId: RackSkuId,
|
||||||
|
availableMoney: number,
|
||||||
|
): { qty: number; cost: number } {
|
||||||
|
if (dc.status !== 'operational') return { qty: 0, cost: 0 };
|
||||||
|
if (dc.rackSkuId !== null && dc.rackSkuId !== skuId) return { qty: 0, cost: 0 };
|
||||||
|
|
||||||
|
const sku = RACK_SKU_CONFIGS[skuId];
|
||||||
|
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
||||||
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots);
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
const existingCompute = dc.computeRacksOnline + pipelineCount;
|
||||||
|
const available = maxCompute - existingCompute;
|
||||||
|
if (available <= 0) return { qty: 0, cost: 0 };
|
||||||
|
|
||||||
|
const affordableQty = Math.floor(availableMoney / sku.baseCost);
|
||||||
|
const powerLimit = Math.floor((tierConfig.powerBudgetKW - dc.computeRacksOnline * sku.powerDrawKW) / sku.powerDrawKW);
|
||||||
|
const qty = Math.min(available, affordableQty, Math.max(0, powerLimit));
|
||||||
|
if (qty <= 0) return { qty: 0, cost: 0 };
|
||||||
|
|
||||||
|
return { qty, cost: sku.baseCost * qty };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRetrofitOnDC(dc: DataCenter, targetSkuId: RackSkuId): DataCenter {
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
const totalRacks = dc.computeRacksOnline + pipelineCount;
|
||||||
|
if (totalRacks <= 0) return dc;
|
||||||
|
|
||||||
|
const oldSku = RACK_SKU_CONFIGS[dc.rackSkuId!];
|
||||||
|
const decommTicks = Math.ceil(oldSku.pipelineTimeTicks.installation * (1 + COHORT_SCALE_FACTOR * totalRacks));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dc,
|
||||||
|
status: 'retrofitting' as const,
|
||||||
|
deploymentCohorts: [],
|
||||||
|
retrofitState: {
|
||||||
|
fromSkuId: dc.rackSkuId!,
|
||||||
|
toSkuId: targetSkuId,
|
||||||
|
phase: 'decommissioning' as const,
|
||||||
|
progress: 0,
|
||||||
|
total: decommTicks,
|
||||||
|
racksRemaining: totalRacks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateCampusInInfra(infra: InfrastructureState, campusId: string, updater: (campus: Campus) => Campus): InfrastructureState {
|
function updateCampusInInfra(infra: InfrastructureState, campusId: string, updater: (campus: Campus) => Campus): InfrastructureState {
|
||||||
return {
|
return {
|
||||||
...infra,
|
...infra,
|
||||||
@@ -297,6 +357,7 @@ export const useGameStore = create<Store>()(
|
|||||||
status: 'constructing',
|
status: 'constructing',
|
||||||
constructionProgress: 0,
|
constructionProgress: 0,
|
||||||
constructionTotal: buildTime,
|
constructionTotal: buildTime,
|
||||||
|
retrofitQueue: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -535,6 +596,179 @@ export const useGameStore = create<Store>()(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --- Infrastructure: Bulk Actions ---
|
||||||
|
|
||||||
|
fillCampusToCapacity: (campusId, skuId) => set((s) => {
|
||||||
|
const found = findCampus(s.infrastructure, campusId);
|
||||||
|
if (!found || found.campus.status !== 'operational') return s;
|
||||||
|
|
||||||
|
const sku = RACK_SKU_CONFIGS[skuId];
|
||||||
|
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
|
if (eraOrder.indexOf(s.meta.currentEra) < eraOrder.indexOf(sku.era)) return s;
|
||||||
|
if (sku.requiredResearch && !s.research.completedResearch.includes(sku.requiredResearch)) return s;
|
||||||
|
|
||||||
|
let remainingMoney = s.economy.money;
|
||||||
|
const dcUpdates = new Map<string, DeploymentCohort>();
|
||||||
|
|
||||||
|
for (const dc of found.campus.dataCenters) {
|
||||||
|
const { qty, cost } = computeFillForDC(dc, skuId, remainingMoney);
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
|
||||||
|
const baseTicks = PIPELINE_ORDER_BASE_TICKS;
|
||||||
|
const scaledTicks = Math.ceil(baseTicks * (1 + COHORT_SCALE_FACTOR * qty));
|
||||||
|
dcUpdates.set(dc.id, {
|
||||||
|
id: uuid(),
|
||||||
|
count: qty,
|
||||||
|
skuId,
|
||||||
|
stage: 'ordered' as PipelineStage,
|
||||||
|
stageProgress: 0,
|
||||||
|
stageTotal: scaledTicks,
|
||||||
|
repairCount: 0,
|
||||||
|
});
|
||||||
|
remainingMoney -= cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dcUpdates.size === 0) return s;
|
||||||
|
|
||||||
|
return {
|
||||||
|
economy: { ...s.economy, money: remainingMoney },
|
||||||
|
infrastructure: updateCampusInInfra(s.infrastructure, campusId, (campus) => ({
|
||||||
|
...campus,
|
||||||
|
dataCenters: campus.dataCenters.map(dc => {
|
||||||
|
const cohort = dcUpdates.get(dc.id);
|
||||||
|
if (!cohort) return dc;
|
||||||
|
return { ...dc, rackSkuId: skuId, deploymentCohorts: [...dc.deploymentCohorts, cohort] };
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
fillClusterToCapacity: (clusterId, skuId) => set((s) => {
|
||||||
|
const cluster = findCluster(s.infrastructure, clusterId);
|
||||||
|
if (!cluster || cluster.status !== 'operational') return s;
|
||||||
|
|
||||||
|
const sku = RACK_SKU_CONFIGS[skuId];
|
||||||
|
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
|
if (eraOrder.indexOf(s.meta.currentEra) < eraOrder.indexOf(sku.era)) return s;
|
||||||
|
if (sku.requiredResearch && !s.research.completedResearch.includes(sku.requiredResearch)) return s;
|
||||||
|
|
||||||
|
let remainingMoney = s.economy.money;
|
||||||
|
const allDcUpdates = new Map<string, DeploymentCohort>();
|
||||||
|
|
||||||
|
for (const campus of cluster.campuses) {
|
||||||
|
if (campus.status !== 'operational') continue;
|
||||||
|
for (const dc of campus.dataCenters) {
|
||||||
|
const { qty, cost } = computeFillForDC(dc, skuId, remainingMoney);
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
|
||||||
|
const baseTicks = PIPELINE_ORDER_BASE_TICKS;
|
||||||
|
const scaledTicks = Math.ceil(baseTicks * (1 + COHORT_SCALE_FACTOR * qty));
|
||||||
|
allDcUpdates.set(dc.id, {
|
||||||
|
id: uuid(),
|
||||||
|
count: qty,
|
||||||
|
skuId,
|
||||||
|
stage: 'ordered' as PipelineStage,
|
||||||
|
stageProgress: 0,
|
||||||
|
stageTotal: scaledTicks,
|
||||||
|
repairCount: 0,
|
||||||
|
});
|
||||||
|
remainingMoney -= cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDcUpdates.size === 0) return s;
|
||||||
|
|
||||||
|
return {
|
||||||
|
economy: { ...s.economy, money: remainingMoney },
|
||||||
|
infrastructure: updateClusterInInfra(s.infrastructure, clusterId, (cl) => ({
|
||||||
|
...cl,
|
||||||
|
campuses: cl.campuses.map(campus => ({
|
||||||
|
...campus,
|
||||||
|
dataCenters: campus.dataCenters.map(dc => {
|
||||||
|
const cohort = allDcUpdates.get(dc.id);
|
||||||
|
if (!cohort) return dc;
|
||||||
|
return { ...dc, rackSkuId: skuId, deploymentCohorts: [...dc.deploymentCohorts, cohort] };
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
startCampusRetrofit: (campusId, targetSkuId, maxConcurrent) => set((s) => {
|
||||||
|
const found = findCampus(s.infrastructure, campusId);
|
||||||
|
if (!found || found.campus.status !== 'operational') return s;
|
||||||
|
if (found.campus.retrofitQueue) return s;
|
||||||
|
|
||||||
|
const sku = RACK_SKU_CONFIGS[targetSkuId];
|
||||||
|
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
|
if (eraOrder.indexOf(s.meta.currentEra) < eraOrder.indexOf(sku.era)) return s;
|
||||||
|
if (sku.requiredResearch && !s.research.completedResearch.includes(sku.requiredResearch)) return s;
|
||||||
|
|
||||||
|
const eligible: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const dc of found.campus.dataCenters) {
|
||||||
|
if (dc.status !== 'operational' || !dc.rackSkuId || dc.rackSkuId === targetSkuId) {
|
||||||
|
skipped.push(dc.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
if (dc.computeRacksOnline + pipelineCount <= 0) {
|
||||||
|
skipped.push(dc.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eligible.push(dc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligible.length === 0) return s;
|
||||||
|
|
||||||
|
const concurrent = Math.max(1, Math.min(maxConcurrent, eligible.length));
|
||||||
|
const toStartNow = eligible.slice(0, concurrent);
|
||||||
|
const pending = eligible.slice(concurrent);
|
||||||
|
|
||||||
|
const queue: CampusRetrofitQueue = {
|
||||||
|
targetSkuId,
|
||||||
|
maxConcurrent: concurrent,
|
||||||
|
pendingDCIds: pending,
|
||||||
|
activeDCIds: toStartNow,
|
||||||
|
completedDCIds: [],
|
||||||
|
skippedDCIds: skipped,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
infrastructure: updateCampusInInfra(s.infrastructure, campusId, (campus) => ({
|
||||||
|
...campus,
|
||||||
|
retrofitQueue: queue,
|
||||||
|
dataCenters: campus.dataCenters.map(dc => {
|
||||||
|
if (!toStartNow.includes(dc.id)) return dc;
|
||||||
|
return startRetrofitOnDC(dc, targetSkuId);
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelCampusRetrofit: (campusId) => set((s) => {
|
||||||
|
const found = findCampus(s.infrastructure, campusId);
|
||||||
|
if (!found || !found.campus.retrofitQueue) return s;
|
||||||
|
|
||||||
|
const queue = found.campus.retrofitQueue;
|
||||||
|
if (queue.activeDCIds.length === 0) {
|
||||||
|
return {
|
||||||
|
infrastructure: updateCampusInInfra(s.infrastructure, campusId, (campus) => ({
|
||||||
|
...campus,
|
||||||
|
retrofitQueue: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
infrastructure: updateCampusInInfra(s.infrastructure, campusId, (campus) => ({
|
||||||
|
...campus,
|
||||||
|
retrofitQueue: { ...queue, pendingDCIds: [] },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// --- Infrastructure: Upgrades ---
|
// --- Infrastructure: Upgrades ---
|
||||||
|
|
||||||
upgradeDataCenter: (dataCenterId, upgrade) => set((s) => {
|
upgradeDataCenter: (dataCenterId, upgrade) => set((s) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
GameState, InfrastructureState, Cluster, Campus, DataCenter,
|
GameState, InfrastructureState, Cluster, Campus, DataCenter,
|
||||||
DeploymentCohort, NetworkHealthState, PipelineStage,
|
DeploymentCohort, NetworkHealthState, PipelineStage, RackSkuId,
|
||||||
|
CampusRetrofitQueue,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
import {
|
import {
|
||||||
LOCATION_CONFIGS,
|
LOCATION_CONFIGS,
|
||||||
@@ -442,7 +443,74 @@ export function processInfrastructure(state: GameState): InfraTickResult {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...campus, dataCenters };
|
// Process campus retrofit queue
|
||||||
|
let finalDCs = dataCenters;
|
||||||
|
let updatedQueue: CampusRetrofitQueue | null = campus.retrofitQueue ?? null;
|
||||||
|
|
||||||
|
if (updatedQueue && updatedQueue.pendingDCIds.length + updatedQueue.activeDCIds.length > 0) {
|
||||||
|
updatedQueue = { ...updatedQueue };
|
||||||
|
|
||||||
|
// Detect DCs that just completed retrofit (were active, now operational)
|
||||||
|
const newlyCompleted = finalDCs.filter(
|
||||||
|
dc => updatedQueue!.activeDCIds.includes(dc.id) && dc.status === 'operational',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newlyCompleted.length > 0) {
|
||||||
|
updatedQueue.activeDCIds = updatedQueue.activeDCIds.filter(
|
||||||
|
id => !newlyCompleted.some(dc => dc.id === id),
|
||||||
|
);
|
||||||
|
updatedQueue.completedDCIds = [
|
||||||
|
...updatedQueue.completedDCIds,
|
||||||
|
...newlyCompleted.map(dc => dc.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote DCs from pending to active
|
||||||
|
const slotsAvailable = updatedQueue.maxConcurrent - updatedQueue.activeDCIds.length;
|
||||||
|
if (slotsAvailable > 0 && updatedQueue.pendingDCIds.length > 0) {
|
||||||
|
const toStart = updatedQueue.pendingDCIds.slice(0, slotsAvailable);
|
||||||
|
updatedQueue.pendingDCIds = updatedQueue.pendingDCIds.slice(toStart.length);
|
||||||
|
updatedQueue.activeDCIds = [...updatedQueue.activeDCIds, ...toStart];
|
||||||
|
|
||||||
|
finalDCs = finalDCs.map(dc => {
|
||||||
|
if (!toStart.includes(dc.id)) return dc;
|
||||||
|
if (dc.status !== 'operational' || !dc.rackSkuId) return dc;
|
||||||
|
|
||||||
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
||||||
|
const totalRacks = dc.computeRacksOnline + pipelineCount;
|
||||||
|
if (totalRacks <= 0) return dc;
|
||||||
|
|
||||||
|
const oldSku = RACK_SKU_CONFIGS[dc.rackSkuId as RackSkuId];
|
||||||
|
const decommTicks = Math.ceil(oldSku.pipelineTimeTicks.installation * (1 + COHORT_SCALE_FACTOR * totalRacks));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dc,
|
||||||
|
status: 'retrofitting' as const,
|
||||||
|
deploymentCohorts: [],
|
||||||
|
retrofitState: {
|
||||||
|
fromSkuId: dc.rackSkuId as RackSkuId,
|
||||||
|
toSkuId: updatedQueue!.targetSkuId,
|
||||||
|
phase: 'decommissioning' as const,
|
||||||
|
progress: 0,
|
||||||
|
total: decommTicks,
|
||||||
|
racksRemaining: totalRacks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if queue is complete
|
||||||
|
if (updatedQueue.pendingDCIds.length === 0 && updatedQueue.activeDCIds.length === 0) {
|
||||||
|
notifications.push({
|
||||||
|
title: 'Campus Retrofit Complete',
|
||||||
|
message: `All DCs in ${campus.name} have been retrofitted to ${RACK_SKU_CONFIGS[updatedQueue.targetSkuId].name}!`,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
updatedQueue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...campus, dataCenters: finalDCs, retrofitQueue: updatedQueue };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...cluster, campuses };
|
return { ...cluster, campuses };
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export interface Cluster {
|
|||||||
|
|
||||||
export type CampusStatus = 'constructing' | 'operational';
|
export type CampusStatus = 'constructing' | 'operational';
|
||||||
|
|
||||||
|
export interface CampusRetrofitQueue {
|
||||||
|
targetSkuId: RackSkuId;
|
||||||
|
maxConcurrent: number;
|
||||||
|
pendingDCIds: string[];
|
||||||
|
activeDCIds: string[];
|
||||||
|
completedDCIds: string[];
|
||||||
|
skippedDCIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Campus {
|
export interface Campus {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,6 +36,7 @@ export interface Campus {
|
|||||||
status: CampusStatus;
|
status: CampusStatus;
|
||||||
constructionProgress: number;
|
constructionProgress: number;
|
||||||
constructionTotal: number;
|
constructionTotal: number;
|
||||||
|
retrofitQueue: CampusRetrofitQueue | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Data Center ---
|
// --- Data Center ---
|
||||||
|
|||||||
Reference in New Issue
Block a user