Deepen faction economy and station planning

This commit is contained in:
2026-03-19 23:34:06 -04:00
parent 9a5040cf1f
commit cd1fe776a5
33 changed files with 3170 additions and 175 deletions

View File

@@ -0,0 +1,228 @@
namespace SpaceGame.Api.Industry.Planning;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
internal sealed class FactionEconomySnapshot
{
private readonly Dictionary<string, FactionCommoditySnapshot> commodities = new(StringComparer.Ordinal);
internal IReadOnlyDictionary<string, FactionCommoditySnapshot> Commodities => commodities;
internal FactionCommoditySnapshot GetCommodity(string itemId)
{
if (!commodities.TryGetValue(itemId, out var commodity))
{
commodity = new FactionCommoditySnapshot(itemId);
commodities[itemId] = commodity;
}
return commodity;
}
}
internal sealed class FactionCommoditySnapshot
{
internal FactionCommoditySnapshot(string itemId)
{
ItemId = itemId;
}
internal string ItemId { get; }
internal float OnHand { get; set; }
internal float ReservedForConstruction { get; set; }
internal float BuyBacklog { get; set; }
internal float SellBacklog { get; set; }
internal float Inbound { get; set; }
internal float ProductionRatePerSecond { get; set; }
internal float CommittedProductionRatePerSecond { get; set; }
internal float ConsumptionRatePerSecond { get; set; }
internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction);
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
internal float ShortageHorizonSeconds
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
}
if (NetRatePerSecond >= -0.01f)
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
}
}
internal float ProjectedShortageHorizonSeconds
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
}
if (ProjectedNetRatePerSecond >= -0.01f)
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
}
}
internal float PressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
internal float ProjectedPressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
}
internal static class FactionEconomyAnalyzer
{
internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId)
{
var snapshot = new FactionEconomySnapshot();
foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)))
{
foreach (var (itemId, amount) in station.Inventory)
{
snapshot.GetCommodity(itemId).OnHand += amount;
}
foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station))
{
var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey);
if (recipe is null)
{
continue;
}
var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe);
var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f);
if (cyclesPerSecond <= 0.0001f)
{
continue;
}
foreach (var input in recipe.Inputs)
{
snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond;
}
foreach (var output in recipe.Outputs)
{
snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond;
}
}
}
foreach (var order in world.MarketOrders.Where(order =>
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
&& order.State != MarketOrderStateKinds.Cancelled
&& order.RemainingAmount > 0.01f))
{
var commodity = snapshot.GetCommodity(order.ItemId);
if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal))
{
commodity.BuyBacklog += order.RemainingAmount;
}
else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal))
{
commodity.SellBacklog += order.RemainingAmount;
}
}
foreach (var site in world.ConstructionSites.Where(site =>
string.Equals(site.FactionId, factionId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed))
{
ApplyCommittedProduction(world, snapshot, site);
foreach (var required in site.RequiredItems)
{
var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key));
if (remaining > 0.01f)
{
snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining;
}
}
}
return snapshot;
}
private static void ApplyCommittedProduction(
SimulationWorld world,
FactionEconomySnapshot snapshot,
ConstructionSiteRuntime site)
{
if (string.IsNullOrWhiteSpace(site.BlueprintId)
|| !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
return;
}
var recipeOutputs = world.Recipes.Values
.Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal))
.SelectMany(candidate => candidate.Outputs)
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
if (recipeOutputs.Count == 0)
{
return;
}
var materialFraction = 0f;
var materialTerms = 0;
foreach (var required in site.RequiredItems)
{
materialTerms += 1;
materialFraction += required.Value <= 0.01f
? 1f
: Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f);
}
materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms;
var buildFraction = recipe.Duration <= 0.01f
? 0f
: Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
var readiness = site.State switch
{
ConstructionSiteStateKinds.Active => 0.3f,
ConstructionSiteStateKinds.Planned => 0.15f,
_ => 0f,
};
readiness += materialFraction * 0.45f;
readiness += buildFraction * 0.25f;
if (site.AssignedConstructorShipIds.Count > 0)
{
readiness += 0.1f;
}
readiness = Math.Clamp(readiness, 0f, 1f);
if (readiness <= 0.01f)
{
return;
}
var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f);
foreach (var (productItemId, amount) in recipeOutputs)
{
snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond;
}
}
}

View 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);

View File

