From 04d182e93f3bf88d8eafb87922caaa20582f4f61 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 27 Mar 2026 16:44:50 -0400 Subject: [PATCH] refactor(backend): align station module production semantics --- apps/backend/Definitions/WorldDefinitions.cs | 85 ++++++++++++------- .../Planning/ProductionGraphBuilder.cs | 2 +- .../Runtime/SimulationRuntimeSupport.cs | 19 +++++ .../Stations/Runtime/StationRuntimeModels.cs | 12 +++ .../InfrastructureSimulationService.cs | 4 +- .../Simulation/StationLifecycleService.cs | 7 +- .../Simulation/StationSimulationService.cs | 10 +-- .../Universe/Scenario/DataCatalogLoader.cs | 41 ++++----- .../backend/Universe/Scenario/WorldBuilder.cs | 6 +- .../Universe/Scenario/WorldSeedingService.cs | 21 +++-- 10 files changed, 125 insertions(+), 82 deletions(-) diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index 1eb883e..36e5086 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -150,12 +150,6 @@ public sealed class RecipeInputDefinition } } -public sealed class ModuleConstructionDefinition -{ - public required List Requirements { get; set; } - public float ProductionTime { get; set; } -} - public sealed class ModuleDockDefinition { public int Capacity { get; set; } @@ -168,10 +162,14 @@ public sealed class ModuleCargoDefinition public required string Type { get; set; } } -public sealed class ModuleWorkForceDefinition +public sealed class ModuleWorkforceDefinition { - public float Capacity { get; set; } - public float Max { get; set; } + [JsonPropertyName("capacity")] + public float SupportedPopulation { get; set; } + + [JsonPropertyName("max")] + public float RequiredWorkforce { get; set; } + public string Race { get; set; } = string.Empty; } @@ -183,7 +181,7 @@ public sealed class ModuleMountDefinition public List Types { get; set; } = []; } -public sealed class ModuleProductionDefinition +public sealed class ModuleBuildRecipeDefinition { public float Time { get; set; } public float Amount { get; set; } @@ -206,12 +204,9 @@ public class ModuleDefinition Description = source.Description; Type = source.Type; ModuleType = source.ModuleType; - Product = source.Product; - Products = [.. source.Products]; - ProductionMode = source.ProductionMode; + ProductIds = [.. source.ProductIds]; Radius = source.Radius; Hull = source.Hull; - WorkforceNeeded = source.WorkforceNeeded; Version = source.Version; Macro = source.Macro; MakerRace = source.MakerRace; @@ -219,12 +214,11 @@ public class ModuleDefinition Price = source.Price; Owners = [.. source.Owners]; Cargo = source.Cargo; - WorkForce = source.WorkForce; + SerializedWorkforce = source.SerializedWorkforce; Docks = [.. source.Docks]; Shields = [.. source.Shields]; Turrets = [.. source.Turrets]; - Production = [.. source.Production]; - Construction = source.Construction; + BuildRecipes = [.. source.BuildRecipes]; } public required string Id { get; set; } @@ -233,13 +227,12 @@ public class ModuleDefinition public required string Type { get; set; } [JsonIgnore] public ModuleType ModuleType { get; set; } + [JsonPropertyName("product")] + public List ProductIds { get; set; } = []; [JsonIgnore] - public string? Product { get; set; } - public List Products { get; set; } = []; - public string ProductionMode { get; set; } = "passive"; + public virtual IReadOnlyList ProductItemIds => []; public float Radius { get; set; } = 12f; public float Hull { get; set; } = 100f; - public float WorkforceNeeded { get; set; } public int Version { get; set; } public string Macro { get; set; } = string.Empty; public string MakerRace { get; set; } = string.Empty; @@ -247,30 +240,58 @@ public class ModuleDefinition public ItemPriceDefinition? Price { get; set; } public List Owners { get; set; } = []; public ModuleCargoDefinition? Cargo { get; set; } - public ModuleWorkForceDefinition? WorkForce { get; set; } + [JsonPropertyName("workForce")] + public ModuleWorkforceDefinition? SerializedWorkforce { get; set; } public List Docks { get; set; } = []; public List Shields { get; set; } = []; public List Turrets { get; set; } = []; - public List Production { get; set; } = []; - public ModuleConstructionDefinition? Construction { get; set; } - [JsonPropertyName("product")] - public List ProductIds + [JsonPropertyName("production")] + public List BuildRecipes { get; set; } = []; +} + +public abstract class ProductionLaneModuleDefinition : ModuleDefinition +{ + [SetsRequiredMembers] + protected ProductionLaneModuleDefinition(ModuleDefinition source, float requiredWorkforce) + : base(source) + { + RequiredWorkforce = requiredWorkforce; + } + + public float RequiredWorkforce { get; init; } +} + +public sealed class ProductionModuleDefinition : ProductionLaneModuleDefinition +{ + [SetsRequiredMembers] + internal ProductionModuleDefinition(ModuleDefinition source, float requiredWorkforce) + : base(source, requiredWorkforce) + { + ProductItemIds = [.. source.ProductIds]; + } + + public override IReadOnlyList ProductItemIds { get; } = []; +} + +public sealed class BuildModuleDefinition : ProductionLaneModuleDefinition +{ + [SetsRequiredMembers] + internal BuildModuleDefinition(ModuleDefinition source, float requiredWorkforce) + : base(source, requiredWorkforce) { - get => Products; - set => Products = value ?? []; } } -public sealed class ProductionModuleDefinition : ModuleDefinition +public sealed class HabitationModuleDefinition : ModuleDefinition { [SetsRequiredMembers] - internal ProductionModuleDefinition(ModuleDefinition source) + internal HabitationModuleDefinition(ModuleDefinition source, float supportedPopulation) : base(source) { - ProductItemIds = [.. source.Products]; + SupportedPopulation = supportedPopulation; } - public IReadOnlyList ProductItemIds { get; init; } = []; + public float SupportedPopulation { get; init; } } public sealed class StorageModuleDefinition : ModuleDefinition diff --git a/apps/backend/Industry/Planning/ProductionGraphBuilder.cs b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs index 9ee70df..8022c7f 100644 --- a/apps/backend/Industry/Planning/ProductionGraphBuilder.cs +++ b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs @@ -87,7 +87,7 @@ internal static class ProductionGraphBuilder outputsByModuleId[module.Id] = outputs; } - foreach (var product in module.Products) + foreach (var product in module.ProductItemIds) { outputs.Add(product); } diff --git a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs index ae2413b..549a698 100644 --- a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs +++ b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs @@ -9,6 +9,25 @@ internal static class SimulationRuntimeSupport internal static int CountStationModules(StationRuntime station, ModuleType moduleType) => station.Modules.Count(module => module.ModuleType == moduleType); + internal static float GetStationSupportedPopulation( + IReadOnlyDictionary moduleDefinitions, + StationRuntime station) => + 40f + station.Modules + .Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is HabitationModuleDefinition habitation + ? habitation.SupportedPopulation + : 0f) + .Sum(); + + internal static float GetStationRequiredWorkforce( + IReadOnlyDictionary moduleDefinitions, + StationRuntime station) => + MathF.Max(12f, station.Modules + .Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) + && definition is ProductionLaneModuleDefinition productionLane + ? productionLane.RequiredWorkforce + : 0f) + .Sum()); + internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind) { SyncStorageModuleLevels(world, station, storageKind); diff --git a/apps/backend/Stations/Runtime/StationRuntimeModels.cs b/apps/backend/Stations/Runtime/StationRuntimeModels.cs index 1b3f45a..cdc2284 100644 --- a/apps/backend/Stations/Runtime/StationRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/StationRuntimeModels.cs @@ -65,6 +65,14 @@ public class StationModuleRuntime Health = production.Hull, MaxHealth = production.Hull, }, + BuildModuleDefinition build => new BuildStationModuleRuntime + { + Id = id, + ModuleId = build.Id, + ModuleType = build.ModuleType, + Health = build.Hull, + MaxHealth = build.Hull, + }, _ => new StationModuleRuntime { Id = id, @@ -81,6 +89,10 @@ public sealed class ProductionStationModuleRuntime : StationModuleRuntime public IReadOnlyList ProductItemIds { get; init; } = []; } +public sealed class BuildStationModuleRuntime : StationModuleRuntime +{ +} + public sealed class StorageStationModuleRuntime : StationModuleRuntime { public StorageKind StorageKind { get; init; } diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index a3162f7..99efd61 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -203,7 +203,7 @@ internal sealed class InfrastructureSimulationService if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition)) { - foreach (var productItemId in moduleDefinition.Products) + foreach (var productItemId in moduleDefinition.ProductItemIds) { if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition)) { @@ -621,7 +621,7 @@ internal sealed class InfrastructureSimulationService } var score = 0f; - foreach (var productItemId in moduleDefinition.Products) + foreach (var productItemId in moduleDefinition.ProductItemIds) { if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f) { diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index 5645fd4..5494ef8 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -34,17 +34,16 @@ internal sealed class StationLifecycleService private void UpdateStationPopulation(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) { - station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); + station.WorkforceRequired = GetStationRequiredWorkforce(world.ModuleDefinitions, station); var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds; var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater); var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater; - var habitatModules = CountStationModules(station, ModuleType.Habitation); - station.PopulationCapacity = 40f + (habitatModules * 220f); + station.PopulationCapacity = GetStationSupportedPopulation(world.ModuleDefinitions, station); if (waterSatisfied) { - if (habitatModules > 0 && station.Population < station.PopulationCapacity) + if (station.PopulationCapacity > 40f && station.Population < station.PopulationCapacity) { station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); } diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index b92438c..eb0f35f 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -216,12 +216,8 @@ internal sealed class StationSimulationService { foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal)) { - if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode)) - { - continue; - } - - if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null) + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition) + || definition is not ProductionLaneModuleDefinition) { continue; } @@ -241,7 +237,7 @@ internal sealed class StationSimulationService internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) => recipe.RequiredModules.FirstOrDefault(moduleId => - world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode)); + world.ModuleDefinitions.TryGetValue(moduleId, out var definition) && definition is ProductionLaneModuleDefinition); internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) { diff --git a/apps/backend/Universe/Scenario/DataCatalogLoader.cs b/apps/backend/Universe/Scenario/DataCatalogLoader.cs index 9ac7d6c..eaa0ff4 100644 --- a/apps/backend/Universe/Scenario/DataCatalogLoader.cs +++ b/apps/backend/Universe/Scenario/DataCatalogLoader.cs @@ -103,12 +103,12 @@ internal sealed class DataCatalogLoader(string dataRoot) private static List BuildModuleRecipes(IEnumerable modules) => modules - .Where(module => module.Construction is not null || module.Production.Count > 0) + .Where(module => module.BuildRecipes.Count > 0) .Select(module => new ModuleRecipeDefinition { ModuleId = module.Id, - Duration = module.Construction?.ProductionTime ?? module.Production[0].Time, - Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares) + Duration = module.BuildRecipes[0].Time, + Inputs = module.BuildRecipes[0].Wares .Select(input => new RecipeInputDefinition { ItemId = input.ItemId, @@ -122,8 +122,8 @@ internal sealed class DataCatalogLoader(string dataRoot) { var recipes = new List(); var preferredProducerByItemId = modules - .Where(module => module.Products.Count > 0) - .GroupBy(module => module.Products[0], StringComparer.Ordinal) + .Where(module => module.ProductItemIds.Count > 0) + .GroupBy(module => module.ProductItemIds[0], StringComparer.Ordinal) .ToDictionary( group => group.Key, group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id, @@ -271,23 +271,6 @@ internal sealed class DataCatalogLoader(string dataRoot) module.Type = module.ModuleType.ToDataValue(); - if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product)) - { - module.Products = [module.Product]; - } - - if (string.IsNullOrWhiteSpace(module.ProductionMode)) - { - module.ProductionMode = module.ModuleType == ModuleType.BuildModule - ? "commanded" - : "passive"; - } - - if (module.WorkforceNeeded <= 0f) - { - module.WorkforceNeeded = module.WorkForce?.Max ?? 0f; - } - modules[index] = CreateSpecializedModuleDefinition(module); } @@ -313,9 +296,19 @@ internal sealed class DataCatalogLoader(string dataRoot) } } - if (module.Products.Count > 0) + if (module.ModuleType == ModuleType.Habitation) { - return new ProductionModuleDefinition(module); + return new HabitationModuleDefinition(module, module.SerializedWorkforce?.SupportedPopulation ?? 0f); + } + + if (module.ModuleType == ModuleType.BuildModule) + { + return new BuildModuleDefinition(module, module.SerializedWorkforce?.RequiredWorkforce ?? 0f); + } + + if (module.ModuleType == ModuleType.Production) + { + return new ProductionModuleDefinition(module, module.SerializedWorkforce?.RequiredWorkforce ?? 0f); } return module; diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 907ca4f..ae0e677 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -38,7 +38,7 @@ internal sealed class WorldBuilder( catalog.ModuleDefinitions, catalog.ItemDefinitions); - seedingService.InitializeStationStockpiles(stations); + seedingService.InitializeStationStockpiles(stations, catalog.ModuleDefinitions); var refinery = seedingService.SelectRefineryStation(stations, scenario); var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery); @@ -208,9 +208,9 @@ internal sealed class WorldBuilder( yield break; } - foreach (var wareId in moduleDefinition.Production + foreach (var wareId in moduleDefinition.BuildRecipes .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) - .Concat(moduleDefinition.Products) + .Concat(moduleDefinition.ProductItemIds) .Distinct(StringComparer.Ordinal)) { if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition)) diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 2f1f8fb..5623935 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -60,11 +60,13 @@ internal sealed class WorldSeedingService } } - internal void InitializeStationStockpiles(IReadOnlyCollection stations) + internal void InitializeStationStockpiles( + IReadOnlyCollection stations, + IReadOnlyDictionary moduleDefinitions) { foreach (var station in stations) { - InitializeStationPopulation(station); + InitializeStationPopulation(station, moduleDefinitions); } } @@ -231,9 +233,9 @@ internal sealed class WorldSeedingService yield break; } - foreach (var wareId in moduleDefinition.Production + foreach (var wareId in moduleDefinition.BuildRecipes .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) - .Concat(moduleDefinition.Products) + .Concat(moduleDefinition.ProductItemIds) .Distinct(StringComparer.Ordinal)) { if (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition)) @@ -549,12 +551,13 @@ internal sealed class WorldSeedingService }; } - private static void InitializeStationPopulation(StationRuntime station) + private static void InitializeStationPopulation( + StationRuntime station, + IReadOnlyDictionary moduleDefinitions) { - var habitatModules = station.Modules.Count(module => module.ModuleType == ModuleType.Habitation); - station.PopulationCapacity = 40f + (habitatModules * 220f); - station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); - station.Population = habitatModules > 0 + station.PopulationCapacity = SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStationSupportedPopulation(moduleDefinitions, station); + station.WorkforceRequired = SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStationRequiredWorkforce(moduleDefinitions, station); + station.Population = station.PopulationCapacity > 40f ? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f) : MathF.Min(28f, station.PopulationCapacity); station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);