diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 1fbe042..d390cbb 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -14,7 +14,8 @@ import { formatMoney, formatNumber, formatPercent, LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS, CAMPUS_TIER_COSTS, CLUSTER_COST_CONFIG, FIRST_CAMPUS_BUILD_TICKS, - networkSlotsRequired, maxComputeRacks, + estimateNetworkSlots, maxComputeRacks, + SWITCH_TIER_CONFIGS, DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT, } from '@ai-tycoon/shared'; import type { @@ -26,12 +27,14 @@ const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; const STAGE_LABELS: Record = { ordered: 'Ordered', manufacturing: 'Mfg', receiving: 'Recv', - installation: 'Install', testing: 'Testing', repair: 'Repair', decommission: 'Decom', + installation: 'Install', testing: 'Testing', repair: 'Repair', + 'network-down': 'Net Down', decommission: 'Decom', }; const STAGE_COLORS: Record = { ordered: 'bg-surface-600', manufacturing: 'bg-blue-500', receiving: 'bg-cyan-500', - installation: 'bg-violet-500', testing: 'bg-amber-500', repair: 'bg-danger', decommission: 'bg-surface-500', + installation: 'bg-violet-500', testing: 'bg-amber-500', repair: 'bg-danger', + 'network-down': 'bg-red-600', decommission: 'bg-surface-500', }; // ─── Shared Components ────────────────────────────────────────── @@ -112,7 +115,7 @@ function Breadcrumb({ nav }: { nav: InfraNav }) { function DeploymentProgressBar({ dc }: { dc: DataCenter }) { const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); const pipelineRacks = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((s, c) => s + c.count, 0); const totalTarget = dc.computeRacksOnline + pipelineRacks; const pct = totalTarget > 0 ? (dc.computeRacksOnline / totalTarget) * 100 : 0; @@ -157,23 +160,27 @@ function CohortStageBreakdown({ cohorts }: { cohorts: DeploymentCohort[] }) { } function NetworkHealthIndicator({ dc }: { dc: DataCenter }) { - const nh = dc.networkHealth; - if (nh.tier1Required === 0) return null; + const ns = dc.networkSummary; + if (ns.switchIds.length === 0) return null; - const allHealthy = nh.tier1Healthy === nh.tier1Required - && nh.tier2Healthy === nh.tier2Required - && nh.tier3Healthy === nh.tier3Required; + const hasDisconnected = ns.racksDisconnected > 0; + const hasDegraded = ns.racksDegraded > 0; + const coreDown = (ns.healthyByTier?.t3 ?? 0) < (ns.totalByTier?.t3 ?? 0); - const color = nh.tier3Healthy < nh.tier3Required ? 'text-danger' - : !allHealthy ? 'text-amber-400' + const color = coreDown ? 'text-danger' + : hasDisconnected ? 'text-danger' + : hasDegraded ? 'text-amber-400' : 'text-green-400'; + const bwPct = Math.round(ns.averageBandwidth * 100); + return (
- {nh.tier3Healthy < nh.tier3Required ? 'Core Down' - : !allHealthy ? `${nh.racksDisconnected} disconnected` + {coreDown ? 'Core Down' + : hasDisconnected ? `${ns.racksDisconnected} disconnected` + : hasDegraded ? `${bwPct}% bandwidth` : 'Healthy'}
@@ -661,7 +668,7 @@ function FillAllDCsModal({ campus, money, era, research, onConfirm, onClose }: { const { qty, cost } = computeFillForDC(dc, selectedSku, remaining); if (qty === 0) { const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); 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' }; @@ -929,7 +936,7 @@ function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId const hasRetrofitQueue = !!campus.retrofitQueue; const fillableDCs = operationalDCs.filter(dc => { - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0); return maxCompute - (dc.computeRacksOnline + pipelineCount) > 0; }); @@ -1124,12 +1131,12 @@ function DataCenterDetailView({ clusterId, campusId, datacenterId }: { if (!dc || !cluster) return
Data center not found.
; const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((s, c) => s + c.count, 0); const existingCompute = dc.computeRacksOnline + pipelineCount; const availableSlots = maxCompute - existingCompute; const sku = dc.rackSkuId ? RACK_SKU_CONFIGS[dc.rackSkuId] : null; - const netSlots = networkSlotsRequired(existingCompute); + const netSlots = estimateNetworkSlots(existingCompute, dc.tier); const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => { if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false; @@ -1257,7 +1264,7 @@ function DataCenterDetailView({ clusterId, campusId, datacenterId }: { {(() => { const skuToUse = dc.rackSkuId ?? selectedSku!; const skuConfig = RACK_SKU_CONFIGS[skuToUse]; - const newNetSlots = networkSlotsRequired(existingCompute + deployQty); + const newNetSlots = estimateNetworkSlots(existingCompute + deployQty, dc.tier); const addedNet = newNetSlots - netSlots; const totalCost = skuConfig.baseCost * deployQty; return ( @@ -1358,24 +1365,51 @@ function DataCenterDetailView({ clusterId, campusId, datacenterId }: {

No racks online. Deploy racks to see network topology.

) : (
- {[ - { label: 'Tier-1 (ToR)', required: dc.networkHealth.tier1Required, healthy: dc.networkHealth.tier1Healthy, desc: `1 per ${24} compute racks` }, - { label: 'Tier-2 (Aggr)', required: dc.networkHealth.tier2Required, healthy: dc.networkHealth.tier2Healthy, desc: `1 per ${6} Tier-1 switches` }, - { label: 'Tier-3 (Core)', required: dc.networkHealth.tier3Required, healthy: dc.networkHealth.tier3Healthy, desc: 'Redundant pair' }, - ].map(tier => ( -
-
-
{tier.label}
-
{tier.desc}
-
-
- {tier.healthy} / {tier.required} -
+ {/* Bandwidth gauge */} +
+
+ Bandwidth + + {formatPercent(dc.networkSummary.averageBandwidth)} +
- ))} - {dc.networkHealth.racksDisconnected > 0 && ( +
+
= 0.8 ? 'bg-green-500' : dc.networkSummary.averageBandwidth >= 0.5 ? 'bg-yellow-500' : 'bg-red-500'}`} + style={{ width: `${dc.networkSummary.averageBandwidth * 100}%` }} + /> +
+
+ Effective FLOPS: {formatPercent(dc.networkSummary.effectiveFlopsFraction)} + {dc.networkSummary.racksDegraded} degraded +
+
+ + {/* Per-tier switch health */} + {(['tor', 't1', 't2', 't3'] as const).map(tier => { + const total = dc.networkSummary.totalByTier[tier] ?? 0; + if (total === 0) return null; + const healthy = dc.networkSummary.healthyByTier[tier] ?? 0; + const failed = total - healthy; + const config = SWITCH_TIER_CONFIGS[tier]; + return ( +
+
+
{config.name}
+
+ {tier === 'tor' ? '1 per rack (embedded)' : `Fan-out ${config.fanOut}, ${config.uplinkCount} uplinks`} +
+
+
0 ? 'text-danger' : 'text-green-400'}`}> + {healthy} / {total} +
+
+ ); + })} + + {dc.networkSummary.racksDisconnected > 0 && (
- {dc.networkHealth.racksDisconnected} compute racks disconnected due to network failures + {dc.networkSummary.racksDisconnected} compute racks disconnected due to network failures
)}
diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 0bd5c6c..9059efc 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, CampusRetrofitQueue, + CampusRetrofitQueue, } from '@ai-tycoon/shared'; import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared'; import { @@ -25,9 +25,12 @@ import { FUNDING_ROUNDS, OPEN_SOURCE_REPUTATION_BOOST, LOCATION_CONFIGS, - networkSlotsRequired, maxComputeRacks, + estimateNetworkSlots, maxComputeRacks, uuid, } from '@ai-tycoon/shared'; +import { + emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary, +} from '@ai-tycoon/game-engine'; import { INITIAL_RIVALS } from '@ai-tycoon/game-engine'; export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models' @@ -57,8 +60,14 @@ export interface GameNotification { read: boolean; } -function emptyNetworkHealth(): NetworkHealthState { - return { tier1Required: 0, tier1Healthy: 0, tier2Required: 0, tier2Healthy: 0, tier3Required: 0, tier3Healthy: 0, racksDisconnected: 0 }; +function emptyDC(): Pick { + return { + networkSummary: emptyDCNetworkSummary(), + effectiveComputeRacks: 0, + usedSlots: 0, usedPowerKW: 0, + energyCostPerTick: 0, maintenanceCostPerTick: 0, + currentUptime: 1, + }; } interface Actions { @@ -189,7 +198,7 @@ export function computeFillForDC( const sku = RACK_SKU_CONFIGS[skuId]; const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); 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; @@ -319,6 +328,7 @@ export const useGameStore = create()( status: isFirst ? 'operational' : 'constructing', constructionProgress: isFirst ? 0 : 0, constructionTotal: isFirst ? 0 : CLUSTER_COST_CONFIG.buildTimeTicks, + networkSummary: emptyClusterNetworkSummary(), }; return { @@ -358,6 +368,7 @@ export const useGameStore = create()( constructionProgress: 0, constructionTotal: buildTime, retrofitQueue: null, + networkSummary: emptyCampusNetworkSummary(), }; return { @@ -398,17 +409,11 @@ export const useGameStore = create()( rackSkuId: null, computeRacksOnline: 0, computeRacksFailed: 0, - networkHealth: emptyNetworkHealth(), + ...emptyDC(), deploymentCohorts: [], retrofitState: null, coolingLevel: 0, redundancyLevel: 0, - effectiveComputeRacks: 0, - usedSlots: 0, - usedPowerKW: 0, - energyCostPerTick: 0, - maintenanceCostPerTick: 0, - currentUptime: 1, }; return { @@ -437,14 +442,14 @@ export const useGameStore = create()( if (sku.requiredResearch && !s.research.completedResearch.includes(sku.requiredResearch)) return s; const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); 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; const actualQty = Math.min(quantity, available); if (actualQty <= 0) return s; - const totalNetSlots = networkSlotsRequired(existingCompute + actualQty); + const totalNetSlots = estimateNetworkSlots(existingCompute + actualQty, dc.tier); const totalSlotsNeeded = existingCompute + actualQty + totalNetSlots; if (totalSlotsNeeded > tierConfig.rackSlots) return s; @@ -484,7 +489,7 @@ export const useGameStore = create()( const dc = found.dc; const tierConfig = DC_TIER_CONFIGS[dc.tier]; - const maxCompute = maxComputeRacks(tierConfig.rackSlots); + const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier); 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; @@ -522,17 +527,11 @@ export const useGameStore = create()( rackSkuId: null, computeRacksOnline: 0, computeRacksFailed: 0, - networkHealth: emptyNetworkHealth(), + ...emptyDC(), deploymentCohorts: [], retrofitState: null, coolingLevel: 0, redundancyLevel: 0, - effectiveComputeRacks: 0, - usedSlots: 0, - usedPowerKW: 0, - energyCostPerTick: 0, - maintenanceCostPerTick: 0, - currentUptime: 1, }); } diff --git a/packages/game-engine/src/data/techTree.ts b/packages/game-engine/src/data/techTree.ts index 120aeb8..ca32836 100644 --- a/packages/game-engine/src/data/techTree.ts +++ b/packages/game-engine/src/data/techTree.ts @@ -122,6 +122,36 @@ export const TECH_TREE: ResearchNode[] = [ cost: { researchPoints: 4, compute: 80, ticks: 360 }, effects: [{ type: 'cost_reduction', target: 'network_failure_rate', value: 0.5 }], }, + { + id: 'network-redundancy', + name: 'Network Redundancy', + description: 'Additional uplink per switch at all tiers — reduces disconnect risk from single switch failures.', + era: 'scaleup', + category: 'infrastructure', + prerequisites: ['network-engineering-i'], + cost: { researchPoints: 3, compute: 40, ticks: 240 }, + effects: [{ type: 'efficiency_boost', target: 'network_uplinks', value: 1 }], + }, + { + id: 'network-fast-repair', + name: 'Fast Network Repair', + description: 'Hot-swap switch modules and pre-staged spares reduce network repair time by 40%.', + era: 'bigtech', + category: 'infrastructure', + prerequisites: ['network-engineering-ii'], + cost: { researchPoints: 5, compute: 100, ticks: 400 }, + effects: [{ type: 'efficiency_boost', target: 'network_repair_speed', value: 0.4 }], + }, + { + id: 'network-hot-standby', + name: 'Hot Standby Switches', + description: 'Automated failover with standby switches — failed switches auto-replace in 5 ticks.', + era: 'agi', + category: 'infrastructure', + prerequisites: ['network-fast-repair'], + cost: { researchPoints: 8, compute: 250, ticks: 600 }, + effects: [{ type: 'efficiency_boost', target: 'network_hot_standby', value: 5 }], + }, { id: 'rapid-deployment', name: 'Rapid Deployment', diff --git a/packages/game-engine/src/index.ts b/packages/game-engine/src/index.ts index 879d722..40513c3 100644 --- a/packages/game-engine/src/index.ts +++ b/packages/game-engine/src/index.ts @@ -2,6 +2,7 @@ export { GameEngine } from './engine'; export { processTick, setAchievementDefinitions } from './tick'; export type { TickNotification } from './tick'; export { getAvailableResearch, getResearchNode } from './systems/researchSystem'; +export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem'; export { canRaiseFunding, getNextFundingRound, computeValuation } from './systems/fundingSystem'; export { TECH_TREE } from './data/techTree'; export { INITIAL_RIVALS } from './data/competitors'; diff --git a/packages/game-engine/src/systems/infrastructureSystem.ts b/packages/game-engine/src/systems/infrastructureSystem.ts index 52492eb..2233f62 100644 --- a/packages/game-engine/src/systems/infrastructureSystem.ts +++ b/packages/game-engine/src/systems/infrastructureSystem.ts @@ -1,7 +1,8 @@ import type { GameState, InfrastructureState, Cluster, Campus, DataCenter, - DeploymentCohort, NetworkHealthState, PipelineStage, RackSkuId, - CampusRetrofitQueue, + DeploymentCohort, PipelineStage, RackSkuId, NetworkSwitch, + SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary, + CampusRetrofitQueue, DCTier, } from '@ai-tycoon/shared'; import { LOCATION_CONFIGS, @@ -12,10 +13,13 @@ import { COOLING_FAILURE_REDUCTION, REDUNDANCY_FAILURE_REDUCTION, RACK_REPAIR_BASE_TICKS, - NETWORK_TOPOLOGY, COHORT_SCALE_FACTOR, PIPELINE_ORDER_BASE_TICKS, - networkSlotsRequired, + SWITCH_TIER_CONFIGS, + T3_COUNT_PER_DC_TIER, + SWITCH_REPAIR_COST_FRACTION, + NETWORK_DEGRADATION, + estimateNetworkSlots, } from '@ai-tycoon/shared'; import type { TickNotification } from '../tick'; @@ -25,6 +29,8 @@ export interface InfraTickResult { repairCosts: number; } +// --- Pipeline helpers --- + const PIPELINE_ADVANCE_ORDER: PipelineStage[] = [ 'ordered', 'manufacturing', 'receiving', 'installation', 'testing', ]; @@ -47,6 +53,7 @@ function cohortStageTotal(stage: PipelineStage, skuId: string, count: number): n case 'testing': base = timings.testing; break; case 'repair': base = RACK_REPAIR_BASE_TICKS; break; case 'decommission': base = timings.installation; break; + case 'network-down': base = 0; break; default: base = 0; } return Math.ceil(base * (1 + COHORT_SCALE_FACTOR * count)); @@ -59,6 +66,7 @@ function stageSpeed(stage: PipelineStage, engEff: number, opsEff: number): numbe case 'testing': case 'decommission': return 1 + opsEff * 0.1; case 'repair': return 1 + opsEff * 0.05; + case 'network-down': return 0; default: return 1; } } @@ -72,66 +80,363 @@ function binomialSample(n: number, p: number): number { return base + (Math.random() < frac ? 1 : 0); } -function computeNetworkHealth(computeRacksOnline: number): NetworkHealthState { - if (computeRacksOnline <= 0) { - return { tier1Required: 0, tier1Healthy: 0, tier2Required: 0, tier2Healthy: 0, tier3Required: 0, tier3Healthy: 0, racksDisconnected: 0 }; - } - const tier1 = Math.ceil(computeRacksOnline / NETWORK_TOPOLOGY.tier1PerCompute); - const tier2 = Math.ceil(tier1 / NETWORK_TOPOLOGY.tier2PerTier1); - const tier3 = NETWORK_TOPOLOGY.tier3PerDC; +// --- Network Topology Construction --- + +let switchIdCounter = 0; + +function createSwitch( + tier: SwitchTier, + dcId: string | null, + campusId: string | null, + clusterId: string | null, +): NetworkSwitch { + const config = SWITCH_TIER_CONFIGS[tier]; return { - tier1Required: tier1, - tier1Healthy: tier1, - tier2Required: tier2, - tier2Healthy: tier2, - tier3Required: tier3, - tier3Healthy: tier3, - racksDisconnected: 0, + id: `${tier}-${dcId ?? campusId ?? clusterId ?? 'x'}-${switchIdCounter++}`, + tier, + status: 'healthy', + dcId, campusId, clusterId, + uplinkIds: [], + downlinkIds: [], + activeUplinks: config.uplinkCount, + totalUplinks: config.uplinkCount, + effectiveBandwidth: 1.0, + repairProgress: 0, + repairTotal: 0, }; } -function processNetworkFailures( - nh: NetworkHealthState, - computeRacksOnline: number, +function wireUplinks(child: NetworkSwitch, parents: NetworkSwitch[], count: number): void { + if (parents.length === 0) return; + for (let i = 0; i < count; i++) { + const parent = parents[i % parents.length]; + child.uplinkIds.push(parent.id); + if (!parent.downlinkIds.includes(child.id)) { + parent.downlinkIds.push(child.id); + } + } + child.activeUplinks = count; + child.effectiveBandwidth = 1.0; +} + +export function emptyDCNetworkSummary(): DCNetworkSummary { + return { + switchIds: [], networkRackCount: 0, + totalByTier: {}, healthyByTier: {}, + racksDisconnected: 0, racksDegraded: 0, + averageBandwidth: 1, effectiveFlopsFraction: 1, + }; +} + +export function emptyCampusNetworkSummary(): CampusNetworkSummary { + return { switchIds: [], totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 }; +} + +export function emptyClusterNetworkSummary(): ClusterNetworkSummary { + return { switchIds: [], totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 }; +} + +export function buildDCTopology( + computeRackCount: number, + dcTier: DCTier, + dcId: string, + registry: Record, +): DCNetworkSummary { + if (computeRackCount <= 0) return emptyDCNetworkSummary(); + + const switchIds: string[] = []; + + const t3Count = T3_COUNT_PER_DC_TIER[dcTier]; + const t3s: NetworkSwitch[] = []; + for (let i = 0; i < t3Count; i++) { + const sw = createSwitch('t3', dcId, null, null); + sw.totalUplinks = 0; + sw.activeUplinks = 0; + t3s.push(sw); + registry[sw.id] = sw; + switchIds.push(sw.id); + } + + const t1Count = Math.ceil(computeRackCount / SWITCH_TIER_CONFIGS.t1.fanOut); + const t2Count = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t2.fanOut); + + const t2s: NetworkSwitch[] = []; + for (let i = 0; i < t2Count; i++) { + const sw = createSwitch('t2', dcId, null, null); + wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount); + t2s.push(sw); + registry[sw.id] = sw; + switchIds.push(sw.id); + } + + const t1s: NetworkSwitch[] = []; + for (let i = 0; i < t1Count; i++) { + const sw = createSwitch('t1', dcId, null, null); + wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount); + t1s.push(sw); + registry[sw.id] = sw; + switchIds.push(sw.id); + } + + for (let i = 0; i < computeRackCount; i++) { + const sw = createSwitch('tor', dcId, null, null); + const primary = t1s[Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut)]; + const altIdx = (Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length; + const alt = t1s[altIdx]; + if (t1s.length >= 2 && primary !== alt) { + wireUplinks(sw, [primary, alt], 2); + } else { + wireUplinks(sw, [primary], 2); + } + registry[sw.id] = sw; + switchIds.push(sw.id); + } + + const networkRackCount = estimateNetworkSlots(computeRackCount, dcTier); + return buildDCSummary(switchIds, networkRackCount, registry); +} + +export function expandDCTopology( + existing: DCNetworkSummary, + newRackCount: number, + dcTier: DCTier, + dcId: string, + registry: Record, +): DCNetworkSummary { + if (newRackCount <= 0) return existing; + + const currentTorCount = existing.totalByTier?.tor ?? 0; + const targetTorCount = currentTorCount + newRackCount; + + const t1s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't1'); + const t2s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't2'); + const t3s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't3'); + + const newIds = [...existing.switchIds]; + + const neededT1 = Math.ceil(targetTorCount / SWITCH_TIER_CONFIGS.t1.fanOut); + const neededT2 = Math.ceil(neededT1 / SWITCH_TIER_CONFIGS.t2.fanOut); + + while (t2s.length < neededT2) { + const sw = createSwitch('t2', dcId, null, null); + wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount); + t2s.push(sw); + registry[sw.id] = sw; + newIds.push(sw.id); + } + + while (t1s.length < neededT1) { + const sw = createSwitch('t1', dcId, null, null); + wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount); + t1s.push(sw); + registry[sw.id] = sw; + newIds.push(sw.id); + } + + for (let i = 0; i < newRackCount; i++) { + const torIdx = currentTorCount + i; + const sw = createSwitch('tor', dcId, null, null); + const primary = t1s[Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut)]; + const altIdx = (Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length; + const alt = t1s[altIdx]; + if (t1s.length >= 2 && primary !== alt) { + wireUplinks(sw, [primary, alt], 2); + } else { + wireUplinks(sw, [primary], 2); + } + registry[sw.id] = sw; + newIds.push(sw.id); + } + + const networkRackCount = estimateNetworkSlots(targetTorCount, dcTier); + return buildDCSummary(newIds, networkRackCount, registry); +} + +export function shrinkDCTopology( + existing: DCNetworkSummary, + removeCount: number, + dcTier: DCTier, + registry: Record, +): DCNetworkSummary { + if (removeCount <= 0) return existing; + + const torIds = existing.switchIds.filter(id => registry[id]?.tier === 'tor'); + const toRemove = new Set(torIds.slice(-removeCount)); + + for (const torId of toRemove) { + const tor = registry[torId]; + if (!tor) continue; + for (const upId of tor.uplinkIds) { + const parent = registry[upId]; + if (parent) parent.downlinkIds = parent.downlinkIds.filter(id => id !== torId); + } + delete registry[torId]; + } + + const remainingIds = existing.switchIds.filter(id => !toRemove.has(id)); + const remainingTors = remainingIds.filter(id => registry[id]?.tier === 'tor').length; + return buildDCSummary(remainingIds, estimateNetworkSlots(remainingTors, dcTier), registry); +} + +function computeRackBandwidth(tor: NetworkSwitch, registry: Record): number { + if (tor.status !== 'healthy') return 0; + + let minBW = tor.totalUplinks > 0 ? tor.activeUplinks / tor.totalUplinks : 1; + if (minBW === 0) return 0; + + const visited = new Set(); + let current = tor.uplinkIds.filter(id => { + const sw = registry[id]; + return sw && sw.status === 'healthy'; + }); + + while (current.length > 0) { + let tierBW = 1; + const next: string[] = []; + for (const sid of current) { + if (visited.has(sid)) continue; + visited.add(sid); + const sw = registry[sid]; + if (!sw || sw.status !== 'healthy') continue; + const bw = sw.totalUplinks > 0 ? sw.activeUplinks / sw.totalUplinks : 1; + tierBW = Math.min(tierBW, bw); + for (const upId of sw.uplinkIds) { + if (registry[upId]?.status === 'healthy') next.push(upId); + } + } + minBW = Math.min(minBW, tierBW); + if (minBW === 0) return 0; + current = next; + } + + return minBW; +} + +function buildDCSummary( + switchIds: string[], + networkRackCount: number, + registry: Record, +): DCNetworkSummary { + const totalByTier: Partial> = {}; + const healthyByTier: Partial> = {}; + let disconnected = 0; + let degraded = 0; + let bwSum = 0; + let torCount = 0; + + for (const sid of switchIds) { + const sw = registry[sid]; + if (!sw) continue; + totalByTier[sw.tier] = (totalByTier[sw.tier] ?? 0) + 1; + if (sw.status === 'healthy') healthyByTier[sw.tier] = (healthyByTier[sw.tier] ?? 0) + 1; + if (sw.tier === 'tor') { + torCount++; + const bw = computeRackBandwidth(sw, registry); + bwSum += bw; + if (bw === 0) disconnected++; + else if (bw < 1) degraded++; + } + } + + const avgBW = torCount > 0 ? bwSum / torCount : 1; + return { + switchIds, networkRackCount, totalByTier, healthyByTier, + racksDisconnected: disconnected, racksDegraded: degraded, + averageBandwidth: avgBW, effectiveFlopsFraction: avgBW, + }; +} + +// --- Network Tick (failure rolls + repair) --- + +function processNetworkTick( + registry: Record, networkResearchBonus: number, -): { networkHealth: NetworkHealthState; racksDisconnected: number } { - if (computeRacksOnline <= 0) { - return { networkHealth: nh, racksDisconnected: 0 }; + opsEff: number, + repairSpeedBonus: number, + hotStandbyTicks: number, + redundancyBonus: number, +): { switchRepairCosts: number; notifications: TickNotification[]; dirty: boolean } { + const notifications: TickNotification[] = []; + let switchRepairCosts = 0; + let dirty = false; + + const healthyByTier: Partial> = {}; + const repairing: NetworkSwitch[] = []; + const failed: NetworkSwitch[] = []; + + for (const sw of Object.values(registry)) { + if (sw.status === 'healthy') { + (healthyByTier[sw.tier] ??= []).push(sw); + } else if (sw.status === 'repairing') { + repairing.push(sw); + } else if (sw.status === 'failed') { + failed.push(sw); + } } - let racksDisconnected = 0; + const tiers: SwitchTier[] = ['tor', 't1', 't2', 't3', 't4', 't5']; + const newlyFailed: NetworkSwitch[] = []; - const t1Rate = NETWORK_TOPOLOGY.tier1FailureRate * (1 - networkResearchBonus); - const t1Failures = binomialSample(nh.tier1Required, t1Rate); - const tier1Healthy = nh.tier1Required - t1Failures; - racksDisconnected += t1Failures * NETWORK_TOPOLOGY.tier1BlastRadius; - - const t2Rate = NETWORK_TOPOLOGY.tier2FailureRate * (1 - networkResearchBonus); - const t2Failures = binomialSample(nh.tier2Required, t2Rate); - const tier2Healthy = nh.tier2Required - t2Failures; - racksDisconnected += t2Failures * NETWORK_TOPOLOGY.tier1BlastRadius * NETWORK_TOPOLOGY.tier2BlastRadiusMultiplier; - - const t3Rate = NETWORK_TOPOLOGY.tier3FailureRate * (1 - networkResearchBonus); - const t3Failures = binomialSample(nh.tier3Required, t3Rate); - const tier3Healthy = nh.tier3Required - t3Failures; - if (t3Failures > 0) { - racksDisconnected = computeRacksOnline; + for (const tier of tiers) { + const healthy = healthyByTier[tier]; + if (!healthy || healthy.length === 0) continue; + const rate = SWITCH_TIER_CONFIGS[tier].failureRatePerTick * (1 - networkResearchBonus); + const count = binomialSample(healthy.length, rate); + if (count > 0) { + const shuffled = [...healthy].sort(() => Math.random() - 0.5); + for (let i = 0; i < count; i++) { + const sw = shuffled[i]; + const baseRepair = SWITCH_TIER_CONFIGS[tier].repairBaseTicks; + const repairTime = hotStandbyTicks > 0 + ? hotStandbyTicks + : baseRepair * (1 - repairSpeedBonus); + sw.status = 'repairing'; + sw.repairProgress = 0; + sw.repairTotal = repairTime; + newlyFailed.push(sw); + switchRepairCosts += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION; + } + dirty = true; + } } - racksDisconnected = Math.min(racksDisconnected, computeRacksOnline); + for (const sw of repairing) { + sw.repairProgress += 1 + opsEff * 0.05; + if (sw.repairProgress >= sw.repairTotal) { + sw.status = 'healthy'; + sw.repairProgress = 0; + sw.repairTotal = 0; + dirty = true; + } + } - return { - networkHealth: { - ...nh, - tier1Healthy, - tier2Healthy, - tier3Healthy, - racksDisconnected, - }, - racksDisconnected, - }; + if (dirty) { + for (const sw of Object.values(registry)) { + if (sw.uplinkIds.length === 0) continue; + let active = 0; + for (const upId of sw.uplinkIds) { + if (registry[upId]?.status === 'healthy') active++; + } + sw.activeUplinks = active; + sw.effectiveBandwidth = sw.totalUplinks > 0 ? Math.min(1, (active + redundancyBonus) / sw.totalUplinks) : 1; + } + } + + for (const sw of newlyFailed) { + if (sw.tier === 't3') { + notifications.push({ title: 'Core Network Failure', message: `Tier-3 core switch failed — potential DC disconnect!`, type: 'danger' }); + } else if (sw.tier === 't4') { + notifications.push({ title: 'Campus Network Failure', message: `Tier-4 campus switch failed — cross-DC degradation!`, type: 'danger' }); + } else if (sw.tier === 't2') { + notifications.push({ title: 'Network Switch Failure', message: `Tier-2 spine switch failed — racks may be degraded.`, type: 'warning' }); + } + } + + return { switchRepairCosts, notifications, dirty }; } +// --- Main Infrastructure Tick --- + export function processInfrastructure(state: GameState): InfraTickResult { const notifications: TickNotification[] = []; let repairCosts = 0; @@ -142,6 +447,20 @@ export function processInfrastructure(state: GameState): InfraTickResult { const netResearch1 = state.research.completedResearch.includes('network-engineering-i') ? 0.4 : 0; const netResearch2 = state.research.completedResearch.includes('network-engineering-ii') ? 0.5 : 0; const networkResearchBonus = Math.min(0.8, netResearch1 + netResearch2); + const repairSpeedBonus = state.research.completedResearch.includes('network-fast-repair') ? 0.4 : 0; + const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0; + const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0; + + // Clone switch registry for mutable operations this tick + const registry: Record = {}; + for (const [id, sw] of Object.entries(state.infrastructure.switchRegistry)) { + registry[id] = { ...sw, uplinkIds: [...sw.uplinkIds], downlinkIds: [...sw.downlinkIds] }; + } + + // Process network failures/repairs globally + const netResult = processNetworkTick(registry, networkResearchBonus, opsEff, repairSpeedBonus, hotStandbyTicks, redundancyBonus); + repairCosts += netResult.switchRepairCosts; + notifications.push(...netResult.notifications); let totalFlops = 0; let totalUptime = 0; @@ -149,9 +468,10 @@ export function processInfrastructure(state: GameState): InfraTickResult { let totalComputeRackCount = 0; let totalDataCenterCount = 0; let dcWithRacks = 0; + let globalLatencyPenalty = 0; + let latencyDCCount = 0; const clusters: Cluster[] = state.infrastructure.clusters.map(cluster => { - // Advance cluster construction if (cluster.status === 'constructing') { const newProgress = cluster.constructionProgress + 1; if (newProgress >= cluster.constructionTotal) { @@ -160,36 +480,26 @@ export function processInfrastructure(state: GameState): InfraTickResult { message: `${cluster.name} cluster in ${LOCATION_CONFIGS[cluster.locationId].name} is now operational!`, type: 'success', }); - return { ...cluster, constructionProgress: cluster.constructionTotal, status: 'operational' as const, campuses: cluster.campuses }; + return { ...cluster, constructionProgress: cluster.constructionTotal, status: 'operational' as const }; } return { ...cluster, constructionProgress: newProgress }; } const campuses: Campus[] = cluster.campuses.map(campus => { - // Advance campus construction if (campus.status === 'constructing') { const newProgress = campus.constructionProgress + 1; if (newProgress >= campus.constructionTotal) { - notifications.push({ - title: 'Campus Ready', - message: `Campus ${campus.name} is now operational!`, - type: 'success', - }); - return { ...campus, constructionProgress: campus.constructionTotal, status: 'operational' as const, dataCenters: campus.dataCenters }; + notifications.push({ title: 'Campus Ready', message: `Campus ${campus.name} is now operational!`, type: 'success' }); + return { ...campus, constructionProgress: campus.constructionTotal, status: 'operational' as const }; } return { ...campus, constructionProgress: newProgress }; } const dataCenters: DataCenter[] = campus.dataCenters.map(dc => { - // Advance DC construction if (dc.status === 'constructing') { const newProgress = dc.constructionProgress + 1; if (newProgress >= dc.constructionTotal) { - notifications.push({ - title: 'Data Center Online', - message: `${dc.name} is now operational!`, - type: 'success', - }); + notifications.push({ title: 'Data Center Online', message: `${dc.name} is now operational!`, type: 'success' }); return { ...dc, constructionProgress: dc.constructionTotal, status: 'operational' as const }; } return { ...dc, constructionProgress: newProgress }; @@ -205,8 +515,9 @@ export function processInfrastructure(state: GameState): InfraTickResult { if (rs.progress >= rs.total) { if (rs.phase === 'decommissioning') { - const installSku = RACK_SKU_CONFIGS[rs.toSkuId]; const installTotal = cohortStageTotal('installation', rs.toSkuId, rs.racksRemaining); + // Clear DC topology on retrofit + for (const sid of dc.networkSummary.switchIds) delete registry[sid]; return { ...dc, computeRacksOnline: 0, @@ -221,31 +532,16 @@ export function processInfrastructure(state: GameState): InfraTickResult { stageTotal: installTotal, repairCount: 0, }], - retrofitState: { - ...rs, - phase: 'installing' as const, - progress: 0, - total: installTotal, - }, - networkHealth: computeNetworkHealth(0), + retrofitState: { ...rs, phase: 'installing' as const, progress: 0, total: installTotal }, + networkSummary: emptyDCNetworkSummary(), effectiveComputeRacks: 0, - usedSlots: 0, - usedPowerKW: 0, - currentUptime: 0, + usedSlots: 0, usedPowerKW: 0, currentUptime: 0, energyCostPerTick: DC_TIER_CONFIGS[dc.tier].baseEnergyCostPerTick * LOCATION_CONFIGS[cluster.locationId].energyCostMultiplier, maintenanceCostPerTick: 0, }; } else { - notifications.push({ - title: 'Retrofit Complete', - message: `${dc.name} retrofit to ${RACK_SKU_CONFIGS[rs.toSkuId].name} is complete!`, - type: 'success', - }); - return { - ...dc, - status: 'operational' as const, - retrofitState: null, - }; + notifications.push({ title: 'Retrofit Complete', message: `${dc.name} retrofit to ${RACK_SKU_CONFIGS[rs.toSkuId].name} is complete!`, type: 'success' }); + return { ...dc, status: 'operational' as const, retrofitState: null }; } } return { ...dc, retrofitState: rs }; @@ -254,9 +550,14 @@ export function processInfrastructure(state: GameState): InfraTickResult { // Process deployment cohorts const updatedCohorts: DeploymentCohort[] = []; let racksJustOnlined = 0; - let racksFailedTesting = 0; for (const cohort of dc.deploymentCohorts) { + // network-down cohorts don't progress via speed — handled separately below + if (cohort.stage === 'network-down') { + updatedCohorts.push(cohort); + continue; + } + const speed = stageSpeed(cohort.stage, engEff, opsEff); const newProgress = cohort.stageProgress + speed; @@ -265,18 +566,11 @@ export function processInfrastructure(state: GameState): InfraTickResult { continue; } - if (cohort.stage === 'decommission') { - continue; - } + if (cohort.stage === 'decommission') continue; if (cohort.stage === 'repair') { const testTotal = cohortStageTotal('testing', cohort.skuId, cohort.count); - updatedCohorts.push({ - ...cohort, - stage: 'testing', - stageProgress: 0, - stageTotal: testTotal, - }); + updatedCohorts.push({ ...cohort, stage: 'testing', stageProgress: 0, stageTotal: testTotal }); continue; } @@ -291,94 +585,91 @@ export function processInfrastructure(state: GameState): InfraTickResult { const failed = binomialSample(cohort.count, effectiveFailRate); const passed = cohort.count - failed; - racksJustOnlined += passed; if (failed > 0) { - racksFailedTesting += failed; const repairCost = sku.baseCost * sku.repairCostFraction * failed; dcRepairCosts += repairCost; - updatedCohorts.push({ id: `repair-${cohort.id}`, - count: failed, - skuId: cohort.skuId, - stage: 'repair', - stageProgress: 0, + count: failed, skuId: cohort.skuId, + stage: 'repair', stageProgress: 0, stageTotal: cohortStageTotal('repair', cohort.skuId, failed), repairCount: cohort.repairCount + 1, }); } } else { const total = cohortStageTotal(next, cohort.skuId, cohort.count); - updatedCohorts.push({ - ...cohort, - stage: next, - stageProgress: 0, - stageTotal: total, - }); + updatedCohorts.push({ ...cohort, stage: next, stageProgress: 0, stageTotal: total }); } } computeRacksOnline += racksJustOnlined; + // Expand topology for newly onlined racks + let networkSummary = dc.networkSummary; + if (racksJustOnlined > 0) { + if (networkSummary.switchIds.length === 0) { + networkSummary = buildDCTopology(computeRacksOnline, dc.tier, dc.id, registry); + } else { + networkSummary = expandDCTopology(networkSummary, racksJustOnlined, dc.tier, dc.id, registry); + } + } - - - // Production failures (statistical) + // Production failures if (computeRacksOnline > 0 && dc.rackSkuId) { const sku = RACK_SKU_CONFIGS[dc.rackSkuId]; const effectiveRate = sku.productionFailureRate * (1 - dc.coolingLevel * COOLING_FAILURE_REDUCTION) * (1 - dc.redundancyLevel * REDUNDANCY_FAILURE_REDUCTION); - const prodFailures = binomialSample(computeRacksOnline, effectiveRate); if (prodFailures > 0) { computeRacksOnline -= prodFailures; - const repairCost = sku.baseCost * sku.repairCostFraction * prodFailures; - dcRepairCosts += repairCost; - + dcRepairCosts += sku.baseCost * sku.repairCostFraction * prodFailures; updatedCohorts.push({ id: `prodfail-${dc.id}-${Date.now()}`, - count: prodFailures, - skuId: dc.rackSkuId, - stage: 'repair', - stageProgress: 0, + count: prodFailures, skuId: dc.rackSkuId, + stage: 'repair', stageProgress: 0, stageTotal: cohortStageTotal('repair', dc.rackSkuId, prodFailures), repairCount: 0, }); - - + networkSummary = shrinkDCTopology(networkSummary, prodFailures, dc.tier, registry); } } repairCosts += dcRepairCosts; - // Network health - const baseNetworkHealth = computeNetworkHealth(computeRacksOnline); - const { networkHealth, racksDisconnected } = processNetworkFailures( - baseNetworkHealth, computeRacksOnline, networkResearchBonus, - ); - - if (racksDisconnected > 0) { - if (networkHealth.tier3Healthy < networkHealth.tier3Required) { - notifications.push({ - title: 'Core Network Failure', - message: `${dc.name}: Tier-3 core switch failure — entire DC disconnected!`, - type: 'danger', - }); - } else if (racksDisconnected >= NETWORK_TOPOLOGY.tier1BlastRadius * NETWORK_TOPOLOGY.tier2BlastRadiusMultiplier) { - notifications.push({ - title: 'Network Switch Failure', - message: `${dc.name}: Tier-2 aggregation failure — ${racksDisconnected} racks disconnected.`, - type: 'warning', - }); - } + // Recompute DC network summary after failures/repairs + if (netResult.dirty && networkSummary.switchIds.length > 0) { + networkSummary = buildDCSummary( + networkSummary.switchIds, networkSummary.networkRackCount, registry, + ); } - const effectiveComputeRacks = computeRacksOnline - racksDisconnected; + // Rackdown: detect recovery (previously disconnected racks now have connectivity) + const prevDisconnected = dc.networkSummary.racksDisconnected; + const currDisconnected = networkSummary.racksDisconnected; - // Compute aggregates for this DC + if (currDisconnected < prevDisconnected && dc.rackSkuId) { + const recovered = prevDisconnected - currDisconnected; + computeRacksOnline -= recovered; + networkSummary = shrinkDCTopology(networkSummary, recovered, dc.tier, registry); + updatedCohorts.push({ + id: `netrecovery-${dc.id}-${Date.now()}`, + count: recovered, skuId: dc.rackSkuId, + stage: 'testing', stageProgress: 0, + stageTotal: cohortStageTotal('testing', dc.rackSkuId, recovered), + repairCount: 0, + }); + // Recompute summary after shrink + networkSummary = buildDCSummary( + networkSummary.switchIds, networkSummary.networkRackCount, registry, + ); + } + + // Compute DC aggregates + const effectiveComputeRacks = Math.max(0, + computeRacksOnline - networkSummary.racksDisconnected); const location = LOCATION_CONFIGS[cluster.locationId]; const tierConfig = DC_TIER_CONFIGS[dc.tier]; const pipelineRacks = updatedCohorts @@ -388,7 +679,7 @@ export function processInfrastructure(state: GameState): InfraTickResult { .filter(c => c.stage === 'repair') .reduce((sum, c) => sum + c.count, 0); const totalRacksInDc = computeRacksOnline + pipelineRacks; - const netSlots = networkSlotsRequired(computeRacksOnline + pipelineRacks); + const netSlots = networkSummary.networkRackCount; const usedSlots = computeRacksOnline + pipelineRacks + netSlots; let usedPowerKW = 0; @@ -396,36 +687,33 @@ export function processInfrastructure(state: GameState): InfraTickResult { if (dc.rackSkuId && computeRacksOnline > 0) { const sku = RACK_SKU_CONFIGS[dc.rackSkuId]; usedPowerKW = computeRacksOnline * sku.powerDrawKW; - dcFlops = effectiveComputeRacks * sku.flopsPerRack; + dcFlops = effectiveComputeRacks * sku.flopsPerRack * networkSummary.effectiveFlopsFraction; } const energyCostPerTick = (tierConfig.baseEnergyCostPerTick + usedPowerKW * BASE_ENERGY_COST_PER_FLOP) * location.energyCostMultiplier; const maintenanceCostPerTick = totalRacksInDc * BASE_MAINTENANCE_PER_RACK; - const currentUptime = totalRacksInDc > 0 ? effectiveComputeRacks / totalRacksInDc : 1; + // Latency penalty from bandwidth degradation + if (networkSummary.averageBandwidth < 1 && computeRacksOnline > 0) { + const penalty = (1 - networkSummary.averageBandwidth) * NETWORK_DEGRADATION.bandwidthToLatencyPenalty; + globalLatencyPenalty += penalty; + latencyDCCount++; + } + totalFlops += dcFlops; totalRackCount += totalRacksInDc + netSlots; totalComputeRackCount += totalRacksInDc; totalDataCenterCount++; - if (totalRacksInDc > 0) { - totalUptime += currentUptime; - dcWithRacks++; - } + if (totalRacksInDc > 0) { totalUptime += currentUptime; dcWithRacks++; } return { ...dc, - computeRacksOnline, - computeRacksFailed, + computeRacksOnline, computeRacksFailed, deploymentCohorts: updatedCohorts, - networkHealth, - effectiveComputeRacks, - usedSlots, - usedPowerKW, - energyCostPerTick, - maintenanceCostPerTick, - currentUptime, + networkSummary, effectiveComputeRacks, + usedSlots, usedPowerKW, energyCostPerTick, maintenanceCostPerTick, currentUptime, }; }); @@ -436,22 +724,16 @@ export function processInfrastructure(state: GameState): InfraTickResult { 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), - ]; + 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); @@ -461,31 +743,28 @@ export function processInfrastructure(state: GameState): InfraTickResult { 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)); - + // Clear topology on retrofit start + for (const sid of dc.networkSummary.switchIds) delete registry[sid]; return { ...dc, status: 'retrofitting' as const, deploymentCohorts: [], + networkSummary: emptyDCNetworkSummary(), retrofitState: { fromSkuId: dc.rackSkuId as RackSkuId, toSkuId: updatedQueue!.targetSkuId, phase: 'decommissioning' as const, - progress: 0, - total: decommTicks, - racksRemaining: totalRacks, + 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', @@ -502,14 +781,18 @@ export function processInfrastructure(state: GameState): InfraTickResult { return { ...cluster, campuses }; }); + const avgLatencyPenalty = latencyDCCount > 0 ? globalLatencyPenalty / latencyDCCount : 0; + return { infrastructure: { clusters, + switchRegistry: registry, totalFlops, totalUptime: dcWithRacks > 0 ? totalUptime / dcWithRacks : 1, totalRackCount, totalComputeRackCount, totalDataCenterCount, + networkLatencyPenalty: avgLatencyPenalty, }, notifications, repairCosts, diff --git a/packages/game-engine/src/systems/marketSystem.ts b/packages/game-engine/src/systems/marketSystem.ts index 87322f6..c337bb7 100644 --- a/packages/game-engine/src/systems/marketSystem.ts +++ b/packages/game-engine/src/systems/marketSystem.ts @@ -8,6 +8,7 @@ import { OPEN_SOURCE_REVENUE_PENALTY, OPEN_SOURCE_TALENT_ATTRACTION, MARKET_SIZE_CAP, + NETWORK_DEGRADATION, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS, OVERLOAD_PENALTY_EXPONENT, @@ -83,8 +84,10 @@ export function processMarket(state: GameState, currentTickCapacity: number): Ma overloadPenalty = Math.min(1, Math.pow(demandCapacityRatio - 1, OVERLOAD_PENALTY_EXPONENT)); } + const networkLatencyPenalty = state.infrastructure.networkLatencyPenalty * + NETWORK_DEGRADATION.satisfactionPenaltyPerLatency; consumers.satisfaction = Math.min(1, Math.max(0, - 0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty, + 0.3 + modelQuality * 0.5 + headroomBonus - overloadPenalty - networkLatencyPenalty, )); consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0; diff --git a/packages/shared/src/constants/gameBalance.ts b/packages/shared/src/constants/gameBalance.ts index 1e4b524..14a7496 100644 --- a/packages/shared/src/constants/gameBalance.ts +++ b/packages/shared/src/constants/gameBalance.ts @@ -1,4 +1,4 @@ -import type { DCTier, DCTierConfig, RackSkuId, RackSkuConfig, NetworkTopologyConfig, CampusTierCost, ClusterCostConfig } from '../types/infrastructure'; +import type { DCTier, DCTierConfig, RackSkuId, RackSkuConfig, SwitchTier, SwitchTierConfig, CampusTierCost, ClusterCostConfig } from '../types/infrastructure'; export const TICK_INTERVAL_MS = 1000; export const MAX_OFFLINE_TICKS = 86_400; @@ -123,19 +123,92 @@ export const CLUSTER_COST_CONFIG: ClusterCostConfig = { buildTimeTicks: 600, }; -// --- Network Topology --- +// --- Network Topology (6-Tier Clos) --- -export const NETWORK_TOPOLOGY: NetworkTopologyConfig = { - tier1PerCompute: 24, - tier2PerTier1: 6, - tier3PerDC: 2, - tier1FailureRate: 0.0001, - tier2FailureRate: 0.00005, - tier3FailureRate: 0.00002, - tier1BlastRadius: 24, - tier2BlastRadiusMultiplier: 6, +export const SWITCH_TIER_CONFIGS: Record = { + tor: { + tier: 'tor', name: 'Top-of-Rack', + baseCost: 0, uplinkCount: 2, fanOut: 1, + failureRatePerTick: 0.00005, repairBaseTicks: 15, + switchesPerNetworkRack: 0, powerDrawKW: 0, + }, + t1: { + tier: 't1', name: 'Tier-1 Aggregation', + baseCost: 8_000, uplinkCount: 2, fanOut: 24, + failureRatePerTick: 0.0001, repairBaseTicks: 25, + switchesPerNetworkRack: 4, powerDrawKW: 0.5, + }, + t2: { + tier: 't2', name: 'Tier-2 Spine', + baseCost: 25_000, uplinkCount: 4, fanOut: 6, + failureRatePerTick: 0.00008, repairBaseTicks: 40, + switchesPerNetworkRack: 2, powerDrawKW: 1.0, + }, + t3: { + tier: 't3', name: 'Tier-3 Core', + baseCost: 80_000, uplinkCount: 4, fanOut: 0, + failureRatePerTick: 0.00004, repairBaseTicks: 60, + switchesPerNetworkRack: 1, powerDrawKW: 2.0, + }, + t4: { + tier: 't4', name: 'Tier-4 Campus', + baseCost: 200_000, uplinkCount: 4, fanOut: 0, + failureRatePerTick: 0.00002, repairBaseTicks: 90, + switchesPerNetworkRack: 0, powerDrawKW: 3.0, + }, + t5: { + tier: 't5', name: 'Tier-5 Cluster', + baseCost: 500_000, uplinkCount: 0, fanOut: 0, + failureRatePerTick: 0.00001, repairBaseTicks: 120, + switchesPerNetworkRack: 0, powerDrawKW: 5.0, + }, }; +export const NETWORK_RACK_COST = 5_000; + +export const T3_COUNT_PER_DC_TIER: Record = { + small: 2, medium: 2, large: 3, mega: 4, +}; + +export const T4_COUNT_PER_CAMPUS: Record = { + small: 2, medium: 2, large: 3, mega: 4, +}; + +export const T5_COUNT_PER_CLUSTER = 2; + +export const NETWORK_DEGRADATION = { + bandwidthToLatencyPenalty: 0.3, + satisfactionPenaltyPerLatency: 0.05, +}; + +export const SWITCH_REPAIR_COST_FRACTION = 0.3; + +export function estimateNetworkSlots(computeRacks: number, dcTier: DCTier): number { + if (computeRacks <= 0) return 0; + const t1Count = Math.ceil(computeRacks / SWITCH_TIER_CONFIGS.t1.fanOut); + const t2Count = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t2.fanOut); + const t3Count = T3_COUNT_PER_DC_TIER[dcTier]; + const t1Racks = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t1.switchesPerNetworkRack); + const t2Racks = Math.ceil(t2Count / SWITCH_TIER_CONFIGS.t2.switchesPerNetworkRack); + const t3Racks = Math.ceil(t3Count / SWITCH_TIER_CONFIGS.t3.switchesPerNetworkRack); + return t1Racks + t2Racks + t3Racks; +} + +export function maxComputeRacks(totalSlots: number, dcTier: DCTier): number { + if (totalSlots <= 2) return 0; + let lo = 0; + let hi = totalSlots; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (mid + estimateNetworkSlots(mid, dcTier) <= totalSlots) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; +} + // --- Rack SKU Configs --- export const RACK_SKU_CONFIGS: Record = { diff --git a/packages/shared/src/types/gameState.ts b/packages/shared/src/types/gameState.ts index 7e3017b..3c32416 100644 --- a/packages/shared/src/types/gameState.ts +++ b/packages/shared/src/types/gameState.ts @@ -58,4 +58,4 @@ export const INITIAL_SETTINGS: GameSettings = { sfxVolume: 0.7, }; -export const SAVE_VERSION = 3; +export const SAVE_VERSION = 4; diff --git a/packages/shared/src/types/infrastructure.ts b/packages/shared/src/types/infrastructure.ts index 2120fb5..8b5bfec 100644 --- a/packages/shared/src/types/infrastructure.ts +++ b/packages/shared/src/types/infrastructure.ts @@ -12,6 +12,7 @@ export interface Cluster { status: ClusterStatus; constructionProgress: number; constructionTotal: number; + networkSummary: ClusterNetworkSummary; } // --- Campus (holds same-tier DCs) --- @@ -37,6 +38,7 @@ export interface Campus { constructionProgress: number; constructionTotal: number; retrofitQueue: CampusRetrofitQueue | null; + networkSummary: CampusNetworkSummary; } // --- Data Center --- @@ -68,7 +70,7 @@ export interface DataCenter { rackSkuId: RackSkuId | null; computeRacksOnline: number; computeRacksFailed: number; - networkHealth: NetworkHealthState; + networkSummary: DCNetworkSummary; deploymentCohorts: DeploymentCohort[]; retrofitState: RetrofitState | null; coolingLevel: number; @@ -81,50 +83,62 @@ export interface DataCenter { currentUptime: number; } -// --- Network Topology --- +// --- Network Topology (6-Tier Clos) --- -export interface NetworkHealthState { - tier1Required: number; - tier1Healthy: number; - tier2Required: number; - tier2Healthy: number; - tier3Required: number; - tier3Healthy: number; +export type SwitchTier = 'tor' | 't1' | 't2' | 't3' | 't4' | 't5'; +export type SwitchStatus = 'healthy' | 'failed' | 'repairing'; + +export interface NetworkSwitch { + id: string; + tier: SwitchTier; + status: SwitchStatus; + dcId: string | null; + campusId: string | null; + clusterId: string | null; + uplinkIds: string[]; + downlinkIds: string[]; + activeUplinks: number; + totalUplinks: number; + effectiveBandwidth: number; + repairProgress: number; + repairTotal: number; +} + +export interface SwitchTierConfig { + tier: SwitchTier; + name: string; + baseCost: number; + uplinkCount: number; + fanOut: number; + failureRatePerTick: number; + repairBaseTicks: number; + switchesPerNetworkRack: number; + powerDrawKW: number; +} + +export interface DCNetworkSummary { + switchIds: string[]; + networkRackCount: number; + totalByTier: Partial>; + healthyByTier: Partial>; racksDisconnected: number; + racksDegraded: number; + averageBandwidth: number; + effectiveFlopsFraction: number; } -export interface NetworkTopologyConfig { - tier1PerCompute: number; - tier2PerTier1: number; - tier3PerDC: number; - tier1FailureRate: number; - tier2FailureRate: number; - tier3FailureRate: number; - tier1BlastRadius: number; - tier2BlastRadiusMultiplier: number; +export interface CampusNetworkSummary { + switchIds: string[]; + totalT4: number; + healthyT4: number; + crossDCBandwidth: number; } -export function networkSlotsRequired(computeRacks: number): number { - if (computeRacks <= 0) return 0; - const tier1 = Math.ceil(computeRacks / 24); - const tier2 = Math.ceil(tier1 / 6); - const tier3 = 2; - return tier1 + tier2 + tier3; -} - -export function maxComputeRacks(totalSlots: number): number { - if (totalSlots <= 2) return 0; - let lo = 0; - let hi = totalSlots; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (mid + networkSlotsRequired(mid) <= totalSlots) { - lo = mid; - } else { - hi = mid - 1; - } - } - return lo; +export interface ClusterNetworkSummary { + switchIds: string[]; + totalT5: number; + healthyT5: number; + crossCampusBandwidth: number; } // --- Racks --- @@ -137,7 +151,7 @@ export type RackSkuId = export type PipelineStage = | 'ordered' | 'manufacturing' | 'receiving' - | 'installation' | 'testing' | 'repair' | 'decommission'; + | 'installation' | 'testing' | 'repair' | 'network-down' | 'decommission'; export interface PipelineTimings { manufacturing: number; @@ -202,20 +216,24 @@ export interface ClusterCostConfig { export interface InfrastructureState { clusters: Cluster[]; + switchRegistry: Record; totalFlops: number; totalUptime: number; totalRackCount: number; totalComputeRackCount: number; totalDataCenterCount: number; + networkLatencyPenalty: number; } export const INITIAL_INFRASTRUCTURE: InfrastructureState = { clusters: [], + switchRegistry: {}, totalFlops: 0, totalUptime: 1, totalRackCount: 0, totalComputeRackCount: 0, totalDataCenterCount: 0, + networkLatencyPenalty: 0, }; // --- Locations ---