namespace SpaceGame.Api.Industry.Planning; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; internal static class FactionIndustryPlanner { internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId) { if (HasActiveExpansionProject(world, factionId)) { return null; } var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId); var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity); if (moduleId is null) { return null; } var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity); if (targetCelestial is null) { return null; } var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( bottleneckCommodity, moduleId, targetCelestial.SystemId, targetCelestial.Id, supportStation.Id); } internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId) { if (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, HasProducer = FactionHasProducerForCommodity(world, factionId, itemId), Pressure = GetCommodityPressure(world, factionId, itemId), Stockpile = GetCommodityStockpile(world, factionId, itemId), }) .Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f) .OrderByDescending(entry => !entry.HasProducer ? 1 : 0) .ThenByDescending(entry => entry.Pressure) .ThenBy(entry => entry.Stockpile) .Select(entry => entry.ItemId) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) { return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity); } return CreateShipyardFoundationProject(world, factionId); } internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId) { const string shipyardModuleId = "module_gen_build_l_01"; if (HasActiveExpansionProject(world, factionId)) { return null; } var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId); if (targetCelestial is null) { return null; } var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( "shipyard", shipyardModuleId, targetCelestial.SystemId, targetCelestial.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 bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity); if (bootstrapCelestial is null) { return null; } var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId); if (bootstrapSupportStation is null) { return null; } return new IndustryExpansionProject( bootstrapCommodity, bootstrapModuleId, bootstrapCelestial.SystemId, bootstrapCelestial.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 targetCelestial = SelectFoundationCelestial(world, factionId, commodityId); if (targetCelestial is null) { return null; } var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); if (supportStation is null) { return null; } return new IndustryExpansionProject( commodityId, moduleId, targetCelestial.SystemId, targetCelestial.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.CelestialId, supportStationId, site.Id); } internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) { if (project.SiteId is not null) { return; } var nowUtc = DateTimeOffset.UtcNow; var claimId = $"claim-{factionId}-{project.CelestialId}"; if (world.Claims.All(candidate => candidate.Id != claimId)) { world.Claims.Add(new ClaimRuntime { Id = claimId, FactionId = factionId, SystemId = project.SystemId, CelestialId = project.CelestialId, 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.CelestialId}"; if (world.ConstructionSites.Any(candidate => candidate.Id == siteId)) { return; } var site = new ConstructionSiteRuntime { Id = siteId, FactionId = factionId, SystemId = project.SystemId, CelestialId = project.CelestialId, 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); if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships")) { demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; } return demandByItem .Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value)) .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 => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId))) .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 => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId))) .OrderByDescending(entry => entry.Score) .FirstOrDefault(); return weakestInput.Score > GetCommodityPressure(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 GetCommodityPressure(SimulationWorld world, string factionId, string itemId) { return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore; } private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) => FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock; private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId) { var resourceItems = ResolveRootResourceItems(world, commodityId); return world.Celestials .Where(celestial => celestial.Kind == SpatialNodeKind.LagrangePoint && celestial.OccupyingStructureId is null && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) .OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) .FirstOrDefault(); } private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId) { return world.Celestials .Where(celestial => celestial.Kind == SpatialNodeKind.LagrangePoint && celestial.OccupyingStructureId is null && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) .OrderByDescending(celestial => world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))) .ThenByDescending(celestial => world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)) .Sum(station => station.Inventory.Values.Sum())) .FirstOrDefault(); } private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection resourceItems) { var resourceScore = world.Nodes .Where(node => node.SystemId == celestial.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, celestial.SystemId, StringComparison.Ordinal)); return resourceScore + (factionPresence * 5_000f); } 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 CelestialId, string SupportStationId, string? SiteId = null);