namespace SpaceGame.Api.Industry.Planning; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; internal static class FactionIndustryPlanner { private const float CommodityTargetLevelSeconds = 240f; private const float WaterTargetLevelSeconds = 300f; internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId, bool ignoreActiveExpansionProject = false) { if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId); var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity); if (moduleId is null) { return null; } var targetAnchor = SelectFoundationAnchor(world, factionId, bottleneckCommodity); if (targetAnchor is null) { return null; } var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( bottleneckCommodity, moduleId, targetAnchor.SystemId, targetAnchor.Id, supportStation.Id); } internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } const string shipyardModuleId = "module_gen_build_l_01"; if (world.Stations.Any(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && station.InstalledModules.Contains(shipyardModuleId, StringComparer.Ordinal))) { return null; } if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe)) { return null; } var bottleneckCommodity = shipyardRecipe.Inputs .Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId)) .Distinct(StringComparer.Ordinal) .Select(itemId => new { ItemId = itemId, Commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId), }) .Where(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f || CommodityOperationalSignal.IsStrained(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) .OrderByDescending(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f ? 1 : 0) .ThenByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) .ThenBy(entry => entry.Commodity.AvailableStock) .Select(entry => entry.ItemId) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) { return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity, ignoreActiveExpansionProject); } return CreateShipyardFoundationProject(world, factionId, ignoreActiveExpansionProject); } internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { const string shipyardModuleId = "module_gen_build_l_01"; if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) { return null; } var targetAnchor = SelectLogisticsFoundationAnchor(world, factionId); if (targetAnchor is null) { return null; } var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetAnchor.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( "shipyard", shipyardModuleId, targetAnchor.SystemId, targetAnchor.Id, supportStation.Id); } internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId) { if (HasActiveExpansionProject(world, factionId)) { return null; } var bootstrapCommodity = SelectBootstrapCommodity(world, factionId); if (bootstrapCommodity is not null) { var bootstrapModuleId = world.ProductionGraph.GetPrimaryProducerModule(bootstrapCommodity); if (bootstrapModuleId is null) { return null; } var bootstrapAnchor = SelectFoundationAnchor(world, factionId, bootstrapCommodity); if (bootstrapAnchor is null) { return null; } var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapAnchor.SystemId); if (bootstrapSupportStation is null) { return null; } return new IndustryExpansionProject( bootstrapCommodity, bootstrapModuleId, bootstrapAnchor.SystemId, bootstrapAnchor.Id, bootstrapSupportStation.Id); } var commodityId = SelectCommodityToExpand(world, factionId); if (commodityId is null) { return null; } var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId); if (moduleId is null) { return null; } var targetAnchor = SelectFoundationAnchor(world, factionId, commodityId); if (targetAnchor is null) { return null; } var supportStation = SelectSupportStation(world, factionId, moduleId, targetAnchor.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( commodityId, moduleId, targetAnchor.SystemId, targetAnchor.Id, supportStation.Id); } internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId) { var site = world.ConstructionSites.FirstOrDefault(candidate => string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal) && string.Equals(candidate.TargetKind, "station-foundation", StringComparison.Ordinal) && candidate.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); if (site is null || site.BlueprintId is null) { return null; } var supportStationId = world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) .ThenByDescending(station => station.Inventory.Values.Sum()) .Select(station => station.Id) .FirstOrDefault(); if (supportStationId is null) { return null; } return new IndustryExpansionProject( site.TargetDefinitionId, site.BlueprintId, site.SystemId, site.AnchorId, supportStationId, site.Id); } internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) { if (project.SiteId is not null) { return; } if (!CanEstablishExpansionSite(world, factionId, project)) { return; } var nowUtc = DateTimeOffset.UtcNow; var claimId = $"claim-{factionId}-{project.AnchorId}"; if (world.Claims.All(candidate => candidate.Id != claimId)) { world.Claims.Add(new ClaimRuntime { Id = claimId, FactionId = factionId, SystemId = project.SystemId, AnchorId = project.AnchorId, PlacedAtUtc = nowUtc, ActivatesAtUtc = nowUtc.AddSeconds(8), State = ClaimStateKinds.Activating, Health = 100f, }); } if (!world.ModuleRecipes.TryGetValue(project.ModuleId, out var recipe)) { return; } var siteId = $"site-{factionId}-{project.AnchorId}"; if (world.ConstructionSites.Any(candidate => candidate.Id == siteId)) { return; } var site = new ConstructionSiteRuntime { Id = siteId, FactionId = factionId, SystemId = project.SystemId, AnchorId = project.AnchorId, TargetKind = "station-foundation", TargetDefinitionId = project.CommodityId, BlueprintId = project.ModuleId, ClaimId = claimId, StationId = null, State = ConstructionSiteStateKinds.Planned, }; foreach (var input in recipe.Inputs) { site.RequiredItems[input.ItemId] = input.Amount; site.DeliveredItems[input.ItemId] = 0f; var orderId = $"market-order-{site.Id}-{input.ItemId}"; site.MarketOrderIds.Add(orderId); world.MarketOrders.Add(new MarketOrderRuntime { Id = orderId, FactionId = factionId, StationId = project.SupportStationId, ConstructionSiteId = site.Id, Kind = MarketOrderKinds.Buy, ItemId = input.ItemId, Amount = input.Amount, RemainingAmount = input.Amount, Valuation = 1.1f, State = MarketOrderStateKinds.Open, }); } if (world.Stations.FirstOrDefault(station => station.Id == project.SupportStationId) is { } supportStation) { foreach (var orderId in site.MarketOrderIds) { supportStation.MarketOrderIds.Add(orderId); } } world.ConstructionSites.Add(site); } private static string? SelectCommodityToExpand(SimulationWorld world, string factionId) { var demandByItem = world.MarketOrders .Where(order => string.Equals(order.FactionId, factionId, StringComparison.Ordinal) && string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal) && order.RemainingAmount > 0.01f) .GroupBy(order => order.ItemId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal); var threatAssessment = CommanderPlanningService.FindFactionThreatAssessment(world, factionId); if (threatAssessment is not null && threatAssessment.EnemyFactionCount > 0) { demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; } return demandByItem .Select(entry => { var itemId = ResolveBottleneckCommodity(world, factionId, entry.Key); var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); var score = entry.Value + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); return (ItemId: itemId, Score: score); }) .Where(entry => entry.ItemId is not null) .GroupBy(entry => entry.ItemId!, StringComparer.Ordinal) .Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score))) .OrderByDescending(entry => entry.Score) .Select(entry => entry.ItemId) .FirstOrDefault(); } private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId) { if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals")) { return "refinedmetals"; } return null; } private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId) { var visited = new HashSet(StringComparer.Ordinal); return ResolveBottleneckCommodity(world, factionId, itemId, visited); } private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet visited) { if (!visited.Add(itemId)) { return itemId; } var producers = world.ProductionGraph.GetProcessesForOutput(itemId); if (producers.Count == 0) { return itemId; } var hasFactionProducer = producers .SelectMany(process => process.RequiredModuleIds) .Any(moduleId => world.Stations.Any(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && station.InstalledModules.Contains(moduleId, StringComparer.Ordinal))); if (!hasFactionProducer) { return itemId; } var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId) .Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId)) .Select(inputId => { var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)), Stockpile: commodity.AvailableStock); }) .OrderByDescending(entry => entry.Score) .ThenBy(entry => entry.Stockpile) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(weakestUnproducedInput.ItemId) && (weakestUnproducedInput.Score > 0.01f || weakestUnproducedInput.Stockpile < 120f)) { return ResolveBottleneckCommodity(world, factionId, weakestUnproducedInput.ItemId, visited); } var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId) .Select(inputId => { var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId))); }) .OrderByDescending(entry => entry.Score) .FirstOrDefault(); return weakestInput.Score > GetCommodityNeedScore(world, factionId, itemId) * 0.6f ? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited) : itemId; } internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId) => FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f; internal static IReadOnlyCollection ResolveRootResourceItems(SimulationWorld world, string commodityId) { var frontier = new Queue(); var resources = new HashSet(StringComparer.Ordinal); var visited = new HashSet(StringComparer.Ordinal); frontier.Enqueue(commodityId); while (frontier.Count > 0) { var current = frontier.Dequeue(); if (!visited.Add(current)) { continue; } var inputs = world.ProductionGraph.GetImmediateInputs(current); if (inputs.Count == 0) { resources.Add(current); continue; } foreach (var input in inputs) { frontier.Enqueue(input); } } return resources.Count > 0 ? resources : [commodityId]; } private static bool HasActiveExpansionProject(SimulationWorld world, string factionId) => world.ConstructionSites.Any(site => string.Equals(site.FactionId, factionId, StringComparison.Ordinal) && string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal) && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); private static float GetCommodityNeedScore(SimulationWorld world, string factionId, string itemId) { var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); return CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); } private static float GetTargetLevelSeconds(string itemId) => string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; private static AnchorRuntime? SelectFoundationAnchor(SimulationWorld world, string factionId, string commodityId) { var resourceItems = ResolveRootResourceItems(world, commodityId); return world.Anchors .Where(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && anchor.OccupyingStructureId is null && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed) && IsExpansionSystemEligible(world, factionId, anchor.SystemId)) .OrderByDescending(anchor => ScoreAnchor(world, factionId, anchor, resourceItems)) .FirstOrDefault(); } private static AnchorRuntime? SelectLogisticsFoundationAnchor(SimulationWorld world, string factionId) { return world.Anchors .Where(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && anchor.OccupyingStructureId is null && world.Claims.All(claim => claim.AnchorId != anchor.Id || claim.State == ClaimStateKinds.Destroyed) && IsExpansionSystemEligible(world, factionId, anchor.SystemId)) .OrderByDescending(anchor => world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal))) .ThenByDescending(anchor => world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)) .Sum(station => station.Inventory.Values.Sum())) .FirstOrDefault(); } private static float ScoreAnchor(SimulationWorld world, string factionId, AnchorRuntime anchor, IReadOnlyCollection resourceItems) { var resourceScore = world.Nodes .Where(node => node.SystemId == anchor.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal)) .Sum(node => node.OreRemaining); var factionPresence = world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, anchor.SystemId, StringComparison.Ordinal)); var controlState = GeopoliticalSimulationService.GetSystemControlState(world, anchor.SystemId); var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, anchor.SystemId); var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == anchor.SystemId); var pressure = world.Geopolitics?.Territory.Pressures .Where(entry => entry.SystemId == anchor.SystemId && entry.FactionId == factionId) .OrderByDescending(entry => entry.HostileInfluence) .ThenBy(entry => entry.Id, StringComparer.Ordinal) .FirstOrDefault(); var controlBias = string.Equals(controlState?.ControllerFactionId, factionId, StringComparison.Ordinal) ? 12_000f : string.Equals(controlState?.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) ? 4_000f : 0f; var regionBias = region is null ? 0f : region.Kind switch { "core-industry" => 4_500f, "shipbuilding" => 3_250f, "trade-hub" => 2_250f, "corridor" => 1_500f, _ => 1_000f, }; var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) + ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, anchor.SystemId, factionId)) * 250f); return resourceScore + (factionPresence * 5_000f) + controlBias + regionBias - securityPenalty; } private static bool IsExpansionSystemEligible(SimulationWorld world, string factionId, string systemId) { var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); if (controlState is null) { return true; } var authorityFactionId = controlState.ControllerFactionId ?? controlState.PrimaryClaimantFactionId; if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) { return true; } if (!GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId)) { return false; } return !GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId); } private static bool CanEstablishExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) { if (!IsExpansionSystemEligible(world, factionId, project.SystemId)) { return false; } var controlState = GeopoliticalSimulationService.GetSystemControlState(world, project.SystemId); if (controlState?.IsContested == true) { return false; } var pressure = world.Geopolitics?.Territory.Pressures .Where(entry => entry.SystemId == project.SystemId && entry.FactionId == factionId) .OrderByDescending(entry => entry.CorridorRisk + entry.HostileInfluence) .ThenBy(entry => entry.Id, StringComparer.Ordinal) .FirstOrDefault(); return pressure is null || (pressure.CorridorRisk < 0.8f && pressure.HostileInfluence < 1.2f); } private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId) { var constructionInputs = world.ModuleRecipes.TryGetValue(moduleId, out var recipe) ? recipe.Inputs.Select(input => input.ItemId).ToList() : []; return world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) .OrderByDescending(station => station.SystemId == targetSystemId ? 1 : 0) .ThenByDescending(station => constructionInputs.Sum(inputId => GetInventoryAmount(station.Inventory, inputId))) .ThenByDescending(station => station.Inventory.Values.Sum()) .FirstOrDefault(); } } internal sealed record IndustryExpansionProject( string CommodityId, string ModuleId, string SystemId, string AnchorId, string SupportStationId, string? SiteId = null);