Deepen faction economy and station planning
This commit is contained in:
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
492
apps/backend/Industry/Planning/FactionIndustryPlanner.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
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<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 => (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<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 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<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);
|
||||
Reference in New Issue
Block a user