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

591 lines
23 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, 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<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 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<string> 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);