Files
space-game/apps/backend/Industry/Planning/FactionIndustryPlanner.cs

510 lines
18 KiB
C#

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)
{
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,
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);
}
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.FactionCommanderHasIssuedTask(world, factionId, FactionIssuedTaskKind.ProduceShips, "military"))
{
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<string>(StringComparer.Ordinal);
return ResolveBottleneckCommodity(world, factionId, itemId, visited);
}
private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet<string> 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<string> ResolveRootResourceItems(SimulationWorld world, string commodityId)
{
var frontier = new Queue<string>();
var resources = new HashSet<string>(StringComparer.Ordinal);
var visited = new HashSet<string>(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 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<string> 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);