@@ -0,0 +1,53 @@
namespace SpaceGame.Api.Industry.Planning;
public sealed class ProductionGraph
{
public required IReadOnlyDictionary<string, ProductionCommodityNode> Commodities { get; init; }
public required IReadOnlyDictionary<string, ProductionProcessNode> Processes { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByOutputId { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<ProductionProcessNode>> ProcessesByInputId { get; init; }
public required IReadOnlyDictionary<string, IReadOnlyList<string>> OutputsByModuleId { get; init; }
public IReadOnlyList<ProductionProcessNode> GetProcessesForOutput(string itemId) =>
ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : [];
public IReadOnlyList<ProductionProcessNode> GetProcessesForInput(string itemId) =>
ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : [];
public string? GetPrimaryProducerModule(string itemId) =>
GetProcessesForOutput(itemId)
.SelectMany(process => process.RequiredModuleIds)
.FirstOrDefault();
public string? GetPrimaryOutputForModule(string moduleId) =>
OutputsByModuleId.TryGetValue(moduleId, out var outputs)
? outputs.FirstOrDefault()
: null;
public IReadOnlyList<string> GetImmediateInputs(string itemId) =>
GetProcessesForOutput(itemId)
.SelectMany(process => process.Inputs.Keys)
.Distinct(StringComparer.Ordinal)
.ToList();
}
public sealed class ProductionCommodityNode
{
public required string ItemId { get; init; }
public required string Name { get; init; }
public required string Group { get; init; }
public required string CargoKind { get; init; }
public List<string> ProducerProcessIds { get; } = [];
public List<string> ConsumerProcessIds { get; } = [];
}
public sealed class ProductionProcessNode
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string FacilityCategory { get; init; }
public required IReadOnlyList<string> RequiredModuleIds { get; init; }
public required IReadOnlyDictionary<string, float> Inputs { get; init; }
public required IReadOnlyDictionary<string, float> Outputs { get; init; }
public required bool ProducesShip { get; init; }
}

View File

@@ -0,0 +1,105 @@
namespace SpaceGame.Api.Industry.Planning;
internal static class ProductionGraphBuilder
{
internal static ProductionGraph Build(
IReadOnlyCollection<ItemDefinition> items,
IReadOnlyCollection<RecipeDefinition> recipes,
IReadOnlyCollection<ModuleDefinition> modules)
{
var commodities = items.ToDictionary(
item => item.Id,
item => new ProductionCommodityNode
{
ItemId = item.Id,
Name = item.Name,
Group = item.Group,
CargoKind = item.CargoKind,
},
StringComparer.Ordinal);
var processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal);
var processesByOutputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
var processesByInputId = new Dictionary<string, List<ProductionProcessNode>>(StringComparer.Ordinal);
var outputsByModuleId = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var recipe in recipes)
{
var outputs = recipe.Outputs
.GroupBy(output => output.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal);
var inputs = recipe.Inputs
.GroupBy(input => input.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal);
var process = new ProductionProcessNode
{
Id = recipe.Id,
Label = recipe.Label,
FacilityCategory = recipe.FacilityCategory,
RequiredModuleIds = recipe.RequiredModules.ToList(),
Inputs = inputs,
Outputs = outputs,
ProducesShip = recipe.ShipOutputId is not null,
};
processes[process.Id] = process;
foreach (var output in outputs.Keys)
{
if (!commodities.ContainsKey(output))
{
continue;
}
commodities[output].ProducerProcessIds.Add(process.Id);
if (!processesByOutputId.TryGetValue(output, out var outputProcesses))
{
outputProcesses = [];
processesByOutputId[output] = outputProcesses;
}
outputProcesses.Add(process);
}
foreach (var input in inputs.Keys)
{
if (!commodities.ContainsKey(input))
{
continue;
}
commodities[input].ConsumerProcessIds.Add(process.Id);
if (!processesByInputId.TryGetValue(input, out var inputProcesses))
{
inputProcesses = [];
processesByInputId[input] = inputProcesses;
}
inputProcesses.Add(process);
}
}
foreach (var module in modules)
{
if (!outputsByModuleId.TryGetValue(module.Id, out var outputs))
{
outputs = new HashSet<string>(StringComparer.Ordinal);
outputsByModuleId[module.Id] = outputs;
}
foreach (var product in module.Products)
{
outputs.Add(product);
}
}
return new ProductionGraph
{
Commodities = commodities,
Processes = processes,
ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<ProductionProcessNode>)entry.Value, StringComparer.Ordinal),
OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList<string>)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal),
};
}
}