From e8fb033a01e2f274d3634fd3a7a6e181edd68da2 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 27 Mar 2026 14:59:15 -0400 Subject: [PATCH] Refactor station modules into typed runtime models --- apps/backend/Definitions/WorldDefinitions.cs | 66 +++++++- .../backend/Shared/Runtime/SimulationKinds.cs | 64 ++++++-- .../Runtime/SimulationRuntimeSupport.cs | 153 +++++++++++------- .../backend/Ships/Simulation/ShipAiService.cs | 6 +- .../Core/SimulationProjectionService.cs | 12 +- .../Stations/Runtime/StationRuntimeModels.cs | 48 +++++- .../InfrastructureSimulationService.cs | 69 ++++---- .../Simulation/StationLifecycleService.cs | 2 +- .../Simulation/StationSimulationService.cs | 33 ++-- .../Universe/Scenario/DataCatalogLoader.cs | 75 ++++++++- .../Universe/Scenario/LoaderSupport.cs | 16 +- .../backend/Universe/Scenario/WorldBuilder.cs | 12 +- .../Universe/Scenario/WorldSeedingService.cs | 21 +-- 13 files changed, 408 insertions(+), 169 deletions(-) diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index fc2ccf9..8559310 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using SpaceGame.Api.Shared.Runtime; @@ -115,6 +116,8 @@ public sealed class ItemDefinition public string Description { get; set; } = string.Empty; public string Type { get; set; } = "material"; public string CargoKind { get; set; } = string.Empty; + [JsonIgnore] + public StorageKind? CargoStorageKind { get; set; } public float Volume { get; set; } = 1f; public int Version { get; set; } public string FactoryName { get; set; } = string.Empty; @@ -190,8 +193,41 @@ public sealed class ModuleProductionDefinition public List Wares { get; set; } = []; } -public sealed class ModuleDefinition +public class ModuleDefinition { + public ModuleDefinition() + { + } + + [SetsRequiredMembers] + protected ModuleDefinition(ModuleDefinition source) + { + Id = source.Id; + Name = source.Name; + Description = source.Description; + Type = source.Type; + ModuleType = source.ModuleType; + Product = source.Product; + Products = [.. source.Products]; + ProductionMode = source.ProductionMode; + Radius = source.Radius; + Hull = source.Hull; + WorkforceNeeded = source.WorkforceNeeded; + Version = source.Version; + Macro = source.Macro; + MakerRace = source.MakerRace; + ExplosionDamage = source.ExplosionDamage; + Price = source.Price; + Owners = [.. source.Owners]; + Cargo = source.Cargo; + WorkForce = source.WorkForce; + Docks = [.. source.Docks]; + Shields = [.. source.Shields]; + Turrets = [.. source.Turrets]; + Production = [.. source.Production]; + Construction = source.Construction; + } + public required string Id { get; set; } public required string Name { get; set; } public string Description { get; set; } = string.Empty; @@ -226,6 +262,32 @@ public sealed class ModuleDefinition } } +public sealed class ProductionModuleDefinition : ModuleDefinition +{ + [SetsRequiredMembers] + internal ProductionModuleDefinition(ModuleDefinition source) + : base(source) + { + ProductItemIds = [.. source.Products]; + } + + public IReadOnlyList ProductItemIds { get; init; } = []; +} + +public sealed class StorageModuleDefinition : ModuleDefinition +{ + [SetsRequiredMembers] + internal StorageModuleDefinition(ModuleDefinition source, StorageKind storageKind, float storageCapacity) + : base(source) + { + StorageKind = storageKind; + StorageCapacity = storageCapacity; + } + + public StorageKind StorageKind { get; init; } + public float StorageCapacity { get; init; } +} + public sealed class ModuleRecipeDefinition { public required string ModuleId { get; set; } @@ -277,6 +339,8 @@ public sealed class ShipDefinition public float SpoolTime { get; set; } public float CargoCapacity { get; set; } public string? CargoKind { get; set; } + [JsonIgnore] + public StorageKind? CargoStorageKind { get; set; } public required string Color { get; set; } public required string HullColor { get; set; } public float Size { get; set; } diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index 7ce027b..e14fccd 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -115,6 +115,14 @@ public enum ModuleType Storage, } +public enum StorageKind +{ + Condensate, + Container, + Liquid, + Solid, +} + public static class CommanderKind { public const string Faction = "faction"; @@ -209,20 +217,54 @@ public static class SimulationEnumMappings _ => throw new ArgumentOutOfRangeException(nameof(moduleType), moduleType, null), }; - public static ModuleType ToModuleType(this string value) => value.Trim() switch + public static ModuleType ToModuleType(this string value) { - "buildmodule" => ModuleType.BuildModule, - "connectionmodule" => ModuleType.ConnectionModule, - "defencemodule" => ModuleType.DefenceModule, - "dockarea" => ModuleType.DockArea, - "habitation" => ModuleType.Habitation, - "pier" => ModuleType.Pier, - "processingmodule" => ModuleType.ProcessingModule, - "production" => ModuleType.Production, - "storage" => ModuleType.Storage, - _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported module type."), + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Module type is required."); + } + + return value.Trim().ToLowerInvariant() switch + { + "buildmodule" => ModuleType.BuildModule, + "connectionmodule" => ModuleType.ConnectionModule, + "defencemodule" => ModuleType.DefenceModule, + "dockarea" => ModuleType.DockArea, + "habitation" => ModuleType.Habitation, + "pier" => ModuleType.Pier, + "processingmodule" => ModuleType.ProcessingModule, + "production" => ModuleType.Production, + "storage" => ModuleType.Storage, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported module type."), + }; + } + + public static string ToDataValue(this StorageKind storageKind) => storageKind switch + { + StorageKind.Condensate => "condensate", + StorageKind.Container => "container", + StorageKind.Liquid => "liquid", + StorageKind.Solid => "solid", + _ => throw new ArgumentOutOfRangeException(nameof(storageKind), storageKind, null), }; + public static StorageKind ToStorageKind(this string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Storage kind is required."); + } + + return value.Trim().ToLowerInvariant() switch + { + "condensate" => StorageKind.Condensate, + "container" => StorageKind.Container, + "liquid" => StorageKind.Liquid, + "solid" => StorageKind.Solid, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported storage kind."), + }; + } + public static string ToContractValue(this SpatialNodeKind kind) => kind switch { SpatialNodeKind.Star => "star", diff --git a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs index f3caf37..c10ed10 100644 --- a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs +++ b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs @@ -6,13 +6,55 @@ internal static class SimulationRuntimeSupport internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) => capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal)); - internal static int CountStationModules(StationRuntime station, string moduleId) => - station.Modules.Count(module => string.Equals(module.ModuleId, moduleId, StringComparison.Ordinal)); + internal static int CountStationModules(StationRuntime station, ModuleType moduleType) => + station.Modules.Count(module => module.ModuleType == moduleType); - internal static int CountStationModules(SimulationWorld world, StationRuntime station, ModuleType moduleType) => - station.Modules.Count(module => - world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) - && definition.ModuleType == moduleType); + internal static float GetStationStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind) + { + SyncStorageModuleLevels(world, station, storageKind); + return GetStorageModules(world, station, storageKind) + .Sum(entry => entry.Definition.StorageCapacity); + } + + internal static bool HasStorageCapacity(SimulationWorld world, StationRuntime station, StorageKind storageKind) + { + SyncStorageModuleLevels(world, station, storageKind); + return GetStorageModules(world, station, storageKind).Any(); + } + + private static IEnumerable<(StorageStationModuleRuntime Module, StorageModuleDefinition Definition)> GetStorageModules( + SimulationWorld world, + StationRuntime station, + StorageKind storageKind) => + station.Modules + .OfType() + .Where(module => module.StorageKind == storageKind) + .Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) && definition is StorageModuleDefinition storageDefinition + ? (Module: module, Definition: storageDefinition) + : ((StorageStationModuleRuntime Module, StorageModuleDefinition Definition)?)null) + .Where(entry => entry is not null && entry.Value.Definition.StorageKind == storageKind) + .Select(entry => entry!.Value); + + private static void SyncStorageModuleLevels(SimulationWorld world, StationRuntime station, StorageKind storageKind) + { + var storageModules = GetStorageModules(world, station, storageKind) + .OrderBy(entry => entry.Module.Id, StringComparer.Ordinal) + .ToList(); + if (storageModules.Count == 0) + { + return; + } + + var remaining = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind) + .Sum(entry => entry.Value); + + foreach (var (module, definition) in storageModules) + { + module.CurrentLevel = MathF.Min(remaining, definition.StorageCapacity); + remaining = MathF.Max(0f, remaining - definition.StorageCapacity); + } + } internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId) { @@ -21,13 +63,7 @@ internal static class SimulationRuntimeSupport return; } - station.Modules.Add(new StationModuleRuntime - { - Id = $"{station.Id}-module-{station.Modules.Count + 1}", - ModuleId = moduleId, - Health = definition.Hull, - MaxHealth = definition.Hull, - }); + station.Modules.Add(StationModuleRuntime.Create($"{station.Id}-module-{station.Modules.Count + 1}", definition)); station.Radius = GetStationRadius(world, station); } @@ -39,41 +75,9 @@ internal static class SimulationRuntimeSupport return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f))); } - internal static float GetStationStorageCapacity(StationRuntime station, string storageClass) - { - var baseCapacity = storageClass switch - { - "manufactured" => 400f, - _ => 0f, - }; - - var bulkBays = CountStationModules(station, "module_arg_stor_solid_m_01"); - var liquidTanks = CountStationModules(station, "module_arg_stor_liquid_m_01"); - var containerBays = CountStationModules(station, "module_arg_stor_container_m_01"); - - var moduleCapacity = storageClass switch - { - "solid" => bulkBays * 1000f, - "liquid" => liquidTanks * 500f, - "container" => containerBays * 800f, - "manufactured" => containerBays * 200f, - _ => 0f, - }; - - return baseCapacity + moduleCapacity; - } - internal static int CountModules(IEnumerable modules, string moduleId) => modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); - internal static int CountModules( - IEnumerable modules, - IReadOnlyDictionary moduleDefinitions, - ModuleType moduleType) => - modules.Count(moduleId => - moduleDefinitions.TryGetValue(moduleId, out var definition) - && definition.ModuleType == moduleType); - internal static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => inventory.TryGetValue(itemId, out var amount) ? amount : 0f; @@ -110,7 +114,8 @@ internal static class SimulationRuntimeSupport internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) => HasShipCapabilities(ship.Definition, "mining") && world.ItemDefinitions.TryGetValue(node.ItemId, out var item) - && string.Equals(item.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal); + && item.CargoStorageKind is not null + && item.CargoStorageKind == ship.Definition.CargoStorageKind; internal static bool CanBuildClaimBeacon(ShipRuntime ship) => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal); @@ -126,13 +131,43 @@ internal static class SimulationRuntimeSupport return 0.1f + (0.9f * staffedRatio); } - internal static string? GetStorageRequirement(string storageClass) => - storageClass switch + internal static string? GetStorageRequirement( + IReadOnlyDictionary moduleDefinitions, + StorageKind? storageKind) + { + if (storageKind is not { } requiredStorageKind) { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => null, - }; + return null; + } + + return moduleDefinitions.Values + .OfType() + .Where(definition => definition.StorageKind == requiredStorageKind) + .OrderBy(definition => GetPreferredStorageModuleRank(definition.Id)) + .ThenBy(definition => definition.Id, StringComparer.Ordinal) + .Select(definition => definition.Id) + .FirstOrDefault(); + } + + private static int GetPreferredStorageModuleRank(string moduleId) + { + if (moduleId.Contains("_m_", StringComparison.Ordinal)) + { + return 0; + } + + if (moduleId.Contains("_s_", StringComparison.Ordinal)) + { + return 1; + } + + if (moduleId.Contains("_l_", StringComparison.Ordinal)) + { + return 2; + } + + return 3; + } internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) { @@ -141,21 +176,25 @@ internal static class SimulationRuntimeSupport return 0f; } - var storageClass = itemDefinition.CargoKind; - var requiredModule = GetStorageRequirement(storageClass); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + var storageKind = itemDefinition.CargoStorageKind; + if (storageKind is null) { return 0f; } - var capacity = GetStationStorageCapacity(station, storageClass); + if (!HasStorageCapacity(world, station, storageKind.Value)) + { + return 0f; + } + + var capacity = GetStationStorageCapacity(world, station, storageKind.Value); if (capacity <= 0.01f) { return 0f; } var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind) .Sum(entry => entry.Value); var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); if (accepted <= 0.01f) diff --git a/apps/backend/Ships/Simulation/ShipAiService.cs b/apps/backend/Ships/Simulation/ShipAiService.cs index 7e29da7..93c8911 100644 --- a/apps/backend/Ships/Simulation/ShipAiService.cs +++ b/apps/backend/Ships/Simulation/ShipAiService.cs @@ -2619,15 +2619,11 @@ internal sealed class ShipAiService { if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { - var storageModule = GetStorageRequirement(itemDefinition.CargoKind); + var storageModule = GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind); if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) { modules.Add(storageModule); } - else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) - { - modules.Add("module_arg_stor_container_m_01"); - } } } diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index 03e7648..3e00b35 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -796,14 +796,14 @@ internal sealed class SimulationProjectionService private static IReadOnlyList ToStationStorageUsageSnapshots(SimulationWorld world, StationRuntime station) { - string[] storageClasses = ["solid", "liquid", "container", "manufactured"]; - return storageClasses - .Select(storageClass => new StationStorageUsageSnapshot( - storageClass, + StorageKind[] storageKinds = [StorageKind.Solid, StorageKind.Liquid, StorageKind.Container]; + return storageKinds + .Select(storageKind => new StationStorageUsageSnapshot( + storageKind.ToDataValue(), station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind) .Sum(entry => entry.Value), - GetStationStorageCapacity(station, storageClass))) + GetStationStorageCapacity(world, station, storageKind))) .Where(snapshot => snapshot.Capacity > 0.01f) .ToList(); } diff --git a/apps/backend/Stations/Runtime/StationRuntimeModels.cs b/apps/backend/Stations/Runtime/StationRuntimeModels.cs index ffb0d35..1b3f45a 100644 --- a/apps/backend/Stations/Runtime/StationRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/StationRuntimeModels.cs @@ -1,3 +1,6 @@ +using SpaceGame.Api.Definitions; +using SpaceGame.Api.Shared.Runtime; + namespace SpaceGame.Api.Stations.Runtime; public sealed class StationRuntime @@ -33,12 +36,55 @@ public sealed class StationRuntime public string LastDeltaSignature { get; set; } = string.Empty; } -public sealed class StationModuleRuntime +public class StationModuleRuntime { public required string Id { get; init; } public required string ModuleId { get; init; } + public required ModuleType ModuleType { get; init; } public float Health { get; set; } public float MaxHealth { get; set; } + + public static StationModuleRuntime Create(string id, ModuleDefinition definition) => + definition switch + { + StorageModuleDefinition storage => new StorageStationModuleRuntime + { + Id = id, + ModuleId = storage.Id, + ModuleType = storage.ModuleType, + StorageKind = storage.StorageKind, + Health = storage.Hull, + MaxHealth = storage.Hull, + }, + ProductionModuleDefinition production => new ProductionStationModuleRuntime + { + Id = id, + ModuleId = production.Id, + ModuleType = production.ModuleType, + ProductItemIds = [.. production.ProductItemIds], + Health = production.Hull, + MaxHealth = production.Hull, + }, + _ => new StationModuleRuntime + { + Id = id, + ModuleId = definition.Id, + ModuleType = definition.ModuleType, + Health = definition.Hull, + MaxHealth = definition.Hull, + }, + }; +} + +public sealed class ProductionStationModuleRuntime : StationModuleRuntime +{ + public IReadOnlyList ProductItemIds { get; init; } = []; +} + +public sealed class StorageStationModuleRuntime : StationModuleRuntime +{ + public StorageKind StorageKind { get; init; } + public float CurrentLevel { get; set; } } public sealed class ModuleConstructionRuntime diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index 568c305..7a79aa3 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -162,21 +162,21 @@ internal sealed class InfrastructureSimulationService private static IEnumerable GetStoragePressureCandidates(SimulationWorld world, StationRuntime station) { - foreach (var (storageClass, moduleId) in new[] + foreach (var (storageKind, moduleId) in new[] { - ("solid", "module_arg_stor_solid_m_01"), - ("liquid", "module_arg_stor_liquid_m_01"), - ("container", "module_arg_stor_container_m_01"), + (StorageKind.Solid, "module_arg_stor_solid_m_01"), + (StorageKind.Liquid, "module_arg_stor_liquid_m_01"), + (StorageKind.Container, "module_arg_stor_container_m_01"), }) { - var capacity = GetStationStorageCapacity(station, storageClass); + var capacity = GetStationStorageCapacity(world, station, storageKind); if (capacity <= 0.01f) { continue; } var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind) .Sum(entry => entry.Value); if (used / capacity >= 0.65f) { @@ -195,14 +195,10 @@ internal sealed class InfrastructureSimulationService continue; } - if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId) { yield return storageModuleId; } - else - { - yield return "module_arg_stor_container_m_01"; - } } if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition)) @@ -214,14 +210,10 @@ internal sealed class InfrastructureSimulationService continue; } - if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + if (GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId) { yield return storageModuleId; } - else - { - yield return "module_arg_stor_container_m_01"; - } } } } @@ -324,16 +316,16 @@ internal sealed class InfrastructureSimulationService string? objectiveCommodityId, bool requiredByObjective) { - var storageClass = storageModuleId switch + var storageKind = storageModuleId switch { - "module_arg_stor_solid_m_01" => "solid", - "module_arg_stor_liquid_m_01" => "liquid", - _ => "container", + "module_arg_stor_solid_m_01" => StorageKind.Solid, + "module_arg_stor_liquid_m_01" => StorageKind.Liquid, + _ => StorageKind.Container, }; - var capacity = GetStationStorageCapacity(station, storageClass); + var capacity = GetStationStorageCapacity(world, station, storageKind); var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoStorageKind == storageKind) .Sum(entry => entry.Value); var utilization = capacity <= 0.01f ? 0f : used / capacity; @@ -342,8 +334,8 @@ internal sealed class InfrastructureSimulationService if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId)) { - var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) - || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass); + var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageKind) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageKind); if (objectiveUsesStorage) { score += 35f; @@ -579,15 +571,15 @@ internal sealed class InfrastructureSimulationService case "module_arg_stor_container_m_01": case "module_arg_stor_solid_m_01": case "module_arg_stor_liquid_m_01": - var storageClass = supportModuleId switch + var storageKind = supportModuleId switch { - "module_arg_stor_solid_m_01" => "solid", - "module_arg_stor_liquid_m_01" => "liquid", - _ => "container", + "module_arg_stor_solid_m_01" => StorageKind.Solid, + "module_arg_stor_liquid_m_01" => StorageKind.Liquid, + _ => StorageKind.Container, }; if (analysis.HasMissingOutputStorage - && (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) - || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass))) + && (ModuleNeedsStorageClass(world, objectiveModuleId, storageKind) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageKind))) { unlockScore += 70f; } @@ -688,7 +680,7 @@ internal sealed class InfrastructureSimulationService return demand; } - private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass) + private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, StorageKind storageKind) { if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) { @@ -697,12 +689,12 @@ internal sealed class InfrastructureSimulationService return recipe.Inputs.Any(input => world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition) - && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal)); + && itemDefinition.CargoStorageKind == storageKind); } - private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) => + private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, StorageKind storageKind) => world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition) - && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal); + && itemDefinition.CargoStorageKind == storageKind; private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount) { @@ -711,14 +703,19 @@ internal sealed class InfrastructureSimulationService return false; } - var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); + if (itemDefinition.CargoStorageKind is not { } storageKind) + { + return false; + } + + var capacity = GetStationStorageCapacity(world, station, storageKind); if (capacity <= 0.01f) { return false; } var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && string.Equals(definition.CargoKind, itemDefinition.CargoKind, StringComparison.Ordinal)) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind) .Sum(entry => entry.Value); return used + amount <= capacity * 0.95f; } diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index 7bbb7f0..5645fd4 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -39,7 +39,7 @@ internal sealed class StationLifecycleService var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds; var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater); var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater; - var habitatModules = CountModules(station.InstalledModules, world.ModuleDefinitions, ModuleType.Habitation); + var habitatModules = CountStationModules(station, ModuleType.Habitation); station.PopulationCapacity = 40f + (habitatModules * 220f); if (waterSatisfied) diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index e67251d..2aee6a2 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -1,5 +1,6 @@ using static SpaceGame.Api.Factions.AI.CommanderPlanningService; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using SpaceGame.Api.Shared.Runtime; namespace SpaceGame.Api.Stations.Simulation; @@ -54,14 +55,14 @@ internal sealed class StationSimulationService _ => 0f, }; var oreReserve = role == "refinery" ? 260f : 0f; - var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); - var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); + var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasShipyardCapability(station) ? 120f : 0f); + var claytronicsReserve = MathF.Max(constructionClayReserve, HasShipyardCapability(station) ? 120f : 0f); var grapheneReserve = role == "graphene" ? 120f : 0f; var siliconWafersReserve = role == "siliconwafers" ? 120f : 0f; var antimatterCellsReserve = role == "antimattercells" ? 120f : 0f; var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f; var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f; - var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") + var shipPartsReserve = HasShipyardCapability(station) && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f ? 90f : 0f; @@ -116,7 +117,7 @@ internal sealed class StationSimulationService var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts"); var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); - var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") + var shipPartsReserve = HasShipyardCapability(station) && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f ? 90f : 0f; @@ -151,8 +152,8 @@ internal sealed class StationSimulationService "refinery" => 80f, _ => 0f, }, constructionRefinedReserve), - "hullparts" => MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f) + shipPartsReserve, - "claytronics" => MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f), + "hullparts" => MathF.Max(constructionHullpartsReserve, HasShipyardCapability(station) ? 120f : 0f) + shipPartsReserve, + "claytronics" => MathF.Max(constructionClayReserve, HasShipyardCapability(station) ? 120f : 0f), "graphene" => MathF.Max(role == "graphene" ? 120f : 0f, role == "quantumtubes" ? 160f : 0f), "siliconwafers" => role == "siliconwafers" ? 120f : 0f, "antimattercells" => MathF.Max(role == "antimattercells" ? 120f : 0f, role == "claytronics" ? 120f : 0f), @@ -292,7 +293,7 @@ internal sealed class StationSimulationService if (outputItemIds.Contains("hullparts")) { - return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") + return HasShipyardCapability(station) && HasStationModules(station, "module_gen_prod_advancedelectronics_01") ? -140f * MathF.Max(expansionPressure, fleetPressure) : 280f * MathF.Max(expansionPressure, fleetPressure); } @@ -407,20 +408,25 @@ internal sealed class StationSimulationService return false; } - var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + var storageKind = itemDefinition.CargoStorageKind; + if (storageKind is null) { return false; } - var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); + if (!HasStorageCapacity(world, station, storageKind.Value)) + { + return false; + } + + var capacity = GetStationStorageCapacity(world, station, storageKind.Value); if (capacity <= 0.01f) { return false; } var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoStorageKind == storageKind) .Sum(entry => entry.Value); return used + amount <= capacity + 0.001f; } @@ -455,7 +461,7 @@ internal sealed class StationSimulationService return objective; } - if (HasStationModules(station, "module_gen_build_l_01")) + if (HasShipyardCapability(station)) { return "shipyard"; } @@ -513,6 +519,9 @@ internal sealed class StationSimulationService return "general"; } + private static bool HasShipyardCapability(StationRuntime station) => + CountStationModules(station, ModuleType.BuildModule) > 0; + private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId) { if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required)) diff --git a/apps/backend/Universe/Scenario/DataCatalogLoader.cs b/apps/backend/Universe/Scenario/DataCatalogLoader.cs index c93e67d..e87bbb4 100644 --- a/apps/backend/Universe/Scenario/DataCatalogLoader.cs +++ b/apps/backend/Universe/Scenario/DataCatalogLoader.cs @@ -16,7 +16,7 @@ internal sealed class DataCatalogLoader(string dataRoot) var authoredSystems = Read>("systems.json"); var scenario = Read("scenario.json"); var modules = NormalizeModules(Read>("modules.json")); - var ships = Read>("ships.json"); + var ships = NormalizeShips(Read>("ships.json")); var items = NormalizeItems(Read>("items.json")); var balance = Read("balance.json"); var recipes = BuildRecipes(items, ships, modules); @@ -263,15 +263,57 @@ internal sealed class DataCatalogLoader(string dataRoot) { item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group; } + + if (string.IsNullOrWhiteSpace(item.CargoKind)) + { + item.CargoStorageKind = null; + } + else + { + try + { + item.CargoStorageKind = item.CargoKind.ToStorageKind(); + item.CargoKind = item.CargoStorageKind.Value.ToDataValue(); + } + catch (ArgumentOutOfRangeException exception) + { + throw new InvalidOperationException($"Item '{item.Id}' has unsupported cargo kind '{item.CargoKind}'.", exception); + } + } } return items; } + private static List NormalizeShips(List ships) + { + foreach (var ship in ships) + { + if (string.IsNullOrWhiteSpace(ship.CargoKind)) + { + ship.CargoStorageKind = null; + continue; + } + + try + { + ship.CargoStorageKind = ship.CargoKind.ToStorageKind(); + ship.CargoKind = ship.CargoStorageKind.Value.ToDataValue(); + } + catch (ArgumentOutOfRangeException exception) + { + throw new InvalidOperationException($"Ship '{ship.Id}' has unsupported cargo kind '{ship.CargoKind}'.", exception); + } + } + + return ships; + } + private static List NormalizeModules(List modules) { - foreach (var module in modules) + for (var index = 0; index < modules.Count; index += 1) { + var module = modules[index]; try { module.ModuleType = module.Type.ToModuleType(); @@ -299,10 +341,39 @@ internal sealed class DataCatalogLoader(string dataRoot) { module.WorkforceNeeded = module.WorkForce?.Max ?? 0f; } + + modules[index] = CreateSpecializedModuleDefinition(module); } return modules; } + + private static ModuleDefinition CreateSpecializedModuleDefinition(ModuleDefinition module) + { + if (module.ModuleType == ModuleType.Storage) + { + if (module.Cargo is null) + { + throw new InvalidOperationException($"Storage module '{module.Id}' is missing cargo metadata."); + } + + try + { + return new StorageModuleDefinition(module, module.Cargo.Type.ToStorageKind(), module.Cargo.Max); + } + catch (ArgumentOutOfRangeException exception) + { + throw new InvalidOperationException($"Storage module '{module.Id}' has unsupported cargo type '{module.Cargo.Type}'.", exception); + } + } + + if (module.Products.Count > 0) + { + return new ProductionModuleDefinition(module); + } + + return module; + } } internal sealed record ScenarioCatalog( diff --git a/apps/backend/Universe/Scenario/LoaderSupport.cs b/apps/backend/Universe/Scenario/LoaderSupport.cs index 4050fac..96df2d4 100644 --- a/apps/backend/Universe/Scenario/LoaderSupport.cs +++ b/apps/backend/Universe/Scenario/LoaderSupport.cs @@ -101,13 +101,7 @@ internal static class LoaderSupport return; } - station.Modules.Add(new StationModuleRuntime - { - Id = $"{station.Id}-module-{station.Modules.Count + 1}", - ModuleId = moduleId, - Health = definition.Hull, - MaxHealth = definition.Hull, - }); + station.Modules.Add(StationModuleRuntime.Create($"{station.Id}-module-{station.Modules.Count + 1}", definition)); station.Radius = GetStationRadius(moduleDefinitions, station); } @@ -126,14 +120,6 @@ internal static class LoaderSupport internal static int CountModules(IEnumerable modules, string moduleId) => modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); - internal static int CountModules( - IEnumerable modules, - IReadOnlyDictionary moduleDefinitions, - ModuleType moduleType) => - modules.Count(moduleId => - moduleDefinitions.TryGetValue(moduleId, out var definition) - && definition.ModuleType == moduleType); - internal static float ComputeWorkforceRatio(float population, float workforceRequired) { if (workforceRequired <= 0.01f) diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 48dc6ad..63628fb 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, catalog.ModuleDefinitions); + seedingService.InitializeStationStockpiles(stations); 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); @@ -218,14 +218,10 @@ internal sealed class WorldBuilder( continue; } - var storageModuleId = itemDefinition.CargoKind switch + if (SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStorageRequirement(moduleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId) { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => "module_arg_stor_container_m_01", - }; - - yield return storageModuleId; + yield return storageModuleId; + } } } diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 3df2b8d..b4bb24c 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -1,4 +1,3 @@ -using SpaceGame.Api.Shared.Runtime; using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; @@ -61,13 +60,11 @@ internal sealed class WorldSeedingService } } - internal void InitializeStationStockpiles( - IReadOnlyCollection stations, - IReadOnlyDictionary moduleDefinitions) + internal void InitializeStationStockpiles(IReadOnlyCollection stations) { foreach (var station in stations) { - InitializeStationPopulation(station, moduleDefinitions); + InitializeStationPopulation(station); } } @@ -244,12 +241,10 @@ internal sealed class WorldSeedingService continue; } - yield return itemDefinition.CargoKind switch + if (SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoStorageKind) is { } storageModuleId) { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => "module_arg_stor_container_m_01", - }; + yield return storageModuleId; + } } } @@ -554,11 +549,9 @@ internal sealed class WorldSeedingService }; } - private static void InitializeStationPopulation( - StationRuntime station, - IReadOnlyDictionary moduleDefinitions) + private static void InitializeStationPopulation(StationRuntime station) { - var habitatModules = CountModules(station.InstalledModules, moduleDefinitions, ModuleType.Habitation); + 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