refactor(backend): align station module production semantics

This commit is contained in:
2026-03-27 16:44:50 -04:00
parent 3237735b08
commit 04d182e93f
10 changed files with 125 additions and 82 deletions

View File

@@ -150,12 +150,6 @@ public sealed class RecipeInputDefinition
}
}
public sealed class ModuleConstructionDefinition
{
public required List<RecipeInputDefinition> 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<string> 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<string> ProductIds { get; set; } = [];
[JsonIgnore]
public string? Product { get; set; }
public List<string> Products { get; set; } = [];
public string ProductionMode { get; set; } = "passive";
public virtual IReadOnlyList<string> 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<string> Owners { get; set; } = [];
public ModuleCargoDefinition? Cargo { get; set; }
public ModuleWorkForceDefinition? WorkForce { get; set; }
[JsonPropertyName("workForce")]
public ModuleWorkforceDefinition? SerializedWorkforce { get; set; }
public List<ModuleDockDefinition> Docks { get; set; } = [];
public List<ModuleMountDefinition> Shields { get; set; } = [];
public List<ModuleMountDefinition> Turrets { get; set; } = [];
public List<ModuleProductionDefinition> Production { get; set; } = [];
public ModuleConstructionDefinition? Construction { get; set; }
[JsonPropertyName("product")]
public List<string> ProductIds
{
get => Products;
set => Products = value ?? [];
}
[JsonPropertyName("production")]
public List<ModuleBuildRecipeDefinition> BuildRecipes { get; set; } = [];
}
public sealed class ProductionModuleDefinition : ModuleDefinition
public abstract class ProductionLaneModuleDefinition : ModuleDefinition
{
[SetsRequiredMembers]
internal ProductionModuleDefinition(ModuleDefinition source)
protected ProductionLaneModuleDefinition(ModuleDefinition source, float requiredWorkforce)
: base(source)
{
ProductItemIds = [.. source.Products];
RequiredWorkforce = requiredWorkforce;
}
public IReadOnlyList<string> ProductItemIds { get; init; } = [];
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<string> ProductItemIds { get; } = [];
}
public sealed class BuildModuleDefinition : ProductionLaneModuleDefinition
{
[SetsRequiredMembers]
internal BuildModuleDefinition(ModuleDefinition source, float requiredWorkforce)
: base(source, requiredWorkforce)
{
}
}
public sealed class HabitationModuleDefinition : ModuleDefinition
{
[SetsRequiredMembers]
internal HabitationModuleDefinition(ModuleDefinition source, float supportedPopulation)
: base(source)
{
SupportedPopulation = supportedPopulation;
}
public float SupportedPopulation { get; init; }
}
public sealed class StorageModuleDefinition : ModuleDefinition

View File

@@ -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);
}

View File

@@ -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<string, ModuleDefinition> 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<string, ModuleDefinition> 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);

View File

@@ -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<string> ProductItemIds { get; init; } = [];
}
public sealed class BuildStationModuleRuntime : StationModuleRuntime
{
}
public sealed class StorageStationModuleRuntime : StationModuleRuntime
{
public StorageKind StorageKind { get; init; }

View File

@@ -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)
{

View File

@@ -34,17 +34,16 @@ internal sealed class StationLifecycleService
private void UpdateStationPopulation(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> 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));
}

View File

@@ -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)
{

View File

@@ -103,12 +103,12 @@ internal sealed class DataCatalogLoader(string dataRoot)
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> 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<RecipeDefinition>();
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;

View File

@@ -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))

View File

@@ -60,11 +60,13 @@ internal sealed class WorldSeedingService
}
}
internal void InitializeStationStockpiles(IReadOnlyCollection<StationRuntime> stations)
internal void InitializeStationStockpiles(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyDictionary<string, ModuleDefinition> 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<string, ModuleDefinition> 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);