From 4a318c36addbdaab3033dcb65c34c294e8cbd6e3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 25 Apr 2026 00:26:14 -0400 Subject: [PATCH] Add bulk fill and staggered retrofit at campus/cluster level 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 --- apps/web/src/pages/InfrastructurePage.tsx | 567 ++++++++++++++++-- apps/web/src/store/index.ts | 236 +++++++- .../src/systems/infrastructureSystem.ts | 72 ++- packages/shared/src/types/infrastructure.ts | 10 + 4 files changed, 840 insertions(+), 45 deletions(-) diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 91c7655..1fbe042 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -5,10 +5,11 @@ import { Rocket, Clock, Lock, Cpu, Activity, DollarSign, Globe, Building2, Layers, Network, ArrowLeft, RefreshCw, ChevronDown, + X, CheckCircle, } from 'lucide-react'; import { TutorialHint } from '@/components/game/TutorialHint'; import { ConfirmModal } from '@/components/common/ConfirmModal'; -import { useGameStore, type InfraNav } from '@/store'; +import { useGameStore, type InfraNav, computeFillForDC } from '@/store'; import { formatMoney, formatNumber, formatPercent, 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 = {}; + 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(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 ( +
+
e.stopPropagation()}> +

Fill All DCs — {cluster.name}

+ +
+
+ +
+ {availableSkus.map(s => ( + + ))} +
+
+ + {selectedSku && ( +
+ +
+ {preview.campuses.map(({ campus, totalQty, totalCost }) => ( +
+ {campus.name} ({campus.dataCenters.length} DCs) + {totalQty > 0 ? ( + +{totalQty} racks ({formatMoney(totalCost)}) + ) : ( + No change + )} +
+ ))} +
+
+ )} + +
+
Total racks:{formatNumber(preview.totalQty)}
+
Total cost:{formatMoney(preview.totalCost)}
+
Remaining budget:{formatMoney(money - preview.totalCost)}
+
+
+ +
+ + +
+
+
+ ); +} + // ─── Cluster Detail View ──────────────────────────────────────── function ClusterDetailView({ clusterId }: { clusterId: string }) { const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId)); 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 [showFillAll, setShowFillAll] = useState(false); if (!cluster) return
Cluster not found.
; const location = LOCATION_CONFIGS[cluster.locationId]; + const allDCs = cluster.campuses.flatMap(c => c.dataCenters); + const hasOperationalDCs = allDCs.some(dc => dc.status === 'operational'); + return (
@@ -358,10 +474,18 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) {

{location.name} — {location.energyCostMultiplier}x energy cost

- +
+ {hasOperationalDCs && ( + + )} + +
{cluster.campuses.length === 0 && ( @@ -383,6 +507,9 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) { {campus.status === 'constructing' && ( Building... )} + {campus.retrofitQueue && ( + Retrofitting + )} @@ -416,6 +543,17 @@ function ClusterDetailView({ clusterId }: { clusterId: string }) { {showBuild && setShowBuild(false)} />} + + {showFillAll && ( + { fillCluster(clusterId, skuId); setShowFillAll(false); }} + onClose={() => setShowFillAll(false)} + /> + )} ); } @@ -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 = {}; + 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(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 ( +
+
e.stopPropagation()}> +

Fill All DCs

+ +
+
+ +
+ {availableSkus.map(s => ( + + ))} +
+
+ + {selectedSku && ( +
+ +
+ {preview.dcs.map(({ dc, qty, cost, reason }) => ( +
+ {dc.name} + {reason ? ( + {reason} + ) : ( + +{qty} racks ({formatMoney(cost)}) + )} +
+ ))} +
+
+ )} + +
+
Total racks:{formatNumber(preview.totalQty)}
+
Total cost:{formatMoney(preview.totalCost)}
+
Remaining budget:{formatMoney(money - preview.totalCost)}
+
+
+ +
+ + +
+
+
+ ); +} + +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(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 ( +
+
e.stopPropagation()}> +

Retrofit Campus

+ +
+
+ +
+ {targetSkus.map(s => { + const isCurrentOnly = currentSkuIds.length === 1 && currentSkuIds[0] === s.id; + return ( + + ); + })} +
+
+ + {selectedSku && eligible.count > 0 && ( + <> +
+ +
+ {([ + { 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 => ( + + ))} +
+ {concurrencyMode === 'custom' && ( + 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" /> + )} +
+ +
+
DCs to retrofit:{eligible.count} of {campus.dataCenters.length}
+
Concurrent retrofits:{maxConcurrent}
+
Estimated batches:{estimatedBatches}
+
+ Capacity maintained: + = 75 ? 'text-green-400' : capacityMaintained >= 50 ? 'text-amber-400' : 'text-red-400'}>~{capacityMaintained}% +
+ {eligible.skipped > 0 && ( +
{eligible.skipped} DCs skipped (constructing, empty, or already target SKU)
+ )} +
+ + )} +
+ +
+ + +
+
+
+ ); +} + +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 ( +
+
+
+ + Campus Retrofit — {RACK_SKU_CONFIGS[queue.targetSkuId].name} +
+ +
+
+
+
+
+ {completed} of {total} complete + {active} active | {pending} pending +
+
+ ); +} + +// ─── Campus Detail View ────────────────────────────────────────��� function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId: string }) { 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 buildDC = useGameStore((s) => s.buildDataCenter); 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 era = useGameStore((s) => s.meta.currentEra); + const research = useGameStore((s) => s.research.completedResearch); const [showAddDC, setShowAddDC] = useState(false); const [dcName, setDcName] = useState(''); const [bulkCount, setBulkCount] = useState(1); + const [showFillAll, setShowFillAll] = useState(false); + const [showRetrofit, setShowRetrofit] = useState(false); if (!campus || !cluster) return
Campus not found.
; 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 (
@@ -516,6 +949,20 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId
+ {operationalDCs.length > 0 && !hasRetrofitQueue && ( + <> + + + + )}
+ {/* Campus Retrofit Progress Banner */} + {campus.retrofitQueue && ( + cancelRetrofit(campusId)} /> + )} + {campus.dataCenters.length === 0 && ( 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 )}
- {campus.dataCenters.map(dc => ( - - ))} + ) : ( +
+ + + {dc.deploymentCohorts.length > 0 && } +
+ Uptime: {formatPercent(dc.currentUptime)} + Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s +
+
+ )} + + ); + })}
{showAddDC && ( @@ -612,6 +1074,27 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId )} + + {showFillAll && ( + { fillCampus(campusId, skuId); setShowFillAll(false); }} + onClose={() => setShowFillAll(false)} + /> + )} + + {showRetrofit && ( + { startRetrofit(campusId, skuId, maxConcurrent); setShowRetrofit(false); }} + onClose={() => setShowRetrofit(false)} + /> + )} ); } diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 9581825..0bd5c6c 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -9,7 +9,7 @@ import type { Cluster, Campus, DataCenter, DCTier, RackSkuId, TrainingJob, ActiveResearch, OwnedDataset, LocationId, DeploymentCohort, PipelineStage, - NetworkHealthState, + NetworkHealthState, CampusRetrofitQueue, } from '@ai-tycoon/shared'; import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared'; import { @@ -79,6 +79,10 @@ interface Actions { addDCsToCampus: (campusId: string, count: number) => void; retrofitDC: (dataCenterId: string, newSkuId: RackSkuId) => 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; startTraining: (job: Omit) => 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 { return { ...infra, @@ -297,6 +357,7 @@ export const useGameStore = create()( status: 'constructing', constructionProgress: 0, constructionTotal: buildTime, + retrofitQueue: null, }; return { @@ -535,6 +596,179 @@ export const useGameStore = create()( }; }), + // --- 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(); + + 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(); + + 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 --- upgradeDataCenter: (dataCenterId, upgrade) => set((s) => { diff --git a/packages/game-engine/src/systems/infrastructureSystem.ts b/packages/game-engine/src/systems/infrastructureSystem.ts index 1b4533b..9b84950 100644 --- a/packages/game-engine/src/systems/infrastructureSystem.ts +++ b/packages/game-engine/src/systems/infrastructureSystem.ts @@ -1,6 +1,7 @@ import type { GameState, InfrastructureState, Cluster, Campus, DataCenter, - DeploymentCohort, NetworkHealthState, PipelineStage, + DeploymentCohort, NetworkHealthState, PipelineStage, RackSkuId, + CampusRetrofitQueue, } from '@ai-tycoon/shared'; import { 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 }; diff --git a/packages/shared/src/types/infrastructure.ts b/packages/shared/src/types/infrastructure.ts index 45df26b..2120fb5 100644 --- a/packages/shared/src/types/infrastructure.ts +++ b/packages/shared/src/types/infrastructure.ts @@ -18,6 +18,15 @@ export interface Cluster { export type CampusStatus = 'constructing' | 'operational'; +export interface CampusRetrofitQueue { + targetSkuId: RackSkuId; + maxConcurrent: number; + pendingDCIds: string[]; + activeDCIds: string[]; + completedDCIds: string[]; + skippedDCIds: string[]; +} + export interface Campus { id: string; name: string; @@ -27,6 +36,7 @@ export interface Campus { status: CampusStatus; constructionProgress: number; constructionTotal: number; + retrofitQueue: CampusRetrofitQueue | null; } // --- Data Center ---