From 766fef1c8f21c6968e52a188ebc3cbc9b9912217 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Tue, 24 Mar 2026 02:55:15 -0400 Subject: [PATCH] chore: add .editorconfig and consistent formatting for backend projects Adds an `.editorconfig` file with C# and project-specific conventions. Applies consistent indentation and formatting across backend handlers, runtime models, and AI services. --- apps/backend/.editorconfig | 54 + apps/backend/Definitions/WorldDefinitions.cs | 414 +- .../Economy/Runtime/CommerceRuntimeModels.cs | 50 +- .../Factions/AI/CommanderPlanningService.cs | 6390 ++++++++--------- .../Factions/Runtime/FactionRuntimeModels.cs | 518 +- .../Runtime/GeopoliticalRuntimeModels.cs | 462 +- .../GeopoliticalSimulationService.cs | 1770 ++--- .../Planning/CommodityOperationalSignal.cs | 84 +- .../Planning/FactionEconomySnapshot.cs | 324 +- .../Planning/FactionIndustryPlanner.cs | 1044 +-- .../Industry/Planning/ProductionGraph.cs | 70 +- .../Planning/ProductionGraphBuilder.cs | 178 +- .../Api/CreatePlayerOrganizationHandler.cs | 42 +- .../Api/DeletePlayerDirectiveHandler.cs | 30 +- .../Api/DeletePlayerOrganizationHandler.cs | 44 +- .../Api/GetPlayerFactionHandler.cs | 28 +- ...datePlayerOrganizationMembershipHandler.cs | 58 +- .../Api/UpdatePlayerStrategicIntentHandler.cs | 28 +- .../Api/UpsertPlayerAssignmentHandler.cs | 38 +- .../UpsertPlayerAutomationPolicyHandler.cs | 32 +- .../Api/UpsertPlayerDirectiveHandler.cs | 32 +- .../Api/UpsertPlayerPolicyHandler.cs | 32 +- .../UpsertPlayerProductionProgramHandler.cs | 32 +- .../UpsertPlayerReinforcementPolicyHandler.cs | 32 +- .../Runtime/PlayerFactionRuntimeModels.cs | 474 +- .../Simulation/PlayerFactionService.cs | 4786 ++++++------ apps/backend/Program.cs | 14 +- .../backend/Shared/Runtime/SimulationKinds.cs | 404 +- .../Runtime/SimulationRuntimeSupport.cs | 292 +- .../Ships/Api/EnqueueShipOrderHandler.cs | 52 +- .../Ships/Api/RemoveShipOrderHandler.cs | 32 +- .../Api/UpdateShipDefaultBehaviorHandler.cs | 38 +- .../Ships/Runtime/ShipRuntimeModels.cs | 248 +- .../backend/Ships/Simulation/ShipAiService.cs | 4642 ++++++------ .../Simulation/Core/SimulationEngine.cs | 246 +- .../Core/SimulationProjectionService.cs | 3702 +++++----- .../Runtime/ConstructionRuntimeModels.cs | 54 +- .../Stations/Runtime/StationRuntimeModels.cs | 74 +- .../InfrastructureSimulationService.cs | 1656 ++--- .../Simulation/StationLifecycleService.cs | 350 +- .../Simulation/StationSimulationService.cs | 1376 ++-- .../backend/Universe/Api/GetBalanceHandler.cs | 14 +- .../Universe/Api/GetTelemetryHandler.cs | 78 +- apps/backend/Universe/Api/GetWorldHandler.cs | 14 +- .../Universe/Api/GetWorldHealthHandler.cs | 30 +- .../backend/Universe/Api/ResetWorldHandler.cs | 14 +- .../Universe/Api/RootRedirectHandler.cs | 20 +- .../Universe/Api/StreamWorldHandler.cs | 78 +- .../Universe/Api/UpdateBalanceHandler.cs | 20 +- .../Universe/Scenario/DataCatalogLoader.cs | 496 +- .../Universe/Scenario/LoaderSupport.cs | 172 +- .../Universe/Scenario/ScenarioLoader.cs | 34 +- .../Universe/Scenario/SpatialBuilder.cs | 538 +- .../Scenario/SystemGenerationService.cs | 1054 +-- .../backend/Universe/Scenario/WorldBuilder.cs | 644 +- .../Universe/Scenario/WorldSeedingService.cs | 1070 +-- .../Simulation/OrbitalSimulationOptions.cs | 2 +- .../Simulation/SimulationHostedService.cs | 24 +- .../Universe/Simulation/TelemetryService.cs | 66 +- .../Simulation/WorldGenerationOptions.cs | 6 +- .../Universe/Simulation/WorldService.cs | 920 +-- 61 files changed, 17787 insertions(+), 17733 deletions(-) create mode 100644 apps/backend/.editorconfig diff --git a/apps/backend/.editorconfig b/apps/backend/.editorconfig new file mode 100644 index 0000000..05c2ded --- /dev/null +++ b/apps/backend/.editorconfig @@ -0,0 +1,54 @@ +root = true + +[*.{cs,csx}] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# .NET/C# conventions +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +csharp_prefer_braces = true:suggestion +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_primary_constructors = false:silent +csharp_new_line_before_open_brace = all + +[*.{csproj,props,targets,sln,slnx}] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{json,jsonc}] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index f4e163e..7c89f1b 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -4,323 +4,323 @@ namespace SpaceGame.Api.Definitions; public sealed class ConstructionDefinition { - public string? RecipeId { get; set; } - public string FacilityCategory { get; set; } = "station"; - public List RequiredModules { get; set; } = []; - public List Requirements { get; set; } = []; - public float CycleTime { get; set; } - public float BatchSize { get; set; } = 1f; - public float ProductsPerHour { get; set; } - public float MaxEfficiency { get; set; } = 1f; - public int Priority { get; set; } + public string? RecipeId { get; set; } + public string FacilityCategory { get; set; } = "station"; + public List RequiredModules { get; set; } = []; + public List Requirements { get; set; } = []; + public float CycleTime { get; set; } + public float BatchSize { get; set; } = 1f; + public float ProductsPerHour { get; set; } + public float MaxEfficiency { get; set; } = 1f; + public int Priority { get; set; } } public sealed class ItemPriceDefinition { - public float Min { get; set; } - public float Max { get; set; } - public float Avg { get; set; } + public float Min { get; set; } + public float Max { get; set; } + public float Avg { get; set; } } public sealed class ItemEffectDefinition { - public required string Type { get; set; } - public float Product { get; set; } + public required string Type { get; set; } + public float Product { get; set; } } public sealed class ItemProductionDefinition { - public float Time { get; set; } - public float Amount { get; set; } - public string Method { get; set; } = "default"; - public string Name { get; set; } = "Universal"; - public List Wares { get; set; } = []; - public List Effects { get; set; } = []; + public float Time { get; set; } + public float Amount { get; set; } + public string Method { get; set; } = "default"; + public string Name { get; set; } = "Universal"; + public List Wares { get; set; } = []; + public List Effects { get; set; } = []; } public sealed class BalanceDefinition { - public float SimulationSpeedMultiplier { get; set; } = 1f; - public float YPlane { get; set; } - public float ArrivalThreshold { get; set; } - public float MiningRate { get; set; } - public float MiningCycleSeconds { get; set; } - public float TransferRate { get; set; } - public float DockingDuration { get; set; } - public float UndockingDuration { get; set; } - public float UndockDistance { get; set; } + public float SimulationSpeedMultiplier { get; set; } = 1f; + public float YPlane { get; set; } + public float ArrivalThreshold { get; set; } + public float MiningRate { get; set; } + public float MiningCycleSeconds { get; set; } + public float TransferRate { get; set; } + public float DockingDuration { get; set; } + public float UndockingDuration { get; set; } + public float UndockDistance { get; set; } } public sealed class StarDefinition { - public string Kind { get; set; } = "main-sequence"; - public required string Color { get; set; } - public required string Glow { get; set; } - public float Size { get; set; } - public float OrbitRadius { get; set; } - public float OrbitSpeed { get; set; } - public float OrbitPhaseAtEpoch { get; set; } + public string Kind { get; set; } = "main-sequence"; + public required string Color { get; set; } + public required string Glow { get; set; } + public float Size { get; set; } + public float OrbitRadius { get; set; } + public float OrbitSpeed { get; set; } + public float OrbitPhaseAtEpoch { get; set; } } public sealed class MoonDefinition { - public required string Label { get; set; } - public float Size { get; set; } - public required string Color { get; set; } - public float OrbitRadius { get; set; } - public float OrbitSpeed { get; set; } - public float OrbitPhaseAtEpoch { get; set; } - public float OrbitInclination { get; set; } - public float OrbitLongitudeOfAscendingNode { get; set; } + public required string Label { get; set; } + public float Size { get; set; } + public required string Color { get; set; } + public float OrbitRadius { get; set; } + public float OrbitSpeed { get; set; } + public float OrbitPhaseAtEpoch { get; set; } + public float OrbitInclination { get; set; } + public float OrbitLongitudeOfAscendingNode { get; set; } } public sealed class SolarSystemDefinition { - public required string Id { get; set; } - public required string Label { get; set; } - public required float[] Position { get; set; } - public required List Stars { get; set; } - public required AsteroidFieldDefinition AsteroidField { get; set; } - public required List ResourceNodes { get; set; } - public required List Planets { get; set; } + public required string Id { get; set; } + public required string Label { get; set; } + public required float[] Position { get; set; } + public required List Stars { get; set; } + public required AsteroidFieldDefinition AsteroidField { get; set; } + public required List ResourceNodes { get; set; } + public required List Planets { get; set; } } public sealed class AsteroidFieldDefinition { - public int DecorationCount { get; set; } - public float RadiusOffset { get; set; } - public float RadiusVariance { get; set; } - public float HeightVariance { get; set; } + public int DecorationCount { get; set; } + public float RadiusOffset { get; set; } + public float RadiusVariance { get; set; } + public float HeightVariance { get; set; } } public sealed class ResourceNodeDefinition { - public string SourceKind { get; set; } = "local-space"; - public string? AnchorReference { get; set; } - public float Angle { get; set; } - public float RadiusOffset { get; set; } - public float InclinationDegrees { get; set; } - public int? AnchorPlanetIndex { get; set; } - public int? AnchorMoonIndex { get; set; } - public float OreAmount { get; set; } - public required string ItemId { get; set; } - public int ShardCount { get; set; } + public string SourceKind { get; set; } = "local-space"; + public string? AnchorReference { get; set; } + public float Angle { get; set; } + public float RadiusOffset { get; set; } + public float InclinationDegrees { get; set; } + public int? AnchorPlanetIndex { get; set; } + public int? AnchorMoonIndex { get; set; } + public float OreAmount { get; set; } + public required string ItemId { get; set; } + public int ShardCount { get; set; } } public sealed class ItemDefinition { - public required string Id { get; set; } - public required string Name { get; set; } - public string Description { get; set; } = string.Empty; - public string Type { get; set; } = "material"; - public string CargoKind { get; set; } = string.Empty; - public float Volume { get; set; } = 1f; - public int Version { get; set; } - public string FactoryName { get; set; } = string.Empty; - public string Icon { get; set; } = string.Empty; - public string Group { get; set; } = string.Empty; - public ItemPriceDefinition? Price { get; set; } - public List Illegal { get; set; } = []; - public List Production { get; set; } = []; - public ConstructionDefinition? Construction { get; set; } - [JsonPropertyName("transport")] - public string Transport - { - set => CargoKind = value; - } + public required string Id { get; set; } + public required string Name { get; set; } + public string Description { get; set; } = string.Empty; + public string Type { get; set; } = "material"; + public string CargoKind { get; set; } = string.Empty; + public float Volume { get; set; } = 1f; + public int Version { get; set; } + public string FactoryName { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string Group { get; set; } = string.Empty; + public ItemPriceDefinition? Price { get; set; } + public List Illegal { get; set; } = []; + public List Production { get; set; } = []; + public ConstructionDefinition? Construction { get; set; } + [JsonPropertyName("transport")] + public string Transport + { + set => CargoKind = value; + } } public sealed class RecipeOutputDefinition { - public required string ItemId { get; set; } - public float Amount { get; set; } + public required string ItemId { get; set; } + public float Amount { get; set; } } public sealed class RecipeInputDefinition { - public string ItemId { get; set; } = string.Empty; - public float Amount { get; set; } - [JsonPropertyName("ware")] - public string Ware - { - set => ItemId = value; - } + public string ItemId { get; set; } = string.Empty; + public float Amount { get; set; } + [JsonPropertyName("ware")] + public string Ware + { + set => ItemId = value; + } } public sealed class ModuleConstructionDefinition { - public required List Requirements { get; set; } - public float ProductionTime { get; set; } + public required List Requirements { get; set; } + public float ProductionTime { get; set; } } public sealed class ModuleDockDefinition { - public int Capacity { get; set; } - public required string Size { get; set; } + public int Capacity { get; set; } + public required string Size { get; set; } } public sealed class ModuleCargoDefinition { - public float Max { get; set; } - public required string Type { get; set; } + public float Max { get; set; } + public required string Type { get; set; } } public sealed class ModuleWorkForceDefinition { - public float Capacity { get; set; } - public float Max { get; set; } - public string Race { get; set; } = string.Empty; + public float Capacity { get; set; } + public float Max { get; set; } + public string Race { get; set; } = string.Empty; } public sealed class ModuleMountDefinition { - public required string Group { get; set; } - public required string Size { get; set; } - public bool Hittable { get; set; } - public List Types { get; set; } = []; + public required string Group { get; set; } + public required string Size { get; set; } + public bool Hittable { get; set; } + public List Types { get; set; } = []; } public sealed class ModuleProductionDefinition { - public float Time { get; set; } - public float Amount { get; set; } - public string Method { get; set; } = "default"; - public string Name { get; set; } = "Universal"; - public List Wares { get; set; } = []; + public float Time { get; set; } + public float Amount { get; set; } + public string Method { get; set; } = "default"; + public string Name { get; set; } = "Universal"; + public List Wares { get; set; } = []; } public sealed class ModuleDefinition { - public required string Id { get; set; } - public required string Name { get; set; } - public string Description { get; set; } = string.Empty; - public required string Type { get; set; } - [JsonIgnore] - public string? Product { get; set; } - public List Products { get; set; } = []; - public string ProductionMode { get; set; } = "passive"; - 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; - public int ExplosionDamage { get; set; } - public ItemPriceDefinition? Price { get; set; } - public List Owners { get; set; } = []; - public ModuleCargoDefinition? Cargo { get; set; } - public ModuleWorkForceDefinition? WorkForce { 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 - { - get => Products; - set => Products = value ?? []; - } + public required string Id { get; set; } + public required string Name { get; set; } + public string Description { get; set; } = string.Empty; + public required string Type { get; set; } + [JsonIgnore] + public string? Product { get; set; } + public List Products { get; set; } = []; + public string ProductionMode { get; set; } = "passive"; + 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; + public int ExplosionDamage { get; set; } + public ItemPriceDefinition? Price { get; set; } + public List Owners { get; set; } = []; + public ModuleCargoDefinition? Cargo { get; set; } + public ModuleWorkForceDefinition? WorkForce { 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 + { + get => Products; + set => Products = value ?? []; + } } public sealed class ModuleRecipeDefinition { - public required string ModuleId { get; set; } - public float Duration { get; set; } - public required List Inputs { get; set; } + public required string ModuleId { get; set; } + public float Duration { get; set; } + public required List Inputs { get; set; } } public sealed class RecipeDefinition { - public required string Id { get; set; } - public required string Label { get; set; } - public required string FacilityCategory { get; set; } - public float Duration { get; set; } - public int Priority { get; set; } - public List RequiredModules { get; set; } = []; - public List Inputs { get; set; } = []; - public List Outputs { get; set; } = []; - public string? ShipOutputId { get; set; } + public required string Id { get; set; } + public required string Label { get; set; } + public required string FacilityCategory { get; set; } + public float Duration { get; set; } + public int Priority { get; set; } + public List RequiredModules { get; set; } = []; + public List Inputs { get; set; } = []; + public List Outputs { get; set; } = []; + public string? ShipOutputId { get; set; } } public sealed class PlanetDefinition { - public required string Label { get; set; } - public string PlanetType { get; set; } = "terrestrial"; - public string Shape { get; set; } = "sphere"; - public List Moons { get; set; } = []; - public float OrbitRadius { get; set; } - public float OrbitSpeed { get; set; } - public float OrbitEccentricity { get; set; } - public float OrbitInclination { get; set; } - public float OrbitLongitudeOfAscendingNode { get; set; } - public float OrbitArgumentOfPeriapsis { get; set; } - public float OrbitPhaseAtEpoch { get; set; } - public float Size { get; set; } - public required string Color { get; set; } - public float Tilt { get; set; } - public bool HasRing { get; set; } + public required string Label { get; set; } + public string PlanetType { get; set; } = "terrestrial"; + public string Shape { get; set; } = "sphere"; + public List Moons { get; set; } = []; + public float OrbitRadius { get; set; } + public float OrbitSpeed { get; set; } + public float OrbitEccentricity { get; set; } + public float OrbitInclination { get; set; } + public float OrbitLongitudeOfAscendingNode { get; set; } + public float OrbitArgumentOfPeriapsis { get; set; } + public float OrbitPhaseAtEpoch { get; set; } + public float Size { get; set; } + public required string Color { get; set; } + public float Tilt { get; set; } + public bool HasRing { get; set; } } public sealed class ShipDefinition { - public required string Id { get; set; } - public required string Label { get; set; } - public required string Kind { get; set; } - public required string Class { get; set; } - public float Speed { get; set; } - public float WarpSpeed { get; set; } - public float FtlSpeed { get; set; } - public float SpoolTime { get; set; } - public float CargoCapacity { get; set; } - public string? CargoKind { get; set; } - public required string Color { get; set; } - public required string HullColor { get; set; } - public float Size { get; set; } - public float MaxHealth { get; set; } - public List Capabilities { get; set; } = []; - public ConstructionDefinition? Construction { get; set; } + public required string Id { get; set; } + public required string Label { get; set; } + public required string Kind { get; set; } + public required string Class { get; set; } + public float Speed { get; set; } + public float WarpSpeed { get; set; } + public float FtlSpeed { get; set; } + public float SpoolTime { get; set; } + public float CargoCapacity { get; set; } + public string? CargoKind { get; set; } + public required string Color { get; set; } + public required string HullColor { get; set; } + public float Size { get; set; } + public float MaxHealth { get; set; } + public List Capabilities { get; set; } = []; + public ConstructionDefinition? Construction { get; set; } } public sealed class ScenarioDefinition { - public required List InitialStations { get; set; } - public required List ShipFormations { get; set; } - public required List PatrolRoutes { get; set; } - public required MiningDefaultsDefinition MiningDefaults { get; set; } + public required List InitialStations { get; set; } + public required List ShipFormations { get; set; } + public required List PatrolRoutes { get; set; } + public required MiningDefaultsDefinition MiningDefaults { get; set; } } public sealed class InitialStationDefinition { - public required string SystemId { get; set; } - public string Label { get; set; } = "Orbital Station"; - public string Color { get; set; } = "#8df0d2"; - public string Objective { get; set; } = "general"; - public List StartingModules { get; set; } = []; - public string? FactionId { get; set; } - public int? PlanetIndex { get; set; } - public int? LagrangeSide { get; set; } - public float[]? Position { get; set; } + public required string SystemId { get; set; } + public string Label { get; set; } = "Orbital Station"; + public string Color { get; set; } = "#8df0d2"; + public string Objective { get; set; } = "general"; + public List StartingModules { get; set; } = []; + public string? FactionId { get; set; } + public int? PlanetIndex { get; set; } + public int? LagrangeSide { get; set; } + public float[]? Position { get; set; } } public sealed class ShipFormationDefinition { - public required string ShipId { get; set; } - public int Count { get; set; } - public required float[] Center { get; set; } - public required string SystemId { get; set; } - public string? FactionId { get; set; } - public Dictionary StartingInventory { get; set; } = new(StringComparer.Ordinal); + public required string ShipId { get; set; } + public int Count { get; set; } + public required float[] Center { get; set; } + public required string SystemId { get; set; } + public string? FactionId { get; set; } + public Dictionary StartingInventory { get; set; } = new(StringComparer.Ordinal); } public sealed class PatrolRouteDefinition { - public required string SystemId { get; set; } - public required List Points { get; set; } + public required string SystemId { get; set; } + public required List Points { get; set; } } public sealed class MiningDefaultsDefinition { - public required string NodeSystemId { get; set; } - public required string RefinerySystemId { get; set; } + public required string NodeSystemId { get; set; } + public required string RefinerySystemId { get; set; } } diff --git a/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs b/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs index 8d97348..7ac7dde 100644 --- a/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs +++ b/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs @@ -2,33 +2,33 @@ namespace SpaceGame.Api.Economy.Runtime; public sealed class MarketOrderRuntime { - public required string Id { get; init; } - public required string FactionId { get; init; } - public string? StationId { get; init; } - public string? ConstructionSiteId { get; init; } - public required string Kind { get; init; } - public required string ItemId { get; init; } - public float Amount { get; init; } - public float RemainingAmount { get; set; } - public float Valuation { get; set; } - public float? ReserveThreshold { get; set; } - public string? PolicySetId { get; set; } - public string State { get; set; } = MarketOrderStateKinds.Open; - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string FactionId { get; init; } + public string? StationId { get; init; } + public string? ConstructionSiteId { get; init; } + public required string Kind { get; init; } + public required string ItemId { get; init; } + public float Amount { get; init; } + public float RemainingAmount { get; set; } + public float Valuation { get; set; } + public float? ReserveThreshold { get; set; } + public string? PolicySetId { get; set; } + public string State { get; set; } = MarketOrderStateKinds.Open; + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class PolicySetRuntime { - public required string Id { get; init; } - public required string OwnerKind { get; init; } - public required string OwnerId { get; init; } - public string TradeAccessPolicy { get; set; } = "owner-and-allies"; - public string DockingAccessPolicy { get; set; } = "owner-and-allies"; - public string ConstructionAccessPolicy { get; set; } = "owner-only"; - public string OperationalRangePolicy { get; set; } = "unrestricted"; - public string CombatEngagementPolicy { get; set; } = "defensive"; - public bool AvoidHostileSystems { get; set; } = true; - public float FleeHullRatio { get; set; } = 0.35f; - public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string OwnerKind { get; init; } + public required string OwnerId { get; init; } + public string TradeAccessPolicy { get; set; } = "owner-and-allies"; + public string DockingAccessPolicy { get; set; } = "owner-and-allies"; + public string ConstructionAccessPolicy { get; set; } = "owner-only"; + public string OperationalRangePolicy { get; set; } = "unrestricted"; + public string CombatEngagementPolicy { get; set; } = "defensive"; + public bool AvoidHostileSystems { get; set; } = true; + public float FleeHullRatio { get; set; } = 0.35f; + public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); + public string LastDeltaSignature { get; set; } = string.Empty; } diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index e359c61..3ea4445 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -6,1807 +6,1807 @@ namespace SpaceGame.Api.Factions.AI; internal sealed class CommanderPlanningService { - private const float FactionCommanderReplanInterval = 8f; - private const float FleetCommanderReplanInterval = 4f; - private const float StationCommanderReplanInterval = 5f; - private const float ShipCommanderReplanInterval = 3f; - private const int MaxDecisionLogEntries = 40; - private const int MaxOutcomeEntries = 32; - private const int MaxAiOrdersPerShip = 2; + private const float FactionCommanderReplanInterval = 8f; + private const float FleetCommanderReplanInterval = 4f; + private const float StationCommanderReplanInterval = 5f; + private const float ShipCommanderReplanInterval = 3f; + private const int MaxDecisionLogEntries = 40; + private const int MaxOutcomeEntries = 32; + private const int MaxAiOrdersPerShip = 2; - internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection events) - { - EnsureHierarchy(world); - - foreach (var commander in world.Commanders) + internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection events) { - if (!commander.IsAlive) - { - continue; - } + EnsureHierarchy(world); - if (commander.ReplanTimer > 0f) - { - commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds); - } - } - - foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList()) - { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) - { - continue; - } - - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) - { - continue; - } - - UpdateFactionCommander(world, commander, events); - } - - foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList()) - { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) - { - continue; - } - - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) - { - continue; - } - - UpdateFleetCommander(world, commander); - } - - foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList()) - { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) - { - continue; - } - - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) - { - continue; - } - - UpdateStationCommander(world, commander); - } - - foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList()) - { - if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) - { - continue; - } - - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) - { - continue; - } - - UpdateShipCommander(world, commander, events); - } - } - - internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => - world.Commanders.FirstOrDefault(commander => - commander.Kind == CommanderKind.Faction && - commander.FactionId == factionId); - - internal static FactionRuntime? FindFaction(SimulationWorld world, string factionId) => - world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, factionId, StringComparison.Ordinal)); - - internal static FactionStrategicStateRuntime? FindFactionStrategicState(SimulationWorld world, string factionId) => - FindFaction(world, factionId)?.StrategicState; - - internal static FactionEconomicAssessmentRuntime? FindFactionEconomicAssessment(SimulationWorld world, string factionId) => - FindFactionStrategicState(world, factionId)?.EconomicAssessment; - - internal static FactionThreatAssessmentRuntime? FindFactionThreatAssessment(SimulationWorld world, string factionId) => - FindFactionStrategicState(world, factionId)?.ThreatAssessment; - - private static void EnsureHierarchy(SimulationWorld world) - { - var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); - var factionCommanders = world.Commanders - .Where(commander => commander.Kind == CommanderKind.Faction) - .ToDictionary(commander => commander.FactionId, StringComparer.Ordinal); - - foreach (var faction in world.Factions) - { - EnsureFactionStateDefaults(world, faction); - - if (!factionCommanders.TryGetValue(faction.Id, out var commander)) - { - commander = new CommanderRuntime + foreach (var commander in world.Commanders) { - Id = $"commander-faction-{faction.Id}", - Kind = CommanderKind.Faction, - FactionId = faction.Id, - ControlledEntityId = faction.Id, - PolicySetId = faction.DefaultPolicySetId, - Doctrine = "strategic-control", - Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 5 }, - }; - world.Commanders.Add(commander); - commandersById[commander.Id] = commander; - factionCommanders[faction.Id] = commander; - } - } + if (!commander.IsAlive) + { + continue; + } - foreach (var commander in world.Commanders) - { - commander.SubordinateCommanderIds.Clear(); - } - - var stationCommanders = new Dictionary(StringComparer.Ordinal); - foreach (var station in world.Stations) - { - if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander)) - { - continue; - } - - var commander = world.Commanders.FirstOrDefault(candidate => - candidate.Kind == CommanderKind.Station && - string.Equals(candidate.ControlledEntityId, station.Id, StringComparison.Ordinal)); - if (commander is null) - { - commander = new CommanderRuntime - { - Id = $"commander-station-{station.Id}", - Kind = CommanderKind.Station, - FactionId = station.FactionId, - ControlledEntityId = station.Id, - Doctrine = "station-control", - Skills = new CommanderSkillProfileRuntime - { - Leadership = 3, - Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), - Strategy = 3, - }, - }; - world.Commanders.Add(commander); - } - - commander.ParentCommanderId = parentCommander.Id; - commander.PolicySetId = parentCommander.PolicySetId; - station.CommanderId = commander.Id; - station.PolicySetId ??= parentCommander.PolicySetId; - stationCommanders[station.Id] = commander; - } - - foreach (var ship in world.Ships) - { - if (!factionCommanders.TryGetValue(ship.FactionId, out var factionCommander)) - { - continue; - } - - var commander = world.Commanders.FirstOrDefault(candidate => - candidate.Kind == CommanderKind.Ship && - string.Equals(candidate.ControlledEntityId, ship.Id, StringComparison.Ordinal)); - if (commander is null) - { - commander = new CommanderRuntime - { - Id = $"commander-ship-{ship.Id}", - Kind = CommanderKind.Ship, - FactionId = ship.FactionId, - ControlledEntityId = ship.Id, - Doctrine = "ship-control", - Skills = new CommanderSkillProfileRuntime - { - Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), - Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), - Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), - }, - }; - world.Commanders.Add(commander); - } - - var parentCommander = ResolveShipParentCommander(world, ship, factionCommander, stationCommanders); - commander.ParentCommanderId = parentCommander.Id; - commander.PolicySetId = parentCommander.PolicySetId; - ship.CommanderId = commander.Id; - ship.PolicySetId ??= parentCommander.PolicySetId; - } - - foreach (var commander in world.Commanders) - { - if (commander.ParentCommanderId is not null && commandersById.TryGetValue(commander.ParentCommanderId, out var parent)) - { - parent.SubordinateCommanderIds.Add(commander.Id); - } - } - - foreach (var faction in world.Factions) - { - faction.CommanderIds.Clear(); - } - - foreach (var commander in world.Commanders) - { - if (world.Factions.FirstOrDefault(faction => faction.Id == commander.FactionId) is { } faction) - { - faction.CommanderIds.Add(commander.Id); - } - } - } - - private static void EnsureFactionStateDefaults(SimulationWorld world, FactionRuntime faction) - { - faction.Doctrine.StrategicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.StrategicPosture) ? "balanced" : faction.Doctrine.StrategicPosture; - faction.Doctrine.ExpansionPosture = string.IsNullOrWhiteSpace(faction.Doctrine.ExpansionPosture) ? "measured" : faction.Doctrine.ExpansionPosture; - faction.Doctrine.MilitaryPosture = string.IsNullOrWhiteSpace(faction.Doctrine.MilitaryPosture) ? "defensive" : faction.Doctrine.MilitaryPosture; - faction.Doctrine.EconomicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.EconomicPosture) ? "self-sufficient" : faction.Doctrine.EconomicPosture; - faction.Doctrine.DesiredControlledSystems = Math.Max(2, Math.Min(world.Systems.Count, faction.Doctrine.DesiredControlledSystems <= 0 ? 3 : faction.Doctrine.DesiredControlledSystems)); - faction.Doctrine.DesiredMilitaryPerFront = Math.Max(1, faction.Doctrine.DesiredMilitaryPerFront); - faction.Doctrine.DesiredMinersPerSystem = Math.Max(1, faction.Doctrine.DesiredMinersPerSystem); - faction.Doctrine.DesiredTransportsPerSystem = Math.Max(1, faction.Doctrine.DesiredTransportsPerSystem); - faction.Doctrine.DesiredConstructors = Math.Max(1, faction.Doctrine.DesiredConstructors); - faction.Doctrine.ReserveCreditsRatio = ClampRatio(faction.Doctrine.ReserveCreditsRatio, 0.2f); - faction.Doctrine.ExpansionBudgetRatio = ClampRatio(faction.Doctrine.ExpansionBudgetRatio, 0.25f); - faction.Doctrine.WarBudgetRatio = ClampRatio(faction.Doctrine.WarBudgetRatio, 0.35f); - faction.Doctrine.ReserveMilitaryRatio = ClampRatio(faction.Doctrine.ReserveMilitaryRatio, 0.2f); - faction.Doctrine.OffensiveReadinessThreshold = ClampRatio(faction.Doctrine.OffensiveReadinessThreshold, 0.62f); - faction.Doctrine.SupplySecurityBias = ClampRatio(faction.Doctrine.SupplySecurityBias, 0.55f); - faction.Doctrine.FailureAversion = ClampRatio(faction.Doctrine.FailureAversion, 0.45f); - faction.Doctrine.ReinforcementLeadPerFront = Math.Max(1, faction.Doctrine.ReinforcementLeadPerFront); - } - - private static float ClampRatio(float value, float fallback) => - value is >= 0f and <= 1f ? value : fallback; - - private static CommanderRuntime ResolveShipParentCommander( - SimulationWorld world, - ShipRuntime ship, - CommanderRuntime factionCommander, - IReadOnlyDictionary stationCommanders) - { - if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) - { - return factionCommander; - } - - var stationCommander = world.Stations - .Where(station => string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)) - .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .Select(station => station.CommanderId is not null && stationCommanders.TryGetValue(station.Id, out var commander) - ? commander - : null) - .FirstOrDefault(candidate => candidate is not null); - - return stationCommander ?? factionCommander; - } - - private static void UpdateFactionCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) - { - var faction = FindFaction(world, commander.FactionId); - if (faction is null) - { - commander.IsAlive = false; - return; - } - - commander.ReplanTimer = FactionCommanderReplanInterval; - commander.NeedsReplan = false; - commander.PlanningCycle += 1; - - EnsureFactionStateDefaults(world, faction); - - var nowUtc = DateTimeOffset.UtcNow; - var previousTheaters = faction.StrategicState.Theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); - var previousCampaigns = faction.StrategicState.Campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); - var previousObjectives = faction.StrategicState.Objectives.ToDictionary(objective => objective.Id, StringComparer.Ordinal); - var previousPrograms = faction.StrategicState.ProductionPrograms.ToDictionary(program => program.Id, StringComparer.Ordinal); - - var economy = FactionEconomyAnalyzer.Build(world, faction.Id); - var expansionProject = ResolveExpansionProject(world, faction); - var threatAssessment = BuildThreatAssessment(world, faction, commander, nowUtc); - var economicAssessment = BuildEconomicAssessment(world, faction, commander, economy, expansionProject, threatAssessment, nowUtc); - UpdateDoctrine(world, faction, threatAssessment, economicAssessment, expansionProject); - UpdateBudget(faction, threatAssessment, economicAssessment); - UpdateMemory(world, faction, threatAssessment, economicAssessment, nowUtc); - - if (expansionProject is not null - && economicAssessment.ConstructorShipCount > 0 - && faction.StrategicState.Budget.ExpansionCredits > 0f) - { - FactionIndustryPlanner.EnsureExpansionSite(world, faction.Id, expansionProject); - expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) ?? expansionProject; - economicAssessment.PrimaryExpansionSiteId = expansionProject.SiteId; - economicAssessment.PrimaryExpansionSystemId = expansionProject.SystemId; - } - - var theaters = BuildTheaters(world, faction, threatAssessment, economicAssessment, expansionProject, nowUtc); - var campaigns = BuildCampaigns(world, faction, theaters, threatAssessment, economicAssessment, expansionProject, previousCampaigns, nowUtc); - var theatersById = theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); - foreach (var campaign in campaigns) - { - if (campaign.TheaterId is not null && theatersById.TryGetValue(campaign.TheaterId, out var theater)) - { - theater.CampaignIds.Add(campaign.Id); - } - } - - var objectives = BuildObjectives(world, faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousObjectives, nowUtc); - var reservations = BuildReservations(world, faction, objectives, nowUtc); - var programs = BuildProductionPrograms(faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousPrograms); - - ReconcileCampaignLifecycle(world, faction, previousCampaigns, campaigns, economicAssessment, threatAssessment, nowUtc); - ReconcileObjectiveLifecycle(faction, previousObjectives, objectives, nowUtc); - ReconcileTheaterLifecycle(faction, previousTheaters, theaters, nowUtc); - ReconcileProgramLifecycle(faction, previousPrograms, programs, nowUtc); - - faction.Memory.LastPlanCycle = commander.PlanningCycle; - faction.Memory.UpdatedAtUtc = nowUtc; - faction.StrategicState.PlanCycle = commander.PlanningCycle; - faction.StrategicState.UpdatedAtUtc = nowUtc; - faction.StrategicState.Status = ResolveStrategicStatus(theaters, campaigns, economicAssessment, threatAssessment); - faction.StrategicState.EconomicAssessment = economicAssessment; - faction.StrategicState.ThreatAssessment = threatAssessment; - faction.StrategicState.Theaters.Clear(); - faction.StrategicState.Theaters.AddRange(theaters); - faction.StrategicState.Campaigns.Clear(); - faction.StrategicState.Campaigns.AddRange(campaigns); - faction.StrategicState.Objectives.Clear(); - faction.StrategicState.Objectives.AddRange(objectives); - faction.StrategicState.Reservations.Clear(); - faction.StrategicState.Reservations.AddRange(reservations); - faction.StrategicState.ProductionPrograms.Clear(); - faction.StrategicState.ProductionPrograms.AddRange(programs); - - ApplyDelegation(world, faction, commander, events, nowUtc); - } - - private static void UpdateStationCommander(SimulationWorld world, CommanderRuntime commander) - { - commander.ReplanTimer = StationCommanderReplanInterval; - commander.NeedsReplan = false; - commander.PlanningCycle += 1; - commander.ActiveObjectiveIds.Clear(); - - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); - if (station is null) - { - commander.IsAlive = false; - commander.Assignment = null; - return; - } - - var faction = FindFaction(world, commander.FactionId); - if (faction is null) - { - commander.Assignment = null; - return; - } - - var objective = faction.StrategicState.Objectives - .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (objective is not null) - { - commander.ActiveObjectiveIds.Add(objective.Id); - commander.Assignment = ToAssignment(objective); - return; - } - - var activeSite = world.ConstructionSites - .Where(site => - site.FactionId == station.FactionId && - site.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned && - (string.Equals(site.StationId, station.Id, StringComparison.Ordinal) || string.Equals(site.SystemId, station.SystemId, StringComparison.Ordinal))) - .OrderByDescending(site => string.Equals(site.StationId, station.Id, StringComparison.Ordinal) ? 1 : 0) - .ThenBy(site => site.Id, StringComparer.Ordinal) - .FirstOrDefault(); - var strategicAssignment = BuildStationFocusAssignment(world, faction, station, activeSite); - commander.Assignment = strategicAssignment; - } - - private static void UpdateShipCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) - { - commander.ReplanTimer = ShipCommanderReplanInterval; - commander.NeedsReplan = false; - commander.PlanningCycle += 1; - commander.ActiveObjectiveIds.Clear(); - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); - if (ship is null) - { - commander.IsAlive = false; - commander.Assignment = null; - return; - } - - var faction = FindFaction(world, commander.FactionId); - if (faction is null) - { - commander.Assignment = null; - return; - } - - var assignedObjective = faction.StrategicState.Objectives - .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - var nextAssignment = assignedObjective is null - ? null - : ToAssignment(assignedObjective); - if (assignedObjective is not null) - { - commander.ActiveObjectiveIds.Add(assignedObjective.Id); - } - - if (!AssignmentsEqual(commander.Assignment, nextAssignment)) - { - commander.Assignment = nextAssignment; - ship.NeedsReplan = true; - events.Add(new SimulationEventRecord( - "ship", - ship.Id, - nextAssignment is null ? "assignment-cleared" : "assignment-updated", - nextAssignment is null - ? $"{ship.Definition.Label} returned to default behavior." - : $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.", - DateTimeOffset.UtcNow)); - } - } - - private static IndustryExpansionProject? ResolveExpansionProject(SimulationWorld world, FactionRuntime faction) => - FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) - ?? FactionIndustryPlanner.AnalyzeExpansionNeed(world, faction.Id) - ?? FactionIndustryPlanner.AnalyzeShipyardNeed(world, faction.Id); - - private static FactionThreatAssessmentRuntime BuildThreatAssessment( - SimulationWorld world, - FactionRuntime faction, - CommanderRuntime commander, - DateTimeOffset nowUtc) - { - var assessment = new FactionThreatAssessmentRuntime - { - PlanCycle = commander.PlanningCycle, - UpdatedAtUtc = nowUtc, - EnemyFactionCount = world.Factions.Count(candidate => candidate.Id != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, candidate.Id)), - EnemyShipCount = world.Ships.Count(ship => ship.Health > 0f && ship.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId)), - EnemyStationCount = world.Stations.Count(station => station.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId)), - }; - - var controlledSystems = GeopoliticalSimulationService.GetControlledSystems(world, faction.Id) - .ToHashSet(StringComparer.Ordinal); - var factionStationSystems = world.Stations - .Where(station => station.FactionId == faction.Id) - .Select(station => station.SystemId) - .ToHashSet(StringComparer.Ordinal); - var borderTensions = world.Geopolitics?.Diplomacy.BorderTensions - .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) - || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) - .ToList() ?? []; - var activeWars = world.Geopolitics?.Diplomacy.Wars - .Where(war => war.Status == "active" - && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) - || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) - .ToList() ?? []; - - var threatSignals = world.Systems - .Select(system => - { - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, system.Definition.Id); - var strategicProfile = FindStrategicProfile(world, system.Definition.Id); - var territoryPressure = FindTerritoryPressure(world, faction.Id, system.Definition.Id); - var systemTensions = borderTensions - .Where(tension => tension.SystemIds.Contains(system.Definition.Id, StringComparer.Ordinal)) - .ToList(); - var enemyShips = world.Ships - .Where(ship => ship.Health > 0f - && ship.FactionId != faction.Id - && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId) - && ship.SystemId == system.Definition.Id) - .ToList(); - var enemyStations = world.Stations - .Where(station => station.FactionId != faction.Id - && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId) - && station.SystemId == system.Definition.Id) - .ToList(); - if (enemyShips.Count == 0 && enemyStations.Count == 0 && systemTensions.Count == 0 && (territoryPressure?.PressureScore ?? 0f) < 0.08f) - { - return null; + if (commander.ReplanTimer > 0f) + { + commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds); + } } - var primaryEnemyFactionId = enemyShips - .GroupBy(ship => ship.FactionId, StringComparer.Ordinal) - .Select(group => (FactionId: group.Key, Weight: group.Count() * 10)) - .Concat(enemyStations - .GroupBy(station => station.FactionId, StringComparer.Ordinal) - .Select(group => (FactionId: group.Key, Weight: group.Count() * 25))) - .GroupBy(entry => entry.FactionId, StringComparer.Ordinal) - .OrderByDescending(group => group.Sum(entry => entry.Weight)) - .ThenBy(group => group.Key, StringComparer.Ordinal) - .Select(group => group.Key) + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateFactionCommander(world, commander, events); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateFleetCommander(world, commander); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateStationCommander(world, commander); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateShipCommander(world, commander, events); + } + } + + internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => + world.Commanders.FirstOrDefault(commander => + commander.Kind == CommanderKind.Faction && + commander.FactionId == factionId); + + internal static FactionRuntime? FindFaction(SimulationWorld world, string factionId) => + world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, factionId, StringComparison.Ordinal)); + + internal static FactionStrategicStateRuntime? FindFactionStrategicState(SimulationWorld world, string factionId) => + FindFaction(world, factionId)?.StrategicState; + + internal static FactionEconomicAssessmentRuntime? FindFactionEconomicAssessment(SimulationWorld world, string factionId) => + FindFactionStrategicState(world, factionId)?.EconomicAssessment; + + internal static FactionThreatAssessmentRuntime? FindFactionThreatAssessment(SimulationWorld world, string factionId) => + FindFactionStrategicState(world, factionId)?.ThreatAssessment; + + private static void EnsureHierarchy(SimulationWorld world) + { + var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); + var factionCommanders = world.Commanders + .Where(commander => commander.Kind == CommanderKind.Faction) + .ToDictionary(commander => commander.FactionId, StringComparer.Ordinal); + + foreach (var faction in world.Factions) + { + EnsureFactionStateDefaults(world, faction); + + if (!factionCommanders.TryGetValue(faction.Id, out var commander)) + { + commander = new CommanderRuntime + { + Id = $"commander-faction-{faction.Id}", + Kind = CommanderKind.Faction, + FactionId = faction.Id, + ControlledEntityId = faction.Id, + PolicySetId = faction.DefaultPolicySetId, + Doctrine = "strategic-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 5 }, + }; + world.Commanders.Add(commander); + commandersById[commander.Id] = commander; + factionCommanders[faction.Id] = commander; + } + } + + foreach (var commander in world.Commanders) + { + commander.SubordinateCommanderIds.Clear(); + } + + var stationCommanders = new Dictionary(StringComparer.Ordinal); + foreach (var station in world.Stations) + { + if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander)) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Station && + string.Equals(candidate.ControlledEntityId, station.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-station-{station.Id}", + Kind = CommanderKind.Station, + FactionId = station.FactionId, + ControlledEntityId = station.Id, + Doctrine = "station-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = 3, + Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), + Strategy = 3, + }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = parentCommander.Id; + commander.PolicySetId = parentCommander.PolicySetId; + station.CommanderId = commander.Id; + station.PolicySetId ??= parentCommander.PolicySetId; + stationCommanders[station.Id] = commander; + } + + foreach (var ship in world.Ships) + { + if (!factionCommanders.TryGetValue(ship.FactionId, out var factionCommander)) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Ship && + string.Equals(candidate.ControlledEntityId, ship.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = ship.FactionId, + ControlledEntityId = ship.Id, + Doctrine = "ship-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), + Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), + Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), + }, + }; + world.Commanders.Add(commander); + } + + var parentCommander = ResolveShipParentCommander(world, ship, factionCommander, stationCommanders); + commander.ParentCommanderId = parentCommander.Id; + commander.PolicySetId = parentCommander.PolicySetId; + ship.CommanderId = commander.Id; + ship.PolicySetId ??= parentCommander.PolicySetId; + } + + foreach (var commander in world.Commanders) + { + if (commander.ParentCommanderId is not null && commandersById.TryGetValue(commander.ParentCommanderId, out var parent)) + { + parent.SubordinateCommanderIds.Add(commander.Id); + } + } + + foreach (var faction in world.Factions) + { + faction.CommanderIds.Clear(); + } + + foreach (var commander in world.Commanders) + { + if (world.Factions.FirstOrDefault(faction => faction.Id == commander.FactionId) is { } faction) + { + faction.CommanderIds.Add(commander.Id); + } + } + } + + private static void EnsureFactionStateDefaults(SimulationWorld world, FactionRuntime faction) + { + faction.Doctrine.StrategicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.StrategicPosture) ? "balanced" : faction.Doctrine.StrategicPosture; + faction.Doctrine.ExpansionPosture = string.IsNullOrWhiteSpace(faction.Doctrine.ExpansionPosture) ? "measured" : faction.Doctrine.ExpansionPosture; + faction.Doctrine.MilitaryPosture = string.IsNullOrWhiteSpace(faction.Doctrine.MilitaryPosture) ? "defensive" : faction.Doctrine.MilitaryPosture; + faction.Doctrine.EconomicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.EconomicPosture) ? "self-sufficient" : faction.Doctrine.EconomicPosture; + faction.Doctrine.DesiredControlledSystems = Math.Max(2, Math.Min(world.Systems.Count, faction.Doctrine.DesiredControlledSystems <= 0 ? 3 : faction.Doctrine.DesiredControlledSystems)); + faction.Doctrine.DesiredMilitaryPerFront = Math.Max(1, faction.Doctrine.DesiredMilitaryPerFront); + faction.Doctrine.DesiredMinersPerSystem = Math.Max(1, faction.Doctrine.DesiredMinersPerSystem); + faction.Doctrine.DesiredTransportsPerSystem = Math.Max(1, faction.Doctrine.DesiredTransportsPerSystem); + faction.Doctrine.DesiredConstructors = Math.Max(1, faction.Doctrine.DesiredConstructors); + faction.Doctrine.ReserveCreditsRatio = ClampRatio(faction.Doctrine.ReserveCreditsRatio, 0.2f); + faction.Doctrine.ExpansionBudgetRatio = ClampRatio(faction.Doctrine.ExpansionBudgetRatio, 0.25f); + faction.Doctrine.WarBudgetRatio = ClampRatio(faction.Doctrine.WarBudgetRatio, 0.35f); + faction.Doctrine.ReserveMilitaryRatio = ClampRatio(faction.Doctrine.ReserveMilitaryRatio, 0.2f); + faction.Doctrine.OffensiveReadinessThreshold = ClampRatio(faction.Doctrine.OffensiveReadinessThreshold, 0.62f); + faction.Doctrine.SupplySecurityBias = ClampRatio(faction.Doctrine.SupplySecurityBias, 0.55f); + faction.Doctrine.FailureAversion = ClampRatio(faction.Doctrine.FailureAversion, 0.45f); + faction.Doctrine.ReinforcementLeadPerFront = Math.Max(1, faction.Doctrine.ReinforcementLeadPerFront); + } + + private static float ClampRatio(float value, float fallback) => + value is >= 0f and <= 1f ? value : fallback; + + private static CommanderRuntime ResolveShipParentCommander( + SimulationWorld world, + ShipRuntime ship, + CommanderRuntime factionCommander, + IReadOnlyDictionary stationCommanders) + { + if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + { + return factionCommander; + } + + var stationCommander = world.Stations + .Where(station => string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .Select(station => station.CommanderId is not null && stationCommanders.TryGetValue(station.Id, out var commander) + ? commander + : null) + .FirstOrDefault(candidate => candidate is not null); + + return stationCommander ?? factionCommander; + } + + private static void UpdateFactionCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) + { + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.IsAlive = false; + return; + } + + commander.ReplanTimer = FactionCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + + EnsureFactionStateDefaults(world, faction); + + var nowUtc = DateTimeOffset.UtcNow; + var previousTheaters = faction.StrategicState.Theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); + var previousCampaigns = faction.StrategicState.Campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); + var previousObjectives = faction.StrategicState.Objectives.ToDictionary(objective => objective.Id, StringComparer.Ordinal); + var previousPrograms = faction.StrategicState.ProductionPrograms.ToDictionary(program => program.Id, StringComparer.Ordinal); + + var economy = FactionEconomyAnalyzer.Build(world, faction.Id); + var expansionProject = ResolveExpansionProject(world, faction); + var threatAssessment = BuildThreatAssessment(world, faction, commander, nowUtc); + var economicAssessment = BuildEconomicAssessment(world, faction, commander, economy, expansionProject, threatAssessment, nowUtc); + UpdateDoctrine(world, faction, threatAssessment, economicAssessment, expansionProject); + UpdateBudget(faction, threatAssessment, economicAssessment); + UpdateMemory(world, faction, threatAssessment, economicAssessment, nowUtc); + + if (expansionProject is not null + && economicAssessment.ConstructorShipCount > 0 + && faction.StrategicState.Budget.ExpansionCredits > 0f) + { + FactionIndustryPlanner.EnsureExpansionSite(world, faction.Id, expansionProject); + expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) ?? expansionProject; + economicAssessment.PrimaryExpansionSiteId = expansionProject.SiteId; + economicAssessment.PrimaryExpansionSystemId = expansionProject.SystemId; + } + + var theaters = BuildTheaters(world, faction, threatAssessment, economicAssessment, expansionProject, nowUtc); + var campaigns = BuildCampaigns(world, faction, theaters, threatAssessment, economicAssessment, expansionProject, previousCampaigns, nowUtc); + var theatersById = theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); + foreach (var campaign in campaigns) + { + if (campaign.TheaterId is not null && theatersById.TryGetValue(campaign.TheaterId, out var theater)) + { + theater.CampaignIds.Add(campaign.Id); + } + } + + var objectives = BuildObjectives(world, faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousObjectives, nowUtc); + var reservations = BuildReservations(world, faction, objectives, nowUtc); + var programs = BuildProductionPrograms(faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousPrograms); + + ReconcileCampaignLifecycle(world, faction, previousCampaigns, campaigns, economicAssessment, threatAssessment, nowUtc); + ReconcileObjectiveLifecycle(faction, previousObjectives, objectives, nowUtc); + ReconcileTheaterLifecycle(faction, previousTheaters, theaters, nowUtc); + ReconcileProgramLifecycle(faction, previousPrograms, programs, nowUtc); + + faction.Memory.LastPlanCycle = commander.PlanningCycle; + faction.Memory.UpdatedAtUtc = nowUtc; + faction.StrategicState.PlanCycle = commander.PlanningCycle; + faction.StrategicState.UpdatedAtUtc = nowUtc; + faction.StrategicState.Status = ResolveStrategicStatus(theaters, campaigns, economicAssessment, threatAssessment); + faction.StrategicState.EconomicAssessment = economicAssessment; + faction.StrategicState.ThreatAssessment = threatAssessment; + faction.StrategicState.Theaters.Clear(); + faction.StrategicState.Theaters.AddRange(theaters); + faction.StrategicState.Campaigns.Clear(); + faction.StrategicState.Campaigns.AddRange(campaigns); + faction.StrategicState.Objectives.Clear(); + faction.StrategicState.Objectives.AddRange(objectives); + faction.StrategicState.Reservations.Clear(); + faction.StrategicState.Reservations.AddRange(reservations); + faction.StrategicState.ProductionPrograms.Clear(); + faction.StrategicState.ProductionPrograms.AddRange(programs); + + ApplyDelegation(world, faction, commander, events, nowUtc); + } + + private static void UpdateStationCommander(SimulationWorld world, CommanderRuntime commander) + { + commander.ReplanTimer = StationCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); + + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (station is null) + { + commander.IsAlive = false; + commander.Assignment = null; + return; + } + + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.Assignment = null; + return; + } + + var objective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (objective is not null) + { + commander.ActiveObjectiveIds.Add(objective.Id); + commander.Assignment = ToAssignment(objective); + return; + } + + var activeSite = world.ConstructionSites + .Where(site => + site.FactionId == station.FactionId && + site.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned && + (string.Equals(site.StationId, station.Id, StringComparison.Ordinal) || string.Equals(site.SystemId, station.SystemId, StringComparison.Ordinal))) + .OrderByDescending(site => string.Equals(site.StationId, station.Id, StringComparison.Ordinal) ? 1 : 0) + .ThenBy(site => site.Id, StringComparer.Ordinal) + .FirstOrDefault(); + var strategicAssignment = BuildStationFocusAssignment(world, faction, station, activeSite); + commander.Assignment = strategicAssignment; + } + + private static void UpdateShipCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) + { + commander.ReplanTimer = ShipCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (ship is null) + { + commander.IsAlive = false; + commander.Assignment = null; + return; + } + + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.Assignment = null; + return; + } + + var assignedObjective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) .FirstOrDefault(); - var warBias = activeWars.Any(war => string.Equals(war.FactionAId, primaryEnemyFactionId, StringComparison.Ordinal) || string.Equals(war.FactionBId, primaryEnemyFactionId, StringComparison.Ordinal)) - ? 24f - : 0f; - var priorityBias = controlledSystems.Contains(system.Definition.Id) ? 40 : factionStationSystems.Contains(system.Definition.Id) ? 25 : 0; - var diplomacyBias = systemTensions.Sum(tension => tension.TensionScore * 26f) + systemTensions.Sum(tension => tension.AccessFriction * 12f); - var territoryBias = (territoryPressure?.PressureScore ?? 0f) * 35f - + ((strategicProfile?.IsContested ?? false) ? 24f : 0f) - + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 12f); - var scopeKind = controlState?.IsContested == true || (factionStationSystems.Contains(system.Definition.Id) && !controlledSystems.Contains(system.Definition.Id)) - ? "contested-system" - : controlledSystems.Contains(system.Definition.Id) - ? "controlled-system" - : "hostile-system"; - return new + var nextAssignment = assignedObjective is null + ? null + : ToAssignment(assignedObjective); + if (assignedObjective is not null) { - Signal = new FactionThreatSignalRuntime - { - ScopeId = system.Definition.Id, - ScopeKind = scopeKind, - EnemyShipCount = enemyShips.Count, - EnemyStationCount = enemyStations.Count, - EnemyFactionId = primaryEnemyFactionId, - }, - Score = (enemyStations.Count * 30) + (enemyShips.Count * 10) + priorityBias + diplomacyBias + territoryBias + warBias, + commander.ActiveObjectiveIds.Add(assignedObjective.Id); + } + + if (!AssignmentsEqual(commander.Assignment, nextAssignment)) + { + commander.Assignment = nextAssignment; + ship.NeedsReplan = true; + events.Add(new SimulationEventRecord( + "ship", + ship.Id, + nextAssignment is null ? "assignment-cleared" : "assignment-updated", + nextAssignment is null + ? $"{ship.Definition.Label} returned to default behavior." + : $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.", + DateTimeOffset.UtcNow)); + } + } + + private static IndustryExpansionProject? ResolveExpansionProject(SimulationWorld world, FactionRuntime faction) => + FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) + ?? FactionIndustryPlanner.AnalyzeExpansionNeed(world, faction.Id) + ?? FactionIndustryPlanner.AnalyzeShipyardNeed(world, faction.Id); + + private static FactionThreatAssessmentRuntime BuildThreatAssessment( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime commander, + DateTimeOffset nowUtc) + { + var assessment = new FactionThreatAssessmentRuntime + { + PlanCycle = commander.PlanningCycle, + UpdatedAtUtc = nowUtc, + EnemyFactionCount = world.Factions.Count(candidate => candidate.Id != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, candidate.Id)), + EnemyShipCount = world.Ships.Count(ship => ship.Health > 0f && ship.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId)), + EnemyStationCount = world.Stations.Count(station => station.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId)), }; - }) - .Where(entry => entry is not null) - .Select(entry => entry!) - .OrderByDescending(entry => entry.Score) - .ThenBy(entry => entry.Signal.ScopeId, StringComparer.Ordinal) - .ToList(); - assessment.ThreatSignals.AddRange(threatSignals.Select(entry => entry.Signal)); - assessment.PrimaryThreatSystemId = threatSignals.FirstOrDefault()?.Signal.ScopeId; - assessment.PrimaryThreatFactionId = threatSignals.FirstOrDefault()?.Signal.EnemyFactionId; - return assessment; - } + var controlledSystems = GeopoliticalSimulationService.GetControlledSystems(world, faction.Id) + .ToHashSet(StringComparer.Ordinal); + var factionStationSystems = world.Stations + .Where(station => station.FactionId == faction.Id) + .Select(station => station.SystemId) + .ToHashSet(StringComparer.Ordinal); + var borderTensions = world.Geopolitics?.Diplomacy.BorderTensions + .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) + || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) + .ToList() ?? []; + var activeWars = world.Geopolitics?.Diplomacy.Wars + .Where(war => war.Status == "active" + && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) + || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) + .ToList() ?? []; - private static FactionEconomicAssessmentRuntime BuildEconomicAssessment( - SimulationWorld world, - FactionRuntime faction, - CommanderRuntime commander, - FactionEconomySnapshot economy, - IndustryExpansionProject? expansionProject, - FactionThreatAssessmentRuntime threatAssessment, - DateTimeOffset nowUtc) - { - var controlledSystems = StationSimulationService.GetFactionControlledSystemsCount(world, faction.Id); - var frontCount = Math.Max(1, - threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system") - + (expansionProject is null ? 0 : 1)); - var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military"); - var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining")); - var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport"); - var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction"); - var hasShipyard = world.Stations.Any(station => - string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) && - station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)); - var hasWarIndustrySupplyChain = HasOperationalWarIndustry(economy); - var factionRegions = world.Geopolitics?.EconomyRegions.Regions - .Where(region => string.Equals(region.FactionId, faction.Id, StringComparison.Ordinal)) - .ToList() ?? []; - var regionSecurity = world.Geopolitics?.EconomyRegions.SecurityAssessments - .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) - .ToList() ?? []; - var regionEconomics = world.Geopolitics?.EconomyRegions.EconomicAssessments - .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) - .ToList() ?? []; - var regionalBottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(bottleneck => factionRegions.Any(region => region.Id == bottleneck.RegionId)) - .ToList() ?? []; - var corridorRisk = world.Geopolitics?.EconomyRegions.Corridors - .Where(corridor => string.Equals(corridor.FactionId, faction.Id, StringComparison.Ordinal)) - .DefaultIfEmpty() - .Average(corridor => corridor?.RiskScore ?? 0f) ?? 0f; - var regionalSupplyRisk = regionSecurity.Count == 0 ? 0f : regionSecurity.Average(assessment => assessment.SupplyRisk); - var regionalSustainment = regionEconomics.Count == 0 ? 1f : regionEconomics.Average(assessment => assessment.SustainmentScore); + var threatSignals = world.Systems + .Select(system => + { + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, system.Definition.Id); + var strategicProfile = FindStrategicProfile(world, system.Definition.Id); + var territoryPressure = FindTerritoryPressure(world, faction.Id, system.Definition.Id); + var systemTensions = borderTensions + .Where(tension => tension.SystemIds.Contains(system.Definition.Id, StringComparer.Ordinal)) + .ToList(); + var enemyShips = world.Ships + .Where(ship => ship.Health > 0f + && ship.FactionId != faction.Id + && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId) + && ship.SystemId == system.Definition.Id) + .ToList(); + var enemyStations = world.Stations + .Where(station => station.FactionId != faction.Id + && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId) + && station.SystemId == system.Definition.Id) + .ToList(); + if (enemyShips.Count == 0 && enemyStations.Count == 0 && systemTensions.Count == 0 && (territoryPressure?.PressureScore ?? 0f) < 0.08f) + { + return null; + } - var assessment = new FactionEconomicAssessmentRuntime + var primaryEnemyFactionId = enemyShips + .GroupBy(ship => ship.FactionId, StringComparer.Ordinal) + .Select(group => (FactionId: group.Key, Weight: group.Count() * 10)) + .Concat(enemyStations + .GroupBy(station => station.FactionId, StringComparer.Ordinal) + .Select(group => (FactionId: group.Key, Weight: group.Count() * 25))) + .GroupBy(entry => entry.FactionId, StringComparer.Ordinal) + .OrderByDescending(group => group.Sum(entry => entry.Weight)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.Key) + .FirstOrDefault(); + + var warBias = activeWars.Any(war => string.Equals(war.FactionAId, primaryEnemyFactionId, StringComparison.Ordinal) || string.Equals(war.FactionBId, primaryEnemyFactionId, StringComparison.Ordinal)) + ? 24f + : 0f; + var priorityBias = controlledSystems.Contains(system.Definition.Id) ? 40 : factionStationSystems.Contains(system.Definition.Id) ? 25 : 0; + var diplomacyBias = systemTensions.Sum(tension => tension.TensionScore * 26f) + systemTensions.Sum(tension => tension.AccessFriction * 12f); + var territoryBias = (territoryPressure?.PressureScore ?? 0f) * 35f + + ((strategicProfile?.IsContested ?? false) ? 24f : 0f) + + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 12f); + var scopeKind = controlState?.IsContested == true || (factionStationSystems.Contains(system.Definition.Id) && !controlledSystems.Contains(system.Definition.Id)) + ? "contested-system" + : controlledSystems.Contains(system.Definition.Id) + ? "controlled-system" + : "hostile-system"; + return new + { + Signal = new FactionThreatSignalRuntime + { + ScopeId = system.Definition.Id, + ScopeKind = scopeKind, + EnemyShipCount = enemyShips.Count, + EnemyStationCount = enemyStations.Count, + EnemyFactionId = primaryEnemyFactionId, + }, + Score = (enemyStations.Count * 30) + (enemyShips.Count * 10) + priorityBias + diplomacyBias + territoryBias + warBias, + }; + }) + .Where(entry => entry is not null) + .Select(entry => entry!) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Signal.ScopeId, StringComparer.Ordinal) + .ToList(); + + assessment.ThreatSignals.AddRange(threatSignals.Select(entry => entry.Signal)); + assessment.PrimaryThreatSystemId = threatSignals.FirstOrDefault()?.Signal.ScopeId; + assessment.PrimaryThreatFactionId = threatSignals.FirstOrDefault()?.Signal.EnemyFactionId; + return assessment; + } + + private static FactionEconomicAssessmentRuntime BuildEconomicAssessment( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime commander, + FactionEconomySnapshot economy, + IndustryExpansionProject? expansionProject, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) { - PlanCycle = commander.PlanningCycle, - UpdatedAtUtc = nowUtc, - MilitaryShipCount = militaryShipCount, - MinerShipCount = minerShipCount, - TransportShipCount = transportShipCount, - ConstructorShipCount = constructorShipCount, - ControlledSystemCount = controlledSystems, - TargetMilitaryShipCount = Math.Max(frontCount * faction.Doctrine.DesiredMilitaryPerFront, controlledSystems + 2), - TargetMinerShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredMinersPerSystem, 2), - TargetTransportShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredTransportsPerSystem, 2), - TargetConstructorShipCount = faction.Doctrine.DesiredConstructors, - HasShipyard = hasShipyard, - HasWarIndustrySupplyChain = hasWarIndustrySupplyChain, - PrimaryExpansionSiteId = expansionProject?.SiteId, - PrimaryExpansionSystemId = expansionProject?.SystemId, - ReplacementPressure = MathF.Max(0f, (faction.ShipsLost - faction.Memory.LastObservedShipsLost) * 6f) - + MathF.Max(0f, frontCount - Math.Max(1, militaryShipCount)) - + (regionalSupplyRisk * 4f), - }; + var controlledSystems = StationSimulationService.GetFactionControlledSystemsCount(world, faction.Id); + var frontCount = Math.Max(1, + threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system") + + (expansionProject is null ? 0 : 1)); + var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military"); + var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining")); + var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport"); + var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction"); + var hasShipyard = world.Stations.Any(station => + string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) && + station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)); + var hasWarIndustrySupplyChain = HasOperationalWarIndustry(economy); + var factionRegions = world.Geopolitics?.EconomyRegions.Regions + .Where(region => string.Equals(region.FactionId, faction.Id, StringComparison.Ordinal)) + .ToList() ?? []; + var regionSecurity = world.Geopolitics?.EconomyRegions.SecurityAssessments + .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) + .ToList() ?? []; + var regionEconomics = world.Geopolitics?.EconomyRegions.EconomicAssessments + .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) + .ToList() ?? []; + var regionalBottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => factionRegions.Any(region => region.Id == bottleneck.RegionId)) + .ToList() ?? []; + var corridorRisk = world.Geopolitics?.EconomyRegions.Corridors + .Where(corridor => string.Equals(corridor.FactionId, faction.Id, StringComparison.Ordinal)) + .DefaultIfEmpty() + .Average(corridor => corridor?.RiskScore ?? 0f) ?? 0f; + var regionalSupplyRisk = regionSecurity.Count == 0 ? 0f : regionSecurity.Average(assessment => assessment.SupplyRisk); + var regionalSustainment = regionEconomics.Count == 0 ? 1f : regionEconomics.Average(assessment => assessment.SustainmentScore); - assessment.CommoditySignals.AddRange( - economy.Commodities - .OrderBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => new FactionCommoditySignalRuntime + var assessment = new FactionEconomicAssessmentRuntime { - ItemId = entry.Key, - AvailableStock = entry.Value.AvailableStock, - OnHand = entry.Value.OnHand, - ProductionRatePerSecond = entry.Value.ProductionRatePerSecond, - CommittedProductionRatePerSecond = entry.Value.CommittedProductionRatePerSecond, - UsageRatePerSecond = entry.Value.OperationalUsageRatePerSecond, - NetRatePerSecond = entry.Value.NetRatePerSecond, - ProjectedNetRatePerSecond = entry.Value.ProjectedNetRatePerSecond, - LevelSeconds = entry.Value.LevelSeconds, - Level = entry.Value.Level.ToString().ToLowerInvariant(), - ProjectedProductionRatePerSecond = entry.Value.ProjectedProductionRatePerSecond, - BuyBacklog = entry.Value.BuyBacklog, - ReservedForConstruction = entry.Value.ReservedForConstruction, - })); + PlanCycle = commander.PlanningCycle, + UpdatedAtUtc = nowUtc, + MilitaryShipCount = militaryShipCount, + MinerShipCount = minerShipCount, + TransportShipCount = transportShipCount, + ConstructorShipCount = constructorShipCount, + ControlledSystemCount = controlledSystems, + TargetMilitaryShipCount = Math.Max(frontCount * faction.Doctrine.DesiredMilitaryPerFront, controlledSystems + 2), + TargetMinerShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredMinersPerSystem, 2), + TargetTransportShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredTransportsPerSystem, 2), + TargetConstructorShipCount = faction.Doctrine.DesiredConstructors, + HasShipyard = hasShipyard, + HasWarIndustrySupplyChain = hasWarIndustrySupplyChain, + PrimaryExpansionSiteId = expansionProject?.SiteId, + PrimaryExpansionSystemId = expansionProject?.SystemId, + ReplacementPressure = MathF.Max(0f, (faction.ShipsLost - faction.Memory.LastObservedShipsLost) * 6f) + + MathF.Max(0f, frontCount - Math.Max(1, militaryShipCount)) + + (regionalSupplyRisk * 4f), + }; - assessment.CriticalShortageCount = assessment.CommoditySignals.Count(signal => signal.Level is "critical" or "low") - + regionalBottlenecks.Count(bottleneck => bottleneck.Severity >= 2.5f); - assessment.IndustrialBottleneckItemId = assessment.CommoditySignals - .OrderByDescending(ComputeCommodityPriority) - .ThenBy(signal => signal.ItemId, StringComparer.Ordinal) - .Select(signal => signal.ItemId) - .FirstOrDefault() - ?? regionalBottlenecks - .OrderByDescending(bottleneck => bottleneck.Severity) - .ThenBy(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) - .Select(bottleneck => bottleneck.ItemId) - .FirstOrDefault(); + assessment.CommoditySignals.AddRange( + economy.Commodities + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => new FactionCommoditySignalRuntime + { + ItemId = entry.Key, + AvailableStock = entry.Value.AvailableStock, + OnHand = entry.Value.OnHand, + ProductionRatePerSecond = entry.Value.ProductionRatePerSecond, + CommittedProductionRatePerSecond = entry.Value.CommittedProductionRatePerSecond, + UsageRatePerSecond = entry.Value.OperationalUsageRatePerSecond, + NetRatePerSecond = entry.Value.NetRatePerSecond, + ProjectedNetRatePerSecond = entry.Value.ProjectedNetRatePerSecond, + LevelSeconds = entry.Value.LevelSeconds, + Level = entry.Value.Level.ToString().ToLowerInvariant(), + ProjectedProductionRatePerSecond = entry.Value.ProjectedProductionRatePerSecond, + BuyBacklog = entry.Value.BuyBacklog, + ReservedForConstruction = entry.Value.ReservedForConstruction, + })); - if (assessment.PrimaryExpansionSystemId is null) - { - assessment.PrimaryExpansionSystemId = factionRegions - .Join(regionEconomics, region => region.Id, economicState => economicState.RegionId, (region, economicState) => new { region, economicState }) - .OrderByDescending(entry => entry.economicState.ConstructionPressure) - .ThenByDescending(entry => entry.economicState.CorridorDependency) - .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) - .Select(entry => entry.region.CoreSystemId) - .FirstOrDefault(); - } + assessment.CriticalShortageCount = assessment.CommoditySignals.Count(signal => signal.Level is "critical" or "low") + + regionalBottlenecks.Count(bottleneck => bottleneck.Severity >= 2.5f); + assessment.IndustrialBottleneckItemId = assessment.CommoditySignals + .OrderByDescending(ComputeCommodityPriority) + .ThenBy(signal => signal.ItemId, StringComparer.Ordinal) + .Select(signal => signal.ItemId) + .FirstOrDefault() + ?? regionalBottlenecks + .OrderByDescending(bottleneck => bottleneck.Severity) + .ThenBy(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) + .Select(bottleneck => bottleneck.ItemId) + .FirstOrDefault(); - var transportCoverage = assessment.TargetTransportShipCount <= 0 - ? 1f - : Math.Clamp(assessment.TransportShipCount / (float)assessment.TargetTransportShipCount, 0f, 1.35f); - var minerCoverage = assessment.TargetMinerShipCount <= 0 - ? 1f - : Math.Clamp(assessment.MinerShipCount / (float)assessment.TargetMinerShipCount, 0f, 1.35f); - var constructorCoverage = assessment.TargetConstructorShipCount <= 0 - ? 1f - : Math.Clamp(assessment.ConstructorShipCount / (float)assessment.TargetConstructorShipCount, 0f, 1.35f); - var shortagePenalty = MathF.Min(0.55f, assessment.CriticalShortageCount * 0.08f); - var replacementPenalty = MathF.Min(0.45f, assessment.ReplacementPressure / MathF.Max(12f, assessment.TargetMilitaryShipCount * 8f)); - - assessment.LogisticsSecurityScore = Math.Clamp( - (transportCoverage * 0.45f) - + (minerCoverage * 0.2f) - + (constructorCoverage * 0.1f) - + (hasWarIndustrySupplyChain ? 0.2f : 0.05f) - - shortagePenalty - - (regionalSupplyRisk * 0.3f) - - (corridorRisk * 0.18f), - 0f, - 1f); - assessment.SustainmentScore = Math.Clamp( - (assessment.LogisticsSecurityScore * 0.55f) - + ((hasShipyard ? 0.15f : 0f) + (hasWarIndustrySupplyChain ? 0.15f : 0f)) - + ((assessment.MilitaryShipCount >= assessment.TargetMilitaryShipCount ? 0.2f : 0.08f)) - + (regionalSustainment * 0.18f) - - replacementPenalty, - 0f, - 1f); - - return assessment; - } - - private static bool HasOperationalWarIndustry(FactionEconomySnapshot economy) - { - var energy = economy.GetCommodity("energycells"); - var refined = economy.GetCommodity("refinedmetals"); - var hullparts = economy.GetCommodity("hullparts"); - var claytronics = economy.GetCommodity("claytronics"); - return CommodityOperationalSignal.IsOperational(energy, 180f) - && CommodityOperationalSignal.IsOperational(refined, 180f) - && CommodityOperationalSignal.IsOperational(hullparts, 180f) - && CommodityOperationalSignal.IsOperational(claytronics, 180f); - } - - private static void UpdateDoctrine( - SimulationWorld world, - FactionRuntime faction, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment, - IndustryExpansionProject? expansionProject) - { - var controlledThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system"); - var contestedThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "contested-system"); - var expansionPressure = StationSimulationService.GetFactionExpansionPressure(world, faction.Id); - var shortagePressure = economicAssessment.CriticalShortageCount; - var activeWars = world.Geopolitics?.Diplomacy.Wars.Count(war => - war.Status == "active" - && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) ?? 0; - var borderTension = world.Geopolitics?.Diplomacy.BorderTensions - .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) - .DefaultIfEmpty() - .Average(tension => tension?.TensionScore ?? 0f) ?? 0f; - - faction.Doctrine.StrategicPosture = activeWars > 0 || controlledThreats > 1 - ? "fortify-and-recover" - : controlledThreats > 0 || borderTension > 0.45f - ? expansionProject is null ? "contested" : "defend-and-delay" - : expansionProject is not null && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold - ? "growth-through-pressure" - : shortagePressure > 0 ? "economic-recovery" : "stable-growth"; - faction.Doctrine.MilitaryPosture = activeWars + controlledThreats + contestedThreats switch - { - >= 3 => "mobilized", - > 0 when economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold && economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount => "counteroffensive", - > 0 => "defensive", - _ => economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold ? "expeditionary" : "defensive", - }; - faction.Doctrine.ExpansionPosture = expansionProject is not null - ? controlledThreats > 0 || economicAssessment.SustainmentScore < 0.58f ? "cautious" : "active" - : expansionPressure > 0.2f && economicAssessment.SustainmentScore >= 0.55f ? "measured" : "consolidating"; - faction.Doctrine.EconomicPosture = shortagePressure >= 3 - ? "stabilizing" - : economicAssessment.HasWarIndustrySupplyChain && economicAssessment.SustainmentScore >= 0.7f - ? "surplus" - : "self-sufficient"; - } - - private static void UpdateBudget( - FactionRuntime faction, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment) - { - var reserveCredits = faction.Credits * faction.Doctrine.ReserveCreditsRatio; - var discretionary = MathF.Max(0f, faction.Credits - reserveCredits); - var warRatio = threatAssessment.ThreatSignals.Count > 0 - ? MathF.Max(faction.Doctrine.WarBudgetRatio, 0.4f) - : faction.Doctrine.WarBudgetRatio * 0.5f; - var expansionRatio = economicAssessment.PrimaryExpansionSystemId is not null - ? MathF.Max(faction.Doctrine.ExpansionBudgetRatio, 0.25f) - : faction.Doctrine.ExpansionBudgetRatio * 0.5f; - - faction.StrategicState.Budget = new FactionBudgetRuntime - { - ReservedCredits = reserveCredits, - WarCredits = discretionary * Math.Clamp(warRatio, 0f, 1f), - ExpansionCredits = discretionary * Math.Clamp(expansionRatio, 0f, 1f), - ReservedMilitaryAssets = Math.Max(1, (int)MathF.Ceiling(economicAssessment.TargetMilitaryShipCount * faction.Doctrine.ReserveMilitaryRatio)), - ReservedLogisticsAssets = Math.Max(economicAssessment.TargetTransportShipCount, economicAssessment.TransportShipCount), - ReservedConstructionAssets = Math.Max(economicAssessment.TargetConstructorShipCount, economicAssessment.ConstructorShipCount), - }; - } - - private static void UpdateMemory( - SimulationWorld world, - FactionRuntime faction, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment, - DateTimeOffset nowUtc) - { - foreach (var station in world.Stations.Where(station => station.FactionId == faction.Id)) - { - faction.Memory.KnownSystemIds.Add(station.SystemId); - } - - foreach (var ship in world.Ships.Where(ship => ship.FactionId == faction.Id)) - { - faction.Memory.KnownSystemIds.Add(ship.SystemId); - } - - foreach (var signal in threatAssessment.ThreatSignals) - { - faction.Memory.KnownSystemIds.Add(signal.ScopeId); - if (!string.IsNullOrWhiteSpace(signal.EnemyFactionId)) - { - faction.Memory.KnownEnemyFactionIds.Add(signal.EnemyFactionId); - } - - var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); - if (systemMemory is null) - { - systemMemory = new FactionSystemMemoryRuntime { SystemId = signal.ScopeId }; - faction.Memory.SystemMemories.Add(systemMemory); - } - - systemMemory.LastSeenAtUtc = nowUtc; - systemMemory.LastEnemyShipCount = signal.EnemyShipCount; - systemMemory.LastEnemyStationCount = signal.EnemyStationCount; - systemMemory.ControlledByFaction = signal.ScopeKind == "controlled-system"; - systemMemory.LastRole = signal.ScopeKind; - var strategicProfile = FindStrategicProfile(world, signal.ScopeId); - var territoryPressure = FindTerritoryPressure(world, faction.Id, signal.ScopeId); - systemMemory.FrontierPressure = ((signal.EnemyStationCount * 0.9f) + (signal.EnemyShipCount * 0.35f)) - + ((territoryPressure?.PressureScore ?? 0f) * 1.4f) - + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 0.35f); - systemMemory.RouteRisk = MathF.Max( - GeopoliticalSimulationService.GetSystemRouteRisk(world, signal.ScopeId, faction.Id), - signal.ScopeKind is "controlled-system" or "contested-system" - ? MathF.Min(1f, (signal.EnemyShipCount * 0.08f) + (signal.EnemyStationCount * 0.16f)) - : MathF.Min(1f, (signal.EnemyShipCount * 0.05f) + (signal.EnemyStationCount * 0.1f))); - if (signal.ScopeKind is "controlled-system" or "contested-system") - { - systemMemory.LastContestedAtUtc = nowUtc; - } - } - - foreach (var systemId in faction.Memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal)) - { - var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); - if (systemMemory is not null) - { - continue; - } - - faction.Memory.SystemMemories.Add(new FactionSystemMemoryRuntime - { - SystemId = systemId, - LastSeenAtUtc = nowUtc, - ControlledByFaction = FactionControlsSystem(world, faction.Id, systemId), - LastRole = FactionControlsSystem(world, faction.Id, systemId) ? "controlled-system" : "observed-system", - RouteRisk = GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id), - }); - } - - foreach (var zone in world.Geopolitics?.Territory.Zones.Where(zone => string.Equals(zone.FactionId, faction.Id, StringComparison.Ordinal)) ?? []) - { - faction.Memory.KnownSystemIds.Add(zone.SystemId); - var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == zone.SystemId); - if (systemMemory is null) - { - continue; - } - - systemMemory.LastRole = zone.Kind switch - { - "contested" => "contested-system", - "frontier" => "controlled-system", - "corridor" => "controlled-system", - _ => systemMemory.LastRole, - }; - systemMemory.RouteRisk = MathF.Max(systemMemory.RouteRisk, GeopoliticalSimulationService.GetSystemRouteRisk(world, zone.SystemId, faction.Id)); - } - - foreach (var systemMemory in faction.Memory.SystemMemories) - { - if (threatAssessment.ThreatSignals.All(signal => signal.ScopeId != systemMemory.SystemId)) - { - systemMemory.FrontierPressure *= 0.92f; - systemMemory.RouteRisk *= 0.9f; - } - } - - foreach (var signal in economicAssessment.CommoditySignals) - { - var commodityMemory = faction.Memory.CommodityMemories.FirstOrDefault(candidate => candidate.ItemId == signal.ItemId); - if (commodityMemory is null) - { - commodityMemory = new FactionCommodityMemoryRuntime { ItemId = signal.ItemId }; - faction.Memory.CommodityMemories.Add(commodityMemory); - } - - commodityMemory.LastObservedBacklog = signal.BuyBacklog; - commodityMemory.UpdatedAtUtc = nowUtc; - commodityMemory.HistoricalShortageScore = (commodityMemory.HistoricalShortageScore * 0.85f) - + (signal.Level is "critical" ? 1.2f : signal.Level is "low" ? 0.65f : 0f) - + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 0.1f); - commodityMemory.HistoricalSurplusScore = (commodityMemory.HistoricalSurplusScore * 0.85f) - + (signal.Level == "surplus" ? 0.4f : 0f); - if (signal.Level is "critical" or "low") - { - commodityMemory.LastCriticalAtUtc = nowUtc; - } - - var impactedSystems = world.Stations - .Where(station => station.FactionId == faction.Id && station.MarketOrderIds.Any(orderId => - world.MarketOrders.Any(order => - order.Id == orderId && - order.Kind == MarketOrderKinds.Buy && - order.ItemId == signal.ItemId && - order.RemainingAmount > 0.01f))) - .Select(station => station.SystemId) - .Distinct(StringComparer.Ordinal) - .ToList(); - foreach (var systemId in impactedSystems) - { - var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); - if (systemMemory is null) + if (assessment.PrimaryExpansionSystemId is null) { - continue; + assessment.PrimaryExpansionSystemId = factionRegions + .Join(regionEconomics, region => region.Id, economicState => economicState.RegionId, (region, economicState) => new { region, economicState }) + .OrderByDescending(entry => entry.economicState.ConstructionPressure) + .ThenByDescending(entry => entry.economicState.CorridorDependency) + .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) + .Select(entry => entry.region.CoreSystemId) + .FirstOrDefault(); } - systemMemory.HistoricalShortagePressure = (systemMemory.HistoricalShortagePressure * 0.82f) - + (signal.Level is "critical" ? 0.7f : signal.Level is "low" ? 0.35f : 0f); - if (signal.Level is "critical" or "low") + var transportCoverage = assessment.TargetTransportShipCount <= 0 + ? 1f + : Math.Clamp(assessment.TransportShipCount / (float)assessment.TargetTransportShipCount, 0f, 1.35f); + var minerCoverage = assessment.TargetMinerShipCount <= 0 + ? 1f + : Math.Clamp(assessment.MinerShipCount / (float)assessment.TargetMinerShipCount, 0f, 1.35f); + var constructorCoverage = assessment.TargetConstructorShipCount <= 0 + ? 1f + : Math.Clamp(assessment.ConstructorShipCount / (float)assessment.TargetConstructorShipCount, 0f, 1.35f); + var shortagePenalty = MathF.Min(0.55f, assessment.CriticalShortageCount * 0.08f); + var replacementPenalty = MathF.Min(0.45f, assessment.ReplacementPressure / MathF.Max(12f, assessment.TargetMilitaryShipCount * 8f)); + + assessment.LogisticsSecurityScore = Math.Clamp( + (transportCoverage * 0.45f) + + (minerCoverage * 0.2f) + + (constructorCoverage * 0.1f) + + (hasWarIndustrySupplyChain ? 0.2f : 0.05f) + - shortagePenalty + - (regionalSupplyRisk * 0.3f) + - (corridorRisk * 0.18f), + 0f, + 1f); + assessment.SustainmentScore = Math.Clamp( + (assessment.LogisticsSecurityScore * 0.55f) + + ((hasShipyard ? 0.15f : 0f) + (hasWarIndustrySupplyChain ? 0.15f : 0f)) + + ((assessment.MilitaryShipCount >= assessment.TargetMilitaryShipCount ? 0.2f : 0.08f)) + + (regionalSustainment * 0.18f) + - replacementPenalty, + 0f, + 1f); + + return assessment; + } + + private static bool HasOperationalWarIndustry(FactionEconomySnapshot economy) + { + var energy = economy.GetCommodity("energycells"); + var refined = economy.GetCommodity("refinedmetals"); + var hullparts = economy.GetCommodity("hullparts"); + var claytronics = economy.GetCommodity("claytronics"); + return CommodityOperationalSignal.IsOperational(energy, 180f) + && CommodityOperationalSignal.IsOperational(refined, 180f) + && CommodityOperationalSignal.IsOperational(hullparts, 180f) + && CommodityOperationalSignal.IsOperational(claytronics, 180f); + } + + private static void UpdateDoctrine( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject) + { + var controlledThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system"); + var contestedThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "contested-system"); + var expansionPressure = StationSimulationService.GetFactionExpansionPressure(world, faction.Id); + var shortagePressure = economicAssessment.CriticalShortageCount; + var activeWars = world.Geopolitics?.Diplomacy.Wars.Count(war => + war.Status == "active" + && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) ?? 0; + var borderTension = world.Geopolitics?.Diplomacy.BorderTensions + .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) + .DefaultIfEmpty() + .Average(tension => tension?.TensionScore ?? 0f) ?? 0f; + + faction.Doctrine.StrategicPosture = activeWars > 0 || controlledThreats > 1 + ? "fortify-and-recover" + : controlledThreats > 0 || borderTension > 0.45f + ? expansionProject is null ? "contested" : "defend-and-delay" + : expansionProject is not null && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold + ? "growth-through-pressure" + : shortagePressure > 0 ? "economic-recovery" : "stable-growth"; + faction.Doctrine.MilitaryPosture = activeWars + controlledThreats + contestedThreats switch { - systemMemory.LastShortageAtUtc = nowUtc; + >= 3 => "mobilized", + > 0 when economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold && economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount => "counteroffensive", + > 0 => "defensive", + _ => economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold ? "expeditionary" : "defensive", + }; + faction.Doctrine.ExpansionPosture = expansionProject is not null + ? controlledThreats > 0 || economicAssessment.SustainmentScore < 0.58f ? "cautious" : "active" + : expansionPressure > 0.2f && economicAssessment.SustainmentScore >= 0.55f ? "measured" : "consolidating"; + faction.Doctrine.EconomicPosture = shortagePressure >= 3 + ? "stabilizing" + : economicAssessment.HasWarIndustrySupplyChain && economicAssessment.SustainmentScore >= 0.7f + ? "surplus" + : "self-sufficient"; + } + + private static void UpdateBudget( + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment) + { + var reserveCredits = faction.Credits * faction.Doctrine.ReserveCreditsRatio; + var discretionary = MathF.Max(0f, faction.Credits - reserveCredits); + var warRatio = threatAssessment.ThreatSignals.Count > 0 + ? MathF.Max(faction.Doctrine.WarBudgetRatio, 0.4f) + : faction.Doctrine.WarBudgetRatio * 0.5f; + var expansionRatio = economicAssessment.PrimaryExpansionSystemId is not null + ? MathF.Max(faction.Doctrine.ExpansionBudgetRatio, 0.25f) + : faction.Doctrine.ExpansionBudgetRatio * 0.5f; + + faction.StrategicState.Budget = new FactionBudgetRuntime + { + ReservedCredits = reserveCredits, + WarCredits = discretionary * Math.Clamp(warRatio, 0f, 1f), + ExpansionCredits = discretionary * Math.Clamp(expansionRatio, 0f, 1f), + ReservedMilitaryAssets = Math.Max(1, (int)MathF.Ceiling(economicAssessment.TargetMilitaryShipCount * faction.Doctrine.ReserveMilitaryRatio)), + ReservedLogisticsAssets = Math.Max(economicAssessment.TargetTransportShipCount, economicAssessment.TransportShipCount), + ReservedConstructionAssets = Math.Max(economicAssessment.TargetConstructorShipCount, economicAssessment.ConstructorShipCount), + }; + } + + private static void UpdateMemory( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + DateTimeOffset nowUtc) + { + foreach (var station in world.Stations.Where(station => station.FactionId == faction.Id)) + { + faction.Memory.KnownSystemIds.Add(station.SystemId); } - } - } - faction.Memory.SystemMemories.Sort((left, right) => string.Compare(left.SystemId, right.SystemId, StringComparison.Ordinal)); - faction.Memory.CommodityMemories.Sort((left, right) => string.Compare(left.ItemId, right.ItemId, StringComparison.Ordinal)); - - if (faction.ShipsBuilt != faction.Memory.LastObservedShipsBuilt) - { - AppendOutcome(faction, new FactionOutcomeRecordRuntime - { - Id = $"outcome-built-{faction.Memory.LastPlanCycle}-{faction.ShipsBuilt}", - Kind = "ships-built", - Summary = $"{faction.Label} has built {faction.ShipsBuilt} ships.", - OccurredAtUtc = nowUtc, - }); - faction.Memory.LastObservedShipsBuilt = faction.ShipsBuilt; - } - - if (faction.ShipsLost != faction.Memory.LastObservedShipsLost) - { - AppendOutcome(faction, new FactionOutcomeRecordRuntime - { - Id = $"outcome-lost-{faction.Memory.LastPlanCycle}-{faction.ShipsLost}", - Kind = "ships-lost", - Summary = $"{faction.Label} has lost {faction.ShipsLost} ships.", - OccurredAtUtc = nowUtc, - }); - faction.Memory.LastObservedShipsLost = faction.ShipsLost; - } - - faction.Memory.LastObservedCredits = faction.Credits; - } - - private static List BuildTheaters( - SimulationWorld world, - FactionRuntime faction, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment, - IndustryExpansionProject? expansionProject, - DateTimeOffset nowUtc) - { - var theaters = new List(); - var systemMemories = faction.Memory.SystemMemories.ToDictionary(memory => memory.SystemId, StringComparer.Ordinal); - - foreach (var frontLine in (world.Geopolitics?.Territory.FrontLines - .Where(front => front.FactionIds.Contains(faction.Id, StringComparer.Ordinal)) - .OrderBy(front => front.Id, StringComparer.Ordinal) - ?? Enumerable.Empty())) - { - var ownedSystemId = frontLine.SystemIds - .FirstOrDefault(systemId => GeopoliticalSimulationService.FactionControlsSystem(world, faction.Id, systemId)) - ?? frontLine.SystemIds.FirstOrDefault(systemId => world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == systemId)) - ?? frontLine.SystemIds.FirstOrDefault(); - if (ownedSystemId is null) - { - continue; - } - if (theaters.Any(existing => string.Equals(existing.SystemId, ownedSystemId, StringComparison.Ordinal) && existing.Kind == "defense-front")) - { - continue; - } - - var pressure = FindTerritoryPressure(world, faction.Id, ownedSystemId); - var security = FindRegionalSecurityAssessment(world, faction.Id, ownedSystemId); - theaters.Add(new FactionTheaterRuntime - { - Id = $"theater-defense-{ownedSystemId}", - Kind = "defense-front", - SystemId = ownedSystemId, - Status = "active", - Priority = 96f + ((pressure?.PressureScore ?? 0f) * 24f) + ((security?.BorderPressure ?? 0f) * 20f), - SupplyRisk = MathF.Max(pressure?.CorridorRisk ?? 0f, security?.SupplyRisk ?? 0f), - FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, ownedSystemId), - TargetFactionId = frontLine.FactionIds.FirstOrDefault(id => !string.Equals(id, faction.Id, StringComparison.Ordinal)), - AnchorEntityId = frontLine.Id, - AnchorPosition = ResolveSystemAnchor(world, ownedSystemId), - UpdatedAtUtc = nowUtc, - }); - } - - foreach (var signal in threatAssessment.ThreatSignals - .Where(candidate => candidate.ScopeKind is "controlled-system" or "contested-system") - .OrderByDescending(candidate => (candidate.EnemyStationCount * 30) + (candidate.EnemyShipCount * 10)) - .ThenBy(candidate => candidate.ScopeId, StringComparer.Ordinal)) - { - if (theaters.Any(existing => string.Equals(existing.SystemId, signal.ScopeId, StringComparison.Ordinal) && existing.Kind == "defense-front")) - { - continue; - } - - var memory = systemMemories.GetValueOrDefault(signal.ScopeId); - theaters.Add(new FactionTheaterRuntime - { - Id = $"theater-defense-{signal.ScopeId}", - Kind = "defense-front", - SystemId = signal.ScopeId, - Status = "active", - Priority = 90f + (signal.EnemyStationCount * 12f) + (signal.EnemyShipCount * 4f) + ((memory?.DefensiveFailures ?? 0) * 6f), - SupplyRisk = memory?.RouteRisk ?? 0f, - FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, signal.ScopeId), - TargetFactionId = signal.EnemyFactionId, - AnchorPosition = ResolveSystemAnchor(world, signal.ScopeId), - UpdatedAtUtc = nowUtc, - }); - } - - foreach (var memory in faction.Memory.SystemMemories - .Where(candidate => - candidate.ControlledByFaction && - (candidate.FrontierPressure > 0.35f || candidate.RouteRisk > 0.3f || candidate.HistoricalShortagePressure > 0.45f) && - theaters.All(existing => existing.SystemId != candidate.SystemId || existing.Kind != "defense-front")) - .OrderByDescending(candidate => (candidate.FrontierPressure * 50f) + (candidate.RouteRisk * 30f) + (candidate.HistoricalShortagePressure * 25f)) - .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal) - .Take(2)) - { - theaters.Add(new FactionTheaterRuntime - { - Id = $"theater-frontier-{memory.SystemId}", - Kind = "defense-front", - SystemId = memory.SystemId, - Status = "active", - Priority = 58f + (memory.FrontierPressure * 22f) + (memory.RouteRisk * 14f) + (memory.HistoricalShortagePressure * 10f), - SupplyRisk = memory.RouteRisk, - FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, memory.SystemId), - AnchorPosition = ResolveSystemAnchor(world, memory.SystemId), - UpdatedAtUtc = nowUtc, - }); - } - - if (CanRunOffensivePosture(faction, economicAssessment, threatAssessment)) - { - foreach (var target in SelectOffensiveTargets(world, faction, threatAssessment, economicAssessment) - .Take(2)) - { - theaters.Add(new FactionTheaterRuntime + foreach (var ship in world.Ships.Where(ship => ship.FactionId == faction.Id)) { - Id = $"theater-offense-{target.SystemId}", - Kind = "offense-front", - SystemId = target.SystemId, - Status = "active", - Priority = target.Priority, - SupplyRisk = target.SupplyRisk, - FriendlyAssetValue = target.Value, - TargetFactionId = target.TargetFactionId, - AnchorEntityId = target.AnchorEntityId, - AnchorPosition = target.AnchorPosition, - UpdatedAtUtc = nowUtc, + faction.Memory.KnownSystemIds.Add(ship.SystemId); + } + + foreach (var signal in threatAssessment.ThreatSignals) + { + faction.Memory.KnownSystemIds.Add(signal.ScopeId); + if (!string.IsNullOrWhiteSpace(signal.EnemyFactionId)) + { + faction.Memory.KnownEnemyFactionIds.Add(signal.EnemyFactionId); + } + + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); + if (systemMemory is null) + { + systemMemory = new FactionSystemMemoryRuntime { SystemId = signal.ScopeId }; + faction.Memory.SystemMemories.Add(systemMemory); + } + + systemMemory.LastSeenAtUtc = nowUtc; + systemMemory.LastEnemyShipCount = signal.EnemyShipCount; + systemMemory.LastEnemyStationCount = signal.EnemyStationCount; + systemMemory.ControlledByFaction = signal.ScopeKind == "controlled-system"; + systemMemory.LastRole = signal.ScopeKind; + var strategicProfile = FindStrategicProfile(world, signal.ScopeId); + var territoryPressure = FindTerritoryPressure(world, faction.Id, signal.ScopeId); + systemMemory.FrontierPressure = ((signal.EnemyStationCount * 0.9f) + (signal.EnemyShipCount * 0.35f)) + + ((territoryPressure?.PressureScore ?? 0f) * 1.4f) + + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 0.35f); + systemMemory.RouteRisk = MathF.Max( + GeopoliticalSimulationService.GetSystemRouteRisk(world, signal.ScopeId, faction.Id), + signal.ScopeKind is "controlled-system" or "contested-system" + ? MathF.Min(1f, (signal.EnemyShipCount * 0.08f) + (signal.EnemyStationCount * 0.16f)) + : MathF.Min(1f, (signal.EnemyShipCount * 0.05f) + (signal.EnemyStationCount * 0.1f))); + if (signal.ScopeKind is "controlled-system" or "contested-system") + { + systemMemory.LastContestedAtUtc = nowUtc; + } + } + + foreach (var systemId in faction.Memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal)) + { + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); + if (systemMemory is not null) + { + continue; + } + + faction.Memory.SystemMemories.Add(new FactionSystemMemoryRuntime + { + SystemId = systemId, + LastSeenAtUtc = nowUtc, + ControlledByFaction = FactionControlsSystem(world, faction.Id, systemId), + LastRole = FactionControlsSystem(world, faction.Id, systemId) ? "controlled-system" : "observed-system", + RouteRisk = GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id), + }); + } + + foreach (var zone in world.Geopolitics?.Territory.Zones.Where(zone => string.Equals(zone.FactionId, faction.Id, StringComparison.Ordinal)) ?? []) + { + faction.Memory.KnownSystemIds.Add(zone.SystemId); + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == zone.SystemId); + if (systemMemory is null) + { + continue; + } + + systemMemory.LastRole = zone.Kind switch + { + "contested" => "contested-system", + "frontier" => "controlled-system", + "corridor" => "controlled-system", + _ => systemMemory.LastRole, + }; + systemMemory.RouteRisk = MathF.Max(systemMemory.RouteRisk, GeopoliticalSimulationService.GetSystemRouteRisk(world, zone.SystemId, faction.Id)); + } + + foreach (var systemMemory in faction.Memory.SystemMemories) + { + if (threatAssessment.ThreatSignals.All(signal => signal.ScopeId != systemMemory.SystemId)) + { + systemMemory.FrontierPressure *= 0.92f; + systemMemory.RouteRisk *= 0.9f; + } + } + + foreach (var signal in economicAssessment.CommoditySignals) + { + var commodityMemory = faction.Memory.CommodityMemories.FirstOrDefault(candidate => candidate.ItemId == signal.ItemId); + if (commodityMemory is null) + { + commodityMemory = new FactionCommodityMemoryRuntime { ItemId = signal.ItemId }; + faction.Memory.CommodityMemories.Add(commodityMemory); + } + + commodityMemory.LastObservedBacklog = signal.BuyBacklog; + commodityMemory.UpdatedAtUtc = nowUtc; + commodityMemory.HistoricalShortageScore = (commodityMemory.HistoricalShortageScore * 0.85f) + + (signal.Level is "critical" ? 1.2f : signal.Level is "low" ? 0.65f : 0f) + + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 0.1f); + commodityMemory.HistoricalSurplusScore = (commodityMemory.HistoricalSurplusScore * 0.85f) + + (signal.Level == "surplus" ? 0.4f : 0f); + if (signal.Level is "critical" or "low") + { + commodityMemory.LastCriticalAtUtc = nowUtc; + } + + var impactedSystems = world.Stations + .Where(station => station.FactionId == faction.Id && station.MarketOrderIds.Any(orderId => + world.MarketOrders.Any(order => + order.Id == orderId && + order.Kind == MarketOrderKinds.Buy && + order.ItemId == signal.ItemId && + order.RemainingAmount > 0.01f))) + .Select(station => station.SystemId) + .Distinct(StringComparer.Ordinal) + .ToList(); + foreach (var systemId in impactedSystems) + { + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); + if (systemMemory is null) + { + continue; + } + + systemMemory.HistoricalShortagePressure = (systemMemory.HistoricalShortagePressure * 0.82f) + + (signal.Level is "critical" ? 0.7f : signal.Level is "low" ? 0.35f : 0f); + if (signal.Level is "critical" or "low") + { + systemMemory.LastShortageAtUtc = nowUtc; + } + } + } + + faction.Memory.SystemMemories.Sort((left, right) => string.Compare(left.SystemId, right.SystemId, StringComparison.Ordinal)); + faction.Memory.CommodityMemories.Sort((left, right) => string.Compare(left.ItemId, right.ItemId, StringComparison.Ordinal)); + + if (faction.ShipsBuilt != faction.Memory.LastObservedShipsBuilt) + { + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-built-{faction.Memory.LastPlanCycle}-{faction.ShipsBuilt}", + Kind = "ships-built", + Summary = $"{faction.Label} has built {faction.ShipsBuilt} ships.", + OccurredAtUtc = nowUtc, + }); + faction.Memory.LastObservedShipsBuilt = faction.ShipsBuilt; + } + + if (faction.ShipsLost != faction.Memory.LastObservedShipsLost) + { + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-lost-{faction.Memory.LastPlanCycle}-{faction.ShipsLost}", + Kind = "ships-lost", + Summary = $"{faction.Label} has lost {faction.ShipsLost} ships.", + OccurredAtUtc = nowUtc, + }); + faction.Memory.LastObservedShipsLost = faction.ShipsLost; + } + + faction.Memory.LastObservedCredits = faction.Credits; + } + + private static List BuildTheaters( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject, + DateTimeOffset nowUtc) + { + var theaters = new List(); + var systemMemories = faction.Memory.SystemMemories.ToDictionary(memory => memory.SystemId, StringComparer.Ordinal); + + foreach (var frontLine in (world.Geopolitics?.Territory.FrontLines + .Where(front => front.FactionIds.Contains(faction.Id, StringComparer.Ordinal)) + .OrderBy(front => front.Id, StringComparer.Ordinal) + ?? Enumerable.Empty())) + { + var ownedSystemId = frontLine.SystemIds + .FirstOrDefault(systemId => GeopoliticalSimulationService.FactionControlsSystem(world, faction.Id, systemId)) + ?? frontLine.SystemIds.FirstOrDefault(systemId => world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == systemId)) + ?? frontLine.SystemIds.FirstOrDefault(); + if (ownedSystemId is null) + { + continue; + } + if (theaters.Any(existing => string.Equals(existing.SystemId, ownedSystemId, StringComparison.Ordinal) && existing.Kind == "defense-front")) + { + continue; + } + + var pressure = FindTerritoryPressure(world, faction.Id, ownedSystemId); + var security = FindRegionalSecurityAssessment(world, faction.Id, ownedSystemId); + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-defense-{ownedSystemId}", + Kind = "defense-front", + SystemId = ownedSystemId, + Status = "active", + Priority = 96f + ((pressure?.PressureScore ?? 0f) * 24f) + ((security?.BorderPressure ?? 0f) * 20f), + SupplyRisk = MathF.Max(pressure?.CorridorRisk ?? 0f, security?.SupplyRisk ?? 0f), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, ownedSystemId), + TargetFactionId = frontLine.FactionIds.FirstOrDefault(id => !string.Equals(id, faction.Id, StringComparison.Ordinal)), + AnchorEntityId = frontLine.Id, + AnchorPosition = ResolveSystemAnchor(world, ownedSystemId), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var signal in threatAssessment.ThreatSignals + .Where(candidate => candidate.ScopeKind is "controlled-system" or "contested-system") + .OrderByDescending(candidate => (candidate.EnemyStationCount * 30) + (candidate.EnemyShipCount * 10)) + .ThenBy(candidate => candidate.ScopeId, StringComparer.Ordinal)) + { + if (theaters.Any(existing => string.Equals(existing.SystemId, signal.ScopeId, StringComparison.Ordinal) && existing.Kind == "defense-front")) + { + continue; + } + + var memory = systemMemories.GetValueOrDefault(signal.ScopeId); + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-defense-{signal.ScopeId}", + Kind = "defense-front", + SystemId = signal.ScopeId, + Status = "active", + Priority = 90f + (signal.EnemyStationCount * 12f) + (signal.EnemyShipCount * 4f) + ((memory?.DefensiveFailures ?? 0) * 6f), + SupplyRisk = memory?.RouteRisk ?? 0f, + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, signal.ScopeId), + TargetFactionId = signal.EnemyFactionId, + AnchorPosition = ResolveSystemAnchor(world, signal.ScopeId), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var memory in faction.Memory.SystemMemories + .Where(candidate => + candidate.ControlledByFaction && + (candidate.FrontierPressure > 0.35f || candidate.RouteRisk > 0.3f || candidate.HistoricalShortagePressure > 0.45f) && + theaters.All(existing => existing.SystemId != candidate.SystemId || existing.Kind != "defense-front")) + .OrderByDescending(candidate => (candidate.FrontierPressure * 50f) + (candidate.RouteRisk * 30f) + (candidate.HistoricalShortagePressure * 25f)) + .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal) + .Take(2)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-frontier-{memory.SystemId}", + Kind = "defense-front", + SystemId = memory.SystemId, + Status = "active", + Priority = 58f + (memory.FrontierPressure * 22f) + (memory.RouteRisk * 14f) + (memory.HistoricalShortagePressure * 10f), + SupplyRisk = memory.RouteRisk, + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, memory.SystemId), + AnchorPosition = ResolveSystemAnchor(world, memory.SystemId), + UpdatedAtUtc = nowUtc, + }); + } + + if (CanRunOffensivePosture(faction, economicAssessment, threatAssessment)) + { + foreach (var target in SelectOffensiveTargets(world, faction, threatAssessment, economicAssessment) + .Take(2)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-offense-{target.SystemId}", + Kind = "offense-front", + SystemId = target.SystemId, + Status = "active", + Priority = target.Priority, + SupplyRisk = target.SupplyRisk, + FriendlyAssetValue = target.Value, + TargetFactionId = target.TargetFactionId, + AnchorEntityId = target.AnchorEntityId, + AnchorPosition = target.AnchorPosition, + UpdatedAtUtc = nowUtc, + }); + } + } + + if (expansionProject is not null && CanSupportExpansion(faction, economicAssessment, threatAssessment)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}", + Kind = "expansion-front", + SystemId = expansionProject.SystemId, + Status = "active", + Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f), + SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId), + AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId, + AnchorPosition = ResolveExpansionAnchor(world, expansionProject), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var signal in economicAssessment.CommoditySignals + .Where(candidate => + candidate.Level is "critical" or "low" + || candidate.ProjectedNetRatePerSecond < -0.01f + || faction.Memory.CommodityMemories.FirstOrDefault(memory => memory.ItemId == candidate.ItemId) is { HistoricalShortageScore: > 0.8f }) + .OrderByDescending(candidate => ComputeCommodityPriority(candidate)) + .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) + .Take(3)) + { + var systemId = FindPrimaryCommoditySystem(world, faction.Id, signal.ItemId) ?? economicAssessment.PrimaryExpansionSystemId ?? world.Systems.First().Definition.Id; + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-economy-{signal.ItemId}", + Kind = "economic-front", + SystemId = systemId, + Status = "active", + Priority = 45f + ComputeCommodityPriority(signal), + SupplyRisk = ComputeSystemRisk(world, faction, systemId), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, systemId), + AnchorEntityId = ResolveCommodityAnchorStation(world, faction.Id, signal.ItemId)?.Id, + UpdatedAtUtc = nowUtc, + }); + } + + theaters.Sort((left, right) => + { + var priority = right.Priority.CompareTo(left.Priority); + return priority != 0 ? priority : string.Compare(left.Id, right.Id, StringComparison.Ordinal); }); - } + + return theaters; } - if (expansionProject is not null && CanSupportExpansion(faction, economicAssessment, threatAssessment)) + private static List BuildCampaigns( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList theaters, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousCampaigns, + DateTimeOffset nowUtc) { - theaters.Add(new FactionTheaterRuntime - { - Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}", - Kind = "expansion-front", - SystemId = expansionProject.SystemId, - Status = "active", - Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f), - SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId), - FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId), - AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId, - AnchorPosition = ResolveExpansionAnchor(world, expansionProject), - UpdatedAtUtc = nowUtc, - }); - } + var campaigns = new List(); - foreach (var signal in economicAssessment.CommoditySignals - .Where(candidate => - candidate.Level is "critical" or "low" - || candidate.ProjectedNetRatePerSecond < -0.01f - || faction.Memory.CommodityMemories.FirstOrDefault(memory => memory.ItemId == candidate.ItemId) is { HistoricalShortageScore: > 0.8f }) - .OrderByDescending(candidate => ComputeCommodityPriority(candidate)) - .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) - .Take(3)) - { - var systemId = FindPrimaryCommoditySystem(world, faction.Id, signal.ItemId) ?? economicAssessment.PrimaryExpansionSystemId ?? world.Systems.First().Definition.Id; - theaters.Add(new FactionTheaterRuntime - { - Id = $"theater-economy-{signal.ItemId}", - Kind = "economic-front", - SystemId = systemId, - Status = "active", - Priority = 45f + ComputeCommodityPriority(signal), - SupplyRisk = ComputeSystemRisk(world, faction, systemId), - FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, systemId), - AnchorEntityId = ResolveCommodityAnchorStation(world, faction.Id, signal.ItemId)?.Id, - UpdatedAtUtc = nowUtc, - }); - } - - theaters.Sort((left, right) => - { - var priority = right.Priority.CompareTo(left.Priority); - return priority != 0 ? priority : string.Compare(left.Id, right.Id, StringComparison.Ordinal); - }); - - return theaters; - } - - private static List BuildCampaigns( - SimulationWorld world, - FactionRuntime faction, - IReadOnlyList theaters, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment, - IndustryExpansionProject? expansionProject, - IReadOnlyDictionary previousCampaigns, - DateTimeOffset nowUtc) - { - var campaigns = new List(); - - foreach (var theater in theaters) - { - var id = theater.Kind switch - { - "defense-front" => $"campaign-defense-{theater.SystemId}", - "offense-front" => $"campaign-offense-{theater.SystemId}", - "expansion-front" => $"campaign-expansion-{theater.SystemId}", - "economic-front" => $"campaign-economy-{ResolveCommodityFromTheaterId(theater.Id) ?? theater.Id}", - _ => $"campaign-{theater.Id}", - }; - previousCampaigns.TryGetValue(id, out var previous); - var systemMemory = theater.SystemId is null ? null : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == theater.SystemId); - var supplyAdequacy = ComputeCampaignSupplyAdequacy(faction, theater, economicAssessment); - var continuationScore = ComputeCampaignContinuationScore(faction, theater, economicAssessment, systemMemory); - var pauseReason = ResolveCampaignPauseReason(faction, theater, economicAssessment, threatAssessment, systemMemory); - var effectiveContinuationScore = previous is not null && previous.ContinuationScore > 0f - ? MathF.Max(previous.ContinuationScore * 0.7f, continuationScore) - : continuationScore; - var campaign = new FactionCampaignRuntime - { - Id = id, - Kind = theater.Kind switch + foreach (var theater in theaters) { - "defense-front" => "defense", - "offense-front" => "offense", - "expansion-front" => "expansion", - "economic-front" => "economic-stabilization", - _ => theater.Kind, - }, - Status = pauseReason is null ? "active" : "paused", - Priority = theater.Priority, - TheaterId = theater.Id, - TargetFactionId = theater.TargetFactionId, - TargetSystemId = theater.SystemId, - TargetEntityId = theater.AnchorEntityId, - CommodityId = theater.Kind == "economic-front" ? ResolveCommodityFromTheaterId(theater.Id) : expansionProject?.CommodityId, - SupportStationId = expansionProject?.SupportStationId, - CurrentStepIndex = previous?.CurrentStepIndex ?? 0, - Summary = BuildCampaignSummary(theater, expansionProject), - UpdatedAtUtc = nowUtc, - PauseReason = pauseReason, - ContinuationScore = effectiveContinuationScore, - SupplyAdequacy = supplyAdequacy, - ReplacementPressure = economicAssessment.ReplacementPressure, - FailureCount = previous?.FailureCount ?? GetCampaignFailureCount(systemMemory, theater.Kind), - SuccessCount = previous?.SuccessCount ?? GetCampaignSuccessCount(systemMemory, theater.Kind), - RequiresReinforcement = RequiresReinforcement(theater, economicAssessment, threatAssessment), - }; + var id = theater.Kind switch + { + "defense-front" => $"campaign-defense-{theater.SystemId}", + "offense-front" => $"campaign-offense-{theater.SystemId}", + "expansion-front" => $"campaign-expansion-{theater.SystemId}", + "economic-front" => $"campaign-economy-{ResolveCommodityFromTheaterId(theater.Id) ?? theater.Id}", + _ => $"campaign-{theater.Id}", + }; + previousCampaigns.TryGetValue(id, out var previous); + var systemMemory = theater.SystemId is null ? null : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == theater.SystemId); + var supplyAdequacy = ComputeCampaignSupplyAdequacy(faction, theater, economicAssessment); + var continuationScore = ComputeCampaignContinuationScore(faction, theater, economicAssessment, systemMemory); + var pauseReason = ResolveCampaignPauseReason(faction, theater, economicAssessment, threatAssessment, systemMemory); + var effectiveContinuationScore = previous is not null && previous.ContinuationScore > 0f + ? MathF.Max(previous.ContinuationScore * 0.7f, continuationScore) + : continuationScore; + var campaign = new FactionCampaignRuntime + { + Id = id, + Kind = theater.Kind switch + { + "defense-front" => "defense", + "offense-front" => "offense", + "expansion-front" => "expansion", + "economic-front" => "economic-stabilization", + _ => theater.Kind, + }, + Status = pauseReason is null ? "active" : "paused", + Priority = theater.Priority, + TheaterId = theater.Id, + TargetFactionId = theater.TargetFactionId, + TargetSystemId = theater.SystemId, + TargetEntityId = theater.AnchorEntityId, + CommodityId = theater.Kind == "economic-front" ? ResolveCommodityFromTheaterId(theater.Id) : expansionProject?.CommodityId, + SupportStationId = expansionProject?.SupportStationId, + CurrentStepIndex = previous?.CurrentStepIndex ?? 0, + Summary = BuildCampaignSummary(theater, expansionProject), + UpdatedAtUtc = nowUtc, + PauseReason = pauseReason, + ContinuationScore = effectiveContinuationScore, + SupplyAdequacy = supplyAdequacy, + ReplacementPressure = economicAssessment.ReplacementPressure, + FailureCount = previous?.FailureCount ?? GetCampaignFailureCount(systemMemory, theater.Kind), + SuccessCount = previous?.SuccessCount ?? GetCampaignSuccessCount(systemMemory, theater.Kind), + RequiresReinforcement = RequiresReinforcement(theater, economicAssessment, threatAssessment), + }; - campaign.Steps.AddRange(BuildCampaignSteps(campaign, expansionProject)); - if (previous is not null) - { - campaign.CreatedAtUtc = previous.CreatedAtUtc; - campaign.FleetCommanderId = previous.FleetCommanderId; - } + campaign.Steps.AddRange(BuildCampaignSteps(campaign, expansionProject)); + if (previous is not null) + { + campaign.CreatedAtUtc = previous.CreatedAtUtc; + campaign.FleetCommanderId = previous.FleetCommanderId; + } - campaigns.Add(campaign); - } + campaigns.Add(campaign); + } - if (economicAssessment.MilitaryShipCount < economicAssessment.TargetMilitaryShipCount - || economicAssessment.ReplacementPressure > 0.5f - || !economicAssessment.HasWarIndustrySupplyChain) - { - previousCampaigns.TryGetValue("campaign-force-build-up", out var previous); - var campaign = new FactionCampaignRuntime - { - Id = "campaign-force-build-up", - Kind = "force-build-up", - Status = "active", - Priority = 60f + ((economicAssessment.TargetMilitaryShipCount - economicAssessment.MilitaryShipCount) * 5f) + (economicAssessment.ReplacementPressure * 10f), - CurrentStepIndex = previous?.CurrentStepIndex ?? 0, - Summary = "Expand warfighting capacity.", - UpdatedAtUtc = nowUtc, - ContinuationScore = 0.9f, - SupplyAdequacy = economicAssessment.SustainmentScore, - ReplacementPressure = economicAssessment.ReplacementPressure, - FailureCount = previous?.FailureCount ?? 0, - SuccessCount = previous?.SuccessCount ?? 0, - RequiresReinforcement = true, - }; - campaign.Steps.AddRange( - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-step-1", Kind = "stabilize-war-industry", Status = "active", Summary = "Stabilize core war industry inputs." }, + if (economicAssessment.MilitaryShipCount < economicAssessment.TargetMilitaryShipCount + || economicAssessment.ReplacementPressure > 0.5f + || !economicAssessment.HasWarIndustrySupplyChain) + { + previousCampaigns.TryGetValue("campaign-force-build-up", out var previous); + var campaign = new FactionCampaignRuntime + { + Id = "campaign-force-build-up", + Kind = "force-build-up", + Status = "active", + Priority = 60f + ((economicAssessment.TargetMilitaryShipCount - economicAssessment.MilitaryShipCount) * 5f) + (economicAssessment.ReplacementPressure * 10f), + CurrentStepIndex = previous?.CurrentStepIndex ?? 0, + Summary = "Expand warfighting capacity.", + UpdatedAtUtc = nowUtc, + ContinuationScore = 0.9f, + SupplyAdequacy = economicAssessment.SustainmentScore, + ReplacementPressure = economicAssessment.ReplacementPressure, + FailureCount = previous?.FailureCount ?? 0, + SuccessCount = previous?.SuccessCount ?? 0, + RequiresReinforcement = true, + }; + campaign.Steps.AddRange( + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-step-1", Kind = "stabilize-war-industry", Status = "active", Summary = "Stabilize core war industry inputs." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-step-2", Kind = "increase-warship-output", Status = "planned", Summary = "Increase military ship production." }, ]); - if (previous is not null) - { - campaign.CreatedAtUtc = previous.CreatedAtUtc; - } + if (previous is not null) + { + campaign.CreatedAtUtc = previous.CreatedAtUtc; + } - campaigns.Add(campaign); + campaigns.Add(campaign); + } + + return campaigns + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); } - return campaigns - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .ToList(); - } - - private static List BuildCampaignSteps(FactionCampaignRuntime campaign, IndustryExpansionProject? expansionProject) - { - return campaign.Kind switch + private static List BuildCampaignSteps(FactionCampaignRuntime campaign, IndustryExpansionProject? expansionProject) { - "defense" => - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-defense", Status = "active", Summary = "Stage defenders into the contested system." }, + return campaign.Kind switch + { + "defense" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-defense", Status = "active", Summary = "Stage defenders into the contested system." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-hold", Kind = "hold-space", Status = "planned", Summary = "Hold the system and protect friendly assets." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-sustain", Kind = "sustain-defense", Status = "planned", Summary = "Sustain logistics and restore security." }, ], - "offense" => - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-offense", Status = "active", Summary = "Stage assault forces at the target frontier." }, + "offense" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-offense", Status = "active", Summary = "Stage assault forces at the target frontier." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-strike", Kind = "strike-targets", Status = "planned", Summary = "Engage hostile military and stations." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-hold", Kind = "hold-gains", Status = "planned", Summary = "Maintain pressure and deny recovery." }, ], - "expansion" => - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." }, + "expansion" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-supply", Kind = "supply-site", Status = "planned", Summary = "Move construction materials to the site." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-guard", Kind = "guard-site", Status = "planned", Summary = "Defend the expansion site until operational." }, ], - "economic-stabilization" => - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-source", Kind = "secure-supply", Status = "active", Summary = "Secure incoming production and mining sources." }, + "economic-stabilization" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-source", Kind = "secure-supply", Status = "active", Summary = "Secure incoming production and mining sources." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-move", Kind = "move-goods", Status = "planned", Summary = "Route logistics toward shortage points." }, new FactionPlanStepRuntime { Id = $"{campaign.Id}-stabilize", Kind = "stabilize-market", Status = "planned", Summary = "Restore stable stock levels." }, ], - _ => - [ - new FactionPlanStepRuntime { Id = $"{campaign.Id}-step", Kind = "maintain", Status = "active", Summary = campaign.Summary }, + _ => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-step", Kind = "maintain", Status = "active", Summary = campaign.Summary }, ], - }; - } + }; + } - private static List BuildObjectives( - SimulationWorld world, - FactionRuntime faction, - IReadOnlyList theaters, - IReadOnlyList campaigns, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment, - IndustryExpansionProject? expansionProject, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - var objectives = new List(); - var campaignsById = campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); - - foreach (var campaign in campaigns) + private static List BuildObjectives( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList theaters, + IReadOnlyList campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - var theater = theaters.FirstOrDefault(candidate => candidate.Id == campaign.TheaterId); - switch (campaign.Kind) - { - case "defense": - AddDefenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); - break; - case "offense": - if (campaign.Status == "active") + var objectives = new List(); + var campaignsById = campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); + + foreach (var campaign in campaigns) + { + var theater = theaters.FirstOrDefault(candidate => candidate.Id == campaign.TheaterId); + switch (campaign.Kind) + { + case "defense": + AddDefenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); + break; + case "offense": + if (campaign.Status == "active") + { + AddOffenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); + } + break; + case "expansion": + if (campaign.Status == "active") + { + AddExpansionObjectives(world, faction, campaign, theater, expansionProject, objectives, previousObjectives, nowUtc); + } + break; + case "economic-stabilization": + AddEconomicObjectives(world, faction, campaign, theater, economicAssessment, objectives, previousObjectives, nowUtc); + break; + case "force-build-up": + AddForceBuildUpObjectives(world, faction, campaign, economicAssessment, objectives, previousObjectives, nowUtc); + break; + } + } + + foreach (var objective in objectives) + { + if (campaignsById.TryGetValue(objective.CampaignId, out var campaign)) + { + campaign.ObjectiveIds.Add(objective.Id); + } + } + + return objectives + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + } + + private static void AddDefenseObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + var stations = world.Stations + .Where(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId) + .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Take(2) + .ToList(); + foreach (var station in stations) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-protect-station-{station.Id}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "protect-station", + DelegationKind = "ship", + BehaviorKind = "protect-station", + Status = "active", + Priority = campaign.Priority + 8f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + Notes = $"Protect {station.Label}", + UpdatedAtUtc = nowUtc, + }, + nowUtc)); + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime { - AddOffenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); - } - break; - case "expansion": - if (campaign.Status == "active") + Id = $"{campaign.Id}-patrol-{campaign.TargetSystemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "patrol-front", + DelegationKind = "ship", + BehaviorKind = "patrol", + Status = "active", + Priority = campaign.Priority + 2f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Patrol defensive front", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, + }, + nowUtc)); + + if ((theater?.SupplyRisk ?? 0f) > 0.25f) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-police-{campaign.TargetSystemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "police-front", + DelegationKind = "ship", + BehaviorKind = "police", + Status = "active", + Priority = campaign.Priority + 1f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Police frontier logistics routes", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + } + } + + private static void AddOffenseObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + var enemyStation = world.Stations + .Where(station => station.FactionId != faction.Id && station.SystemId == campaign.TargetSystemId) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (enemyStation is not null) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-strike-station-{enemyStation.Id}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "strike-station", + DelegationKind = "ship", + BehaviorKind = "attack-target", + Status = "active", + Priority = campaign.Priority + 10f, + TargetSystemId = enemyStation.SystemId, + TargetEntityId = enemyStation.Id, + TargetPosition = enemyStation.Position, + Notes = $"Strike {enemyStation.Label}", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime { - AddExpansionObjectives(world, faction, campaign, theater, expansionProject, objectives, previousObjectives, nowUtc); - } - break; - case "economic-stabilization": - AddEconomicObjectives(world, faction, campaign, theater, economicAssessment, objectives, previousObjectives, nowUtc); - break; - case "force-build-up": - AddForceBuildUpObjectives(world, faction, campaign, economicAssessment, objectives, previousObjectives, nowUtc); - break; - } + Id = $"{campaign.Id}-hold-front", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "hold-front", + DelegationKind = "ship", + BehaviorKind = "protect-position", + Status = "active", + Priority = campaign.Priority + 3f, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Maintain assault formation", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-fleet-supply", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "fleet-sustainment", + DelegationKind = "ship", + BehaviorKind = "supply-fleet", + Status = "active", + Priority = campaign.Priority + 1.5f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + ItemId = "hullparts", + Notes = "Sustain frontline fleet operations", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); } - foreach (var objective in objectives) + private static void AddExpansionObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + IndustryExpansionProject? expansionProject, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - if (campaignsById.TryGetValue(objective.CampaignId, out var campaign)) - { - campaign.ObjectiveIds.Add(objective.Id); - } - } - - return objectives - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .ToList(); - } - - private static void AddDefenseObjectives( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionTheaterRuntime? theater, - ICollection objectives, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - var stations = world.Stations - .Where(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId) - .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .Take(2) - .ToList(); - foreach (var station in stations) - { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + if (expansionProject is null) { - Id = $"{campaign.Id}-protect-station-{station.Id}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "protect-station", - DelegationKind = "ship", - BehaviorKind = "protect-station", - Status = "active", - Priority = campaign.Priority + 8f, - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - TargetSystemId = station.SystemId, - TargetEntityId = station.Id, - Notes = $"Protect {station.Label}", - UpdatedAtUtc = nowUtc, - }, - nowUtc)); - } + return; + } - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-patrol-{campaign.TargetSystemId}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "patrol-front", - DelegationKind = "ship", - BehaviorKind = "patrol", - Status = "active", - Priority = campaign.Priority + 2f, - HomeSystemId = campaign.TargetSystemId, - TargetSystemId = campaign.TargetSystemId, - TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), - Notes = "Patrol defensive front", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, - }, - nowUtc)); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-construct-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "construct-site", + DelegationKind = "ship", + BehaviorKind = "construct-station", + Status = "active", + Priority = campaign.Priority + 8f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + TargetEntityId = expansionProject.SiteId, + TargetPosition = ResolveExpansionAnchor(world, expansionProject), + Notes = $"Construct {expansionProject.ModuleId}", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); - if ((theater?.SupplyRisk ?? 0f) > 0.25f) - { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-supply-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "supply-site", + DelegationKind = "ship", + BehaviorKind = "find-build-tasks", + Status = "active", + Priority = campaign.Priority + 4f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + TargetEntityId = expansionProject.SiteId, + ItemId = expansionProject.CommodityId, + Notes = "Supply expansion site", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-guard-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "guard-site", + DelegationKind = "ship", + BehaviorKind = "protect-position", + Status = "active", + Priority = campaign.Priority + 2f, + TargetSystemId = expansionProject.SystemId, + TargetPosition = ResolveExpansionAnchor(world, expansionProject), + TargetEntityId = expansionProject.SiteId, + Notes = "Guard construction frontier", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + + if (CanMineItem(world, expansionProject.CommodityId)) { - Id = $"{campaign.Id}-police-{campaign.TargetSystemId}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "police-front", - DelegationKind = "ship", - BehaviorKind = "police", - Status = "active", - Priority = campaign.Priority + 1f, - HomeSystemId = campaign.TargetSystemId, - TargetSystemId = campaign.TargetSystemId, - TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), - Notes = "Police frontier logistics routes", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 1, - }, - nowUtc)); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-expansion-input", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "mine-expansion-input", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 1f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + ItemId = expansionProject.CommodityId, + Notes = $"Mine {expansionProject.CommodityId} for frontier build-up", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); + } } - } - private static void AddOffenseObjectives( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionTheaterRuntime? theater, - ICollection objectives, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - var enemyStation = world.Stations - .Where(station => station.FactionId != faction.Id && station.SystemId == campaign.TargetSystemId) - .OrderBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (enemyStation is not null) + private static void AddEconomicObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + FactionEconomicAssessmentRuntime economicAssessment, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + var itemId = campaign.CommodityId ?? ResolveCommodityFromTheaterId(theater?.Id); + if (string.IsNullOrWhiteSpace(itemId)) { - Id = $"{campaign.Id}-strike-station-{enemyStation.Id}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "strike-station", - DelegationKind = "ship", - BehaviorKind = "attack-target", - Status = "active", - Priority = campaign.Priority + 10f, - TargetSystemId = enemyStation.SystemId, - TargetEntityId = enemyStation.Id, - TargetPosition = enemyStation.Position, - Notes = $"Strike {enemyStation.Label}", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 2, - }, - nowUtc)); - } + return; + } - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-hold-front", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "hold-front", - DelegationKind = "ship", - BehaviorKind = "protect-position", - Status = "active", - Priority = campaign.Priority + 3f, - TargetSystemId = campaign.TargetSystemId, - TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), - Notes = "Maintain assault formation", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 2, - }, - nowUtc)); + var anchorStation = ResolveCommodityAnchorStation(world, faction.Id, itemId); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-trade-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "trade-shortage", + DelegationKind = "ship", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = campaign.Priority + 5f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, + TargetEntityId = anchorStation?.Id, + ItemId = itemId, + Notes = $"Stabilize {itemId} shortages", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, + }, + nowUtc)); - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-fleet-supply", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "fleet-sustainment", - DelegationKind = "ship", - BehaviorKind = "supply-fleet", - Status = "active", - Priority = campaign.Priority + 1.5f, - HomeSystemId = campaign.TargetSystemId, - TargetSystemId = campaign.TargetSystemId, - ItemId = "hullparts", - Notes = "Sustain frontline fleet operations", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 1, - }, - nowUtc)); - } - - private static void AddExpansionObjectives( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionTheaterRuntime? theater, - IndustryExpansionProject? expansionProject, - ICollection objectives, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - if (expansionProject is null) - { - return; - } - - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-construct-site", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "construct-site", - DelegationKind = "ship", - BehaviorKind = "construct-station", - Status = "active", - Priority = campaign.Priority + 8f, - HomeSystemId = expansionProject.SystemId, - HomeStationId = expansionProject.SupportStationId, - TargetSystemId = expansionProject.SystemId, - TargetEntityId = expansionProject.SiteId, - TargetPosition = ResolveExpansionAnchor(world, expansionProject), - Notes = $"Construct {expansionProject.ModuleId}", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 2, - }, - nowUtc)); - - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-supply-site", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "supply-site", - DelegationKind = "ship", - BehaviorKind = "find-build-tasks", - Status = "active", - Priority = campaign.Priority + 4f, - HomeSystemId = expansionProject.SystemId, - HomeStationId = expansionProject.SupportStationId, - TargetSystemId = expansionProject.SystemId, - TargetEntityId = expansionProject.SiteId, - ItemId = expansionProject.CommodityId, - Notes = "Supply expansion site", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 1, - }, - nowUtc)); - - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-guard-site", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "guard-site", - DelegationKind = "ship", - BehaviorKind = "protect-position", - Status = "active", - Priority = campaign.Priority + 2f, - TargetSystemId = expansionProject.SystemId, - TargetPosition = ResolveExpansionAnchor(world, expansionProject), - TargetEntityId = expansionProject.SiteId, - Notes = "Guard construction frontier", - UpdatedAtUtc = nowUtc, - UseOrders = true, - StagingOrderKind = ShipOrderKinds.Move, - ReinforcementLevel = 1, - }, - nowUtc)); - - if (CanMineItem(world, expansionProject.CommodityId)) - { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + if (CanMineItem(world, itemId)) { - Id = $"{campaign.Id}-mine-expansion-input", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "mine-expansion-input", - DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", - Status = "active", - Priority = campaign.Priority + 1f, - HomeSystemId = expansionProject.SystemId, - HomeStationId = expansionProject.SupportStationId, - TargetSystemId = expansionProject.SystemId, - ItemId = expansionProject.CommodityId, - Notes = $"Mine {expansionProject.CommodityId} for frontier build-up", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = 1, - }, - nowUtc)); - } - } + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "mine-shortage", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 3f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = campaign.TargetSystemId, + ItemId = itemId, + Notes = $"Mine {itemId} for economic recovery", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); + } - private static void AddEconomicObjectives( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionTheaterRuntime? theater, - FactionEconomicAssessmentRuntime economicAssessment, - ICollection objectives, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - var itemId = campaign.CommodityId ?? ResolveCommodityFromTheaterId(theater?.Id); - if (string.IsNullOrWhiteSpace(itemId)) - { - return; + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-revisit-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "revisit-stations", + DelegationKind = "ship", + BehaviorKind = "revisit-known-stations", + Status = "active", + Priority = campaign.Priority + 0.5f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, + ItemId = itemId, + Notes = $"Refresh station trade knowledge for {itemId}", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); } - var anchorStation = ResolveCommodityAnchorStation(world, faction.Id, itemId); - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-trade-{itemId}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "trade-shortage", - DelegationKind = "ship", - BehaviorKind = "fill-shortages", - Status = "active", - Priority = campaign.Priority + 5f, - HomeSystemId = anchorStation?.SystemId, - HomeStationId = anchorStation?.Id, - TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, - TargetEntityId = anchorStation?.Id, - ItemId = itemId, - Notes = $"Stabilize {itemId} shortages", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, - }, - nowUtc)); - - if (CanMineItem(world, itemId)) + private static void AddForceBuildUpObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionEconomicAssessmentRuntime economicAssessment, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + var shipyard = world.Stations + .Where(station => station.FactionId == faction.Id && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (shipyard is null) { - Id = $"{campaign.Id}-mine-{itemId}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "mine-shortage", - DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", - Status = "active", - Priority = campaign.Priority + 3f, - HomeSystemId = anchorStation?.SystemId, - HomeStationId = anchorStation?.Id, - TargetSystemId = campaign.TargetSystemId, - ItemId = itemId, - Notes = $"Mine {itemId} for economic recovery", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = 1, - }, - nowUtc)); - } + return; + } - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-revisit-{itemId}", - CampaignId = campaign.Id, - TheaterId = theater?.Id, - Kind = "revisit-stations", - DelegationKind = "ship", - BehaviorKind = "revisit-known-stations", - Status = "active", - Priority = campaign.Priority + 0.5f, - HomeSystemId = anchorStation?.SystemId, - HomeStationId = anchorStation?.Id, - TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, - ItemId = itemId, - Notes = $"Refresh station trade knowledge for {itemId}", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = 1, - }, - nowUtc)); - } + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-feed-shipyard", + CampaignId = campaign.Id, + Kind = "feed-shipyard", + DelegationKind = "ship", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = campaign.Priority + 4f, + HomeSystemId = shipyard.SystemId, + HomeStationId = shipyard.Id, + TargetSystemId = shipyard.SystemId, + TargetEntityId = shipyard.Id, + ItemId = economicAssessment.IndustrialBottleneckItemId ?? "hullparts", + Notes = "Keep shipyard supplied for fleet growth", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 2, + }, + nowUtc)); - private static void AddForceBuildUpObjectives( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionEconomicAssessmentRuntime economicAssessment, - ICollection objectives, - IReadOnlyDictionary previousObjectives, - DateTimeOffset nowUtc) - { - var shipyard = world.Stations - .Where(station => station.FactionId == faction.Id && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)) - .OrderBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (shipyard is null) - { - return; - } - - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime - { - Id = $"{campaign.Id}-feed-shipyard", - CampaignId = campaign.Id, - Kind = "feed-shipyard", - DelegationKind = "ship", - BehaviorKind = "fill-shortages", - Status = "active", - Priority = campaign.Priority + 4f, - HomeSystemId = shipyard.SystemId, - HomeStationId = shipyard.Id, - TargetSystemId = shipyard.SystemId, - TargetEntityId = shipyard.Id, - ItemId = economicAssessment.IndustrialBottleneckItemId ?? "hullparts", - Notes = "Keep shipyard supplied for fleet growth", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = 2, - }, - nowUtc)); - - if (!string.IsNullOrWhiteSpace(economicAssessment.IndustrialBottleneckItemId) - && CanMineItem(world, economicAssessment.IndustrialBottleneckItemId)) - { - objectives.Add(CreateObjective( - previousObjectives, - new FactionOperationalObjectiveRuntime + if (!string.IsNullOrWhiteSpace(economicAssessment.IndustrialBottleneckItemId) + && CanMineItem(world, economicAssessment.IndustrialBottleneckItemId)) { - Id = $"{campaign.Id}-mine-bottleneck", - CampaignId = campaign.Id, - Kind = "mine-bottleneck", - DelegationKind = "ship", - BehaviorKind = "expert-auto-mine", - Status = "active", - Priority = campaign.Priority + 2f, - HomeSystemId = shipyard.SystemId, - HomeStationId = shipyard.Id, - TargetSystemId = shipyard.SystemId, - ItemId = economicAssessment.IndustrialBottleneckItemId, - Notes = $"Mine {economicAssessment.IndustrialBottleneckItemId} for replacement pressure relief", - UpdatedAtUtc = nowUtc, - ReinforcementLevel = 1, - }, - nowUtc)); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-bottleneck", + CampaignId = campaign.Id, + Kind = "mine-bottleneck", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 2f, + HomeSystemId = shipyard.SystemId, + HomeStationId = shipyard.Id, + TargetSystemId = shipyard.SystemId, + ItemId = economicAssessment.IndustrialBottleneckItemId, + Notes = $"Mine {economicAssessment.IndustrialBottleneckItemId} for replacement pressure relief", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); + } } - } - private static FactionOperationalObjectiveRuntime CreateObjective( - IReadOnlyDictionary previousObjectives, - FactionOperationalObjectiveRuntime objective, - DateTimeOffset nowUtc) - { - if (previousObjectives.TryGetValue(objective.Id, out var previous)) + private static FactionOperationalObjectiveRuntime CreateObjective( + IReadOnlyDictionary previousObjectives, + FactionOperationalObjectiveRuntime objective, + DateTimeOffset nowUtc) { - objective.CreatedAtUtc = previous.CreatedAtUtc; - objective.CurrentStepIndex = previous.CurrentStepIndex; - objective.CommanderId = previous.CommanderId; - objective.Status = previous.Status; - objective.ReservedAssetIds.AddRange(previous.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal)); + if (previousObjectives.TryGetValue(objective.Id, out var previous)) + { + objective.CreatedAtUtc = previous.CreatedAtUtc; + objective.CurrentStepIndex = previous.CurrentStepIndex; + objective.CommanderId = previous.CommanderId; + objective.Status = previous.Status; + objective.ReservedAssetIds.AddRange(previous.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal)); + } + + objective.UpdatedAtUtc = nowUtc; + objective.Steps.AddRange(BuildObjectiveSteps(objective)); + return objective; } - objective.UpdatedAtUtc = nowUtc; - objective.Steps.AddRange(BuildObjectiveSteps(objective)); - return objective; - } - - private static IEnumerable BuildObjectiveSteps(FactionOperationalObjectiveRuntime objective) - { - return - [ - new FactionPlanStepRuntime + private static IEnumerable BuildObjectiveSteps(FactionOperationalObjectiveRuntime objective) + { + return + [ + new FactionPlanStepRuntime { Id = $"{objective.Id}-step-1", Kind = "deploy", @@ -1821,1594 +1821,1594 @@ internal sealed class CommanderPlanningService Summary = objective.Notes ?? objective.BehaviorKind, }, ]; - } - - private static List BuildReservations( - SimulationWorld world, - FactionRuntime faction, - IReadOnlyList objectives, - DateTimeOffset nowUtc) - { - var reservations = new List(); - var commanders = world.Commanders - .Where(commander => commander.IsAlive && commander.FactionId == faction.Id && commander.Kind is CommanderKind.Station or CommanderKind.Ship) - .OrderBy(commander => commander.Kind, StringComparer.Ordinal) - .ThenBy(commander => commander.Id, StringComparer.Ordinal) - .ToList(); - var reservedCommanderIds = new HashSet(StringComparer.Ordinal); - var availableMilitaryCommanders = commanders.Count(commander => - commander.Kind == CommanderKind.Ship && - world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f }); - var committedMilitaryCommanders = 0; - - foreach (var objective in objectives - .OrderByDescending(candidate => candidate.Priority + (candidate.ReinforcementLevel * 4f)) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal)) - { - if (IsCombatObjective(objective) - && objective.Priority < 95f - && availableMilitaryCommanders - committedMilitaryCommanders <= faction.StrategicState.Budget.ReservedMilitaryAssets) - { - objective.Status = "reserved"; - continue; - } - - var commander = SelectCommanderForObjective(world, objective, commanders, reservedCommanderIds); - if (commander is null) - { - continue; - } - - reservedCommanderIds.Add(commander.Id); - objective.CommanderId = commander.Id; - objective.ReservedAssetIds.Clear(); - objective.ReservedAssetIds.Add(commander.ControlledEntityId ?? commander.Id); - objective.Status = "active"; - objective.CurrentStepIndex = 0; - if (IsCombatObjective(objective)) - { - committedMilitaryCommanders += 1; - } - - reservations.Add(new FactionAssetReservationRuntime - { - Id = $"reservation-{objective.Id}-{commander.Id}", - ObjectiveId = objective.Id, - CampaignId = objective.CampaignId, - AssetKind = commander.Kind == CommanderKind.Station ? "station-commander" : "ship-commander", - AssetId = commander.Id, - Priority = objective.Priority, - UpdatedAtUtc = nowUtc, - }); } - return reservations; - } - - private static CommanderRuntime? SelectCommanderForObjective( - SimulationWorld world, - FactionOperationalObjectiveRuntime objective, - IReadOnlyList commanders, - IReadOnlySet reservedCommanderIds) - { - return commanders - .Where(commander => - !reservedCommanderIds.Contains(commander.Id) && - IsCommanderEligibleForObjective(world, commander, objective)) - .OrderByDescending(commander => ScoreCommanderForObjective(world, commander, objective)) - .ThenBy(commander => commander.Id, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static bool IsCommanderEligibleForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) - { - if (objective.DelegationKind == "station") + private static List BuildReservations( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList objectives, + DateTimeOffset nowUtc) { - return commander.Kind == CommanderKind.Station - && (objective.HomeStationId is null || string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)); - } + var reservations = new List(); + var commanders = world.Commanders + .Where(commander => commander.IsAlive && commander.FactionId == faction.Id && commander.Kind is CommanderKind.Station or CommanderKind.Ship) + .OrderBy(commander => commander.Kind, StringComparer.Ordinal) + .ThenBy(commander => commander.Id, StringComparer.Ordinal) + .ToList(); + var reservedCommanderIds = new HashSet(StringComparer.Ordinal); + var availableMilitaryCommanders = commanders.Count(commander => + commander.Kind == CommanderKind.Ship && + world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f }); + var committedMilitaryCommanders = 0; - if (commander.Kind != CommanderKind.Ship) - { - return false; - } - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); - if (ship is null || ship.Health <= 0f) - { - return false; - } - - return objective.BehaviorKind switch - { - "construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal), - "find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), - "fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), - "local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"), - "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal), - _ => true, - }; - } - - private static float ScoreCommanderForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) - { - var skillScore = commander.Skills.Leadership + commander.Skills.Coordination + commander.Skills.Strategy; - var homeBias = 0f; - - if (commander.Kind == CommanderKind.Station) - { - if (string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)) - { - homeBias += 25f; - } - - if (world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId) is { } station - && string.Equals(station.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) - { - homeBias += 12f; - } - - return homeBias + skillScore; - } - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); - if (ship is null) - { - return float.MinValue; - } - - if (string.Equals(ship.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) - { - homeBias += 30f; - } - else if (string.Equals(ship.SystemId, objective.HomeSystemId, StringComparison.Ordinal)) - { - homeBias += 18f; - } - - if (ship.CommanderId == objective.CommanderId) - { - homeBias += 8f; - } - - var distancePenalty = objective.TargetPosition is null ? 0f : ship.Position.DistanceTo(objective.TargetPosition.Value); - return homeBias + skillScore - distancePenalty; - } - - private static List BuildProductionPrograms( - FactionRuntime faction, - IReadOnlyList theaters, - IReadOnlyList campaigns, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment, - IndustryExpansionProject? expansionProject, - IReadOnlyDictionary previousPrograms) - { - var programs = new List(); - - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-military", - Kind = "military-fleet", - Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active", - Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f), - ShipKind = "military", - TargetCount = economicAssessment.TargetMilitaryShipCount, - CurrentCount = economicAssessment.MilitaryShipCount, - Notes = "Maintain enough military hulls for all active fronts.", - })); - - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-miners", - Kind = "mining-fleet", - Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active", - Priority = 60f, - ShipKind = "mining", - TargetCount = economicAssessment.TargetMinerShipCount, - CurrentCount = economicAssessment.MinerShipCount, - Notes = "Maintain raw resource extraction capacity.", - })); - - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-transports", - Kind = "logistics-fleet", - Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active", - Priority = 62f, - ShipKind = "transport", - TargetCount = economicAssessment.TargetTransportShipCount, - CurrentCount = economicAssessment.TransportShipCount, - Notes = "Maintain logistics throughput across stations and fronts.", - })); - - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-constructors", - Kind = "construction-fleet", - Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active", - Priority = expansionProject is null ? 35f : 68f, - ShipKind = "construction", - TargetCount = economicAssessment.TargetConstructorShipCount, - CurrentCount = economicAssessment.ConstructorShipCount, - Notes = "Maintain construction capacity for frontier growth.", - })); - - if (!economicAssessment.HasWarIndustrySupplyChain) - { - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-war-industry", - Kind = "war-industry", - Status = "active", - Priority = 78f, - CommodityId = "hullparts", - TargetCount = 1, - CurrentCount = economicAssessment.HasWarIndustrySupplyChain ? 1 : 0, - Notes = "Stabilize war industry bottlenecks.", - })); - } - - if (expansionProject is not null) - { - programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime - { - Id = $"program-{faction.Id}-expansion", - Kind = "expansion", - Status = "active", - Priority = 72f, - CampaignId = campaigns.FirstOrDefault(candidate => candidate.Kind == "expansion")?.Id, - CommodityId = expansionProject.CommodityId, - ModuleId = expansionProject.ModuleId, - TargetSystemId = expansionProject.SystemId, - TargetCount = 1, - CurrentCount = economicAssessment.PrimaryExpansionSiteId is null ? 0 : 1, - Notes = $"Expand into {expansionProject.SystemId}.", - })); - } - - return programs - .OrderByDescending(program => program.Priority) - .ThenBy(program => program.Id, StringComparer.Ordinal) - .ToList(); - } - - private static FactionProductionProgramRuntime CreateProductionProgram( - IReadOnlyDictionary previousPrograms, - FactionProductionProgramRuntime program) - { - if (previousPrograms.TryGetValue(program.Id, out var previous)) - { - program.CampaignId ??= previous.CampaignId; - } - - return program; - } - - private static void ReconcileCampaignLifecycle( - SimulationWorld world, - FactionRuntime faction, - IReadOnlyDictionary previousCampaigns, - IReadOnlyCollection campaigns, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment, - DateTimeOffset nowUtc) - { - var activeIds = campaigns.Select(campaign => campaign.Id).ToHashSet(StringComparer.Ordinal); - foreach (var campaign in campaigns) - { - if (!previousCampaigns.ContainsKey(campaign.Id)) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime + foreach (var objective in objectives + .OrderByDescending(candidate => candidate.Priority + (candidate.ReinforcementLevel * 4f)) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal)) { - Id = $"decision-{campaign.Id}-created", - Kind = "campaign-created", - Summary = $"Activated {campaign.Kind} campaign {campaign.Id}.", - RelatedEntityId = campaign.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - } + if (IsCombatObjective(objective) + && objective.Priority < 95f + && availableMilitaryCommanders - committedMilitaryCommanders <= faction.StrategicState.Budget.ReservedMilitaryAssets) + { + objective.Status = "reserved"; + continue; + } - foreach (var previous in previousCampaigns.Values.Where(candidate => !activeIds.Contains(candidate.Id))) - { - UpdateCampaignMemoryFromOutcome(world, faction, previous, economicAssessment, threatAssessment, nowUtc); - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{previous.Id}-completed-{faction.StrategicState.PlanCycle}", - Kind = "campaign-completed", - Summary = $"Closed {previous.Kind} campaign {previous.Id}.", - RelatedEntityId = previous.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - AppendOutcome(faction, new FactionOutcomeRecordRuntime - { - Id = $"outcome-{previous.Id}-{faction.StrategicState.PlanCycle}", - Kind = "campaign-completed", - Summary = $"Campaign {previous.Id} left the active strategic set.", - RelatedCampaignId = previous.Id, - OccurredAtUtc = nowUtc, - }); - } - } + var commander = SelectCommanderForObjective(world, objective, commanders, reservedCommanderIds); + if (commander is null) + { + continue; + } - private static void ReconcileObjectiveLifecycle( - FactionRuntime faction, - IReadOnlyDictionary previousObjectives, - IReadOnlyCollection objectives, - DateTimeOffset nowUtc) - { - var activeIds = objectives.Select(objective => objective.Id).ToHashSet(StringComparer.Ordinal); - foreach (var objective in objectives.Where(candidate => !previousObjectives.ContainsKey(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{objective.Id}-created", - Kind = "objective-created", - Summary = $"Delegated objective {objective.Kind}.", - RelatedEntityId = objective.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } + reservedCommanderIds.Add(commander.Id); + objective.CommanderId = commander.Id; + objective.ReservedAssetIds.Clear(); + objective.ReservedAssetIds.Add(commander.ControlledEntityId ?? commander.Id); + objective.Status = "active"; + objective.CurrentStepIndex = 0; + if (IsCombatObjective(objective)) + { + committedMilitaryCommanders += 1; + } - foreach (var previous in previousObjectives.Values.Where(candidate => !activeIds.Contains(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{previous.Id}-retired-{faction.StrategicState.PlanCycle}", - Kind = "objective-retired", - Summary = $"Retired objective {previous.Kind}.", - RelatedEntityId = previous.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - } - - private static void ReconcileTheaterLifecycle( - FactionRuntime faction, - IReadOnlyDictionary previousTheaters, - IReadOnlyCollection theaters, - DateTimeOffset nowUtc) - { - var activeIds = theaters.Select(theater => theater.Id).ToHashSet(StringComparer.Ordinal); - foreach (var theater in theaters.Where(candidate => !previousTheaters.ContainsKey(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{theater.Id}-opened", - Kind = "theater-opened", - Summary = $"Opened {theater.Kind} in {theater.SystemId}.", - RelatedEntityId = theater.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - - foreach (var previous in previousTheaters.Values.Where(candidate => !activeIds.Contains(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{previous.Id}-closed-{faction.StrategicState.PlanCycle}", - Kind = "theater-closed", - Summary = $"Closed {previous.Kind} in {previous.SystemId}.", - RelatedEntityId = previous.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - } - - private static void ReconcileProgramLifecycle( - FactionRuntime faction, - IReadOnlyDictionary previousPrograms, - IReadOnlyCollection programs, - DateTimeOffset nowUtc) - { - var activeIds = programs.Select(program => program.Id).ToHashSet(StringComparer.Ordinal); - foreach (var program in programs.Where(candidate => !previousPrograms.ContainsKey(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{program.Id}-started", - Kind = "program-started", - Summary = $"Started production program {program.Kind}.", - RelatedEntityId = program.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - - foreach (var previous in previousPrograms.Values.Where(candidate => !activeIds.Contains(candidate.Id))) - { - AppendDecision(faction, new FactionDecisionLogEntryRuntime - { - Id = $"decision-{previous.Id}-stopped-{faction.StrategicState.PlanCycle}", - Kind = "program-stopped", - Summary = $"Stopped production program {previous.Kind}.", - RelatedEntityId = previous.Id, - PlanCycle = faction.StrategicState.PlanCycle, - OccurredAtUtc = nowUtc, - }); - } - } - - private static void ApplyDelegation( - SimulationWorld world, - FactionRuntime faction, - CommanderRuntime factionCommander, - ICollection events, - DateTimeOffset nowUtc) - { - foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id)) - { - commander.ActiveObjectiveIds.Clear(); - } - - foreach (var objective in faction.StrategicState.Objectives.Where(candidate => candidate.CommanderId is not null)) - { - if (world.Commanders.FirstOrDefault(candidate => candidate.Id == objective.CommanderId) is not { } commander) - { - continue; - } - - commander.ActiveObjectiveIds.Add(objective.Id); - } - - var fleetCommanders = EnsureFleetCommanders(world, faction, factionCommander, nowUtc); - - var focusCampaign = faction.StrategicState.Campaigns - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - factionCommander.Assignment = new CommanderAssignmentRuntime - { - ObjectiveId = focusCampaign?.Id ?? $"objective-strategic-{faction.Id}", - CampaignId = focusCampaign?.Id, - TheaterId = focusCampaign?.TheaterId, - Kind = "strategic-executive", - BehaviorKind = "strategic-executive", - Status = "active", - Priority = 100f, - HomeSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, - TargetSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.ThreatAssessment.PrimaryThreatSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, - TargetEntityId = focusCampaign?.TargetEntityId, - Notes = focusCampaign?.Summary ?? faction.StrategicState.Status, - UpdatedAtUtc = nowUtc, - }; - - foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id && candidate.Kind is CommanderKind.Ship or CommanderKind.Station)) - { - var objective = faction.StrategicState.Objectives - .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (commander.Kind == CommanderKind.Ship - && world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } ship) - { - if (ApplyShipControlSurface(world, faction, factionCommander, commander, ship, objective, fleetCommanders, nowUtc)) - { - ship.NeedsReplan = true; - ship.LastReplanReason = objective is null ? "faction-fallback-updated" : "faction-objective-updated"; + reservations.Add(new FactionAssetReservationRuntime + { + Id = $"reservation-{objective.Id}-{commander.Id}", + ObjectiveId = objective.Id, + CampaignId = objective.CampaignId, + AssetKind = commander.Kind == CommanderKind.Station ? "station-commander" : "ship-commander", + AssetId = commander.Id, + Priority = objective.Priority, + UpdatedAtUtc = nowUtc, + }); } - } - if (objective is not null) - { - commander.NeedsReplan = true; - } + return reservations; } - RefreshCommanderHierarchy(world, faction.Id); - - events.Add(new SimulationEventRecord( - "faction", - faction.Id, - "strategic-cycle", - $"{faction.Label} strategic cycle {faction.StrategicState.PlanCycle} updated {faction.StrategicState.Campaigns.Count} campaigns across {faction.StrategicState.Theaters.Count} theaters.", - nowUtc)); - } - - private static void UpdateFleetCommander(SimulationWorld world, CommanderRuntime commander) - { - commander.ReplanTimer = FleetCommanderReplanInterval; - commander.NeedsReplan = false; - commander.PlanningCycle += 1; - commander.ActiveObjectiveIds.Clear(); - - var faction = FindFaction(world, commander.FactionId); - var campaign = faction?.StrategicState.Campaigns.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); - if (faction is null || campaign is null) + private static CommanderRuntime? SelectCommanderForObjective( + SimulationWorld world, + FactionOperationalObjectiveRuntime objective, + IReadOnlyList commanders, + IReadOnlySet reservedCommanderIds) { - commander.IsAlive = false; - commander.Assignment = null; - return; + return commanders + .Where(commander => + !reservedCommanderIds.Contains(commander.Id) && + IsCommanderEligibleForObjective(world, commander, objective)) + .OrderByDescending(commander => ScoreCommanderForObjective(world, commander, objective)) + .ThenBy(commander => commander.Id, StringComparer.Ordinal) + .FirstOrDefault(); } - var objectives = faction.StrategicState.Objectives - .Where(candidate => candidate.CampaignId == campaign.Id && candidate.CommanderId is not null) - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .ToList(); - foreach (var objective in objectives) + private static bool IsCommanderEligibleForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) { - commander.ActiveObjectiveIds.Add(objective.Id); - } - - commander.Assignment = new CommanderAssignmentRuntime - { - ObjectiveId = campaign.Id, - CampaignId = campaign.Id, - TheaterId = campaign.TheaterId, - Kind = "fleet-command", - BehaviorKind = campaign.Kind switch - { - "offense" => "attack-target", - "defense" => "protect-position", - "expansion" => "protect-position", - _ => "patrol", - }, - Status = campaign.Status, - Priority = campaign.Priority, - HomeSystemId = campaign.TargetSystemId, - TargetSystemId = campaign.TargetSystemId, - TargetEntityId = campaign.TargetEntityId, - Notes = campaign.Summary, - UpdatedAtUtc = DateTimeOffset.UtcNow, - }; - } - - private static CommanderAssignmentRuntime BuildStationFocusAssignment( - SimulationWorld world, - FactionRuntime faction, - StationRuntime station, - ConstructionSiteRuntime? activeSite) - { - var economic = faction.StrategicState.EconomicAssessment; - var role = StationSimulationService.DetermineStationRole(station); - var bottleneckItem = economic.IndustrialBottleneckItemId; - var nowUtc = DateTimeOffset.UtcNow; - - if (HasStationModules(station, "module_gen_build_l_01") - && economic.MilitaryShipCount < economic.TargetMilitaryShipCount) - { - return new CommanderAssignmentRuntime - { - ObjectiveId = $"objective-station-{station.Id}-ship-production", - Kind = "ship-production-focus", - BehaviorKind = "fill-shortages", - Status = "active", - Priority = 55f, - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - TargetSystemId = station.SystemId, - TargetEntityId = station.Id, - ItemId = bottleneckItem ?? "hullparts", - Notes = "Prioritize military replacement output", - UpdatedAtUtc = nowUtc, - }; - } - - if (!string.IsNullOrWhiteSpace(bottleneckItem) && StationCanProduceItem(world, station, bottleneckItem)) - { - return new CommanderAssignmentRuntime - { - ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}", - Kind = "commodity-focus", - BehaviorKind = "fill-shortages", - Status = "active", - Priority = 45f, - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - TargetSystemId = station.SystemId, - TargetEntityId = station.Id, - ItemId = bottleneckItem, - Notes = $"Stabilize {bottleneckItem} production", - UpdatedAtUtc = nowUtc, - }; - } - - if (activeSite is not null) - { - return new CommanderAssignmentRuntime - { - ObjectiveId = $"objective-station-{station.Id}-expansion-support", - Kind = "expansion-support", - BehaviorKind = "find-build-tasks", - Status = "active", - Priority = 40f, - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - TargetSystemId = activeSite.SystemId, - TargetEntityId = activeSite.Id, - ItemId = economic.IndustrialBottleneckItemId, - Notes = $"Support {activeSite.BlueprintId}", - UpdatedAtUtc = nowUtc, - }; - } - - return new CommanderAssignmentRuntime - { - ObjectiveId = $"objective-station-{station.Id}-oversight", - Kind = "station-oversight", - BehaviorKind = "fill-shortages", - Status = "active", - Priority = 30f, - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - TargetSystemId = station.SystemId, - TargetEntityId = station.Id, - ItemId = bottleneckItem, - Notes = role, - UpdatedAtUtc = nowUtc, - }; - } - - private static IReadOnlyDictionary EnsureFleetCommanders( - SimulationWorld world, - FactionRuntime faction, - CommanderRuntime factionCommander, - DateTimeOffset nowUtc) - { - var activeCampaignIds = faction.StrategicState.Campaigns - .Where(campaign => - campaign.Status == "active" && - faction.StrategicState.Objectives.Any(objective => - objective.CampaignId == campaign.Id && - objective.CommanderId is not null && - (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))) - .Select(campaign => campaign.Id) - .ToHashSet(StringComparer.Ordinal); - - world.Commanders.RemoveAll(commander => - commander.Kind == CommanderKind.Fleet && - commander.FactionId == faction.Id && - (commander.ControlledEntityId is null || !activeCampaignIds.Contains(commander.ControlledEntityId))); - - var fleetCommanders = new Dictionary(StringComparer.Ordinal); - foreach (var campaign in faction.StrategicState.Campaigns.Where(campaign => activeCampaignIds.Contains(campaign.Id))) - { - var commander = world.Commanders.FirstOrDefault(candidate => - candidate.Kind == CommanderKind.Fleet && - candidate.FactionId == faction.Id && - string.Equals(candidate.ControlledEntityId, campaign.Id, StringComparison.Ordinal)); - if (commander is null) - { - commander = new CommanderRuntime + if (objective.DelegationKind == "station") { - Id = $"commander-fleet-{campaign.Id}", - Kind = CommanderKind.Fleet, - FactionId = faction.Id, - ParentCommanderId = factionCommander.Id, - ControlledEntityId = campaign.Id, - PolicySetId = factionCommander.PolicySetId, - Doctrine = "fleet-control", - Skills = new CommanderSkillProfileRuntime - { - Leadership = Math.Clamp(factionCommander.Skills.Leadership, 3, 5), - Coordination = Math.Clamp(factionCommander.Skills.Coordination + 1, 3, 5), - Strategy = Math.Clamp(factionCommander.Skills.Strategy, 3, 5), - }, - ReplanTimer = 0f, - NeedsReplan = true, + return commander.Kind == CommanderKind.Station + && (objective.HomeStationId is null || string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)); + } + + if (commander.Kind != CommanderKind.Ship) + { + return false; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (ship is null || ship.Health <= 0f) + { + return false; + } + + return objective.BehaviorKind switch + { + "construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal), + "find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), + "fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), + "local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"), + "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal), + _ => true, }; - world.Commanders.Add(commander); - } + } - commander.ParentCommanderId = factionCommander.Id; - commander.PolicySetId = factionCommander.PolicySetId; - commander.IsAlive = true; - commander.Assignment = new CommanderAssignmentRuntime - { - ObjectiveId = campaign.Id, - CampaignId = campaign.Id, - TheaterId = campaign.TheaterId, - Kind = "fleet-command", - BehaviorKind = campaign.Kind switch + private static float ScoreCommanderForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) + { + var skillScore = commander.Skills.Leadership + commander.Skills.Coordination + commander.Skills.Strategy; + var homeBias = 0f; + + if (commander.Kind == CommanderKind.Station) { - "offense" => "attack-target", - "defense" => "protect-position", - "expansion" => "protect-position", - _ => "patrol", - }, - Status = campaign.Status, - Priority = campaign.Priority + 1f, - HomeSystemId = campaign.TargetSystemId, - TargetSystemId = campaign.TargetSystemId, - TargetEntityId = campaign.TargetEntityId, - Notes = campaign.Summary, - UpdatedAtUtc = nowUtc, - }; - campaign.FleetCommanderId = commander.Id; - fleetCommanders[campaign.Id] = commander; + if (string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)) + { + homeBias += 25f; + } + + if (world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId) is { } station + && string.Equals(station.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) + { + homeBias += 12f; + } + + return homeBias + skillScore; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (ship is null) + { + return float.MinValue; + } + + if (string.Equals(ship.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) + { + homeBias += 30f; + } + else if (string.Equals(ship.SystemId, objective.HomeSystemId, StringComparison.Ordinal)) + { + homeBias += 18f; + } + + if (ship.CommanderId == objective.CommanderId) + { + homeBias += 8f; + } + + var distancePenalty = objective.TargetPosition is null ? 0f : ship.Position.DistanceTo(objective.TargetPosition.Value); + return homeBias + skillScore - distancePenalty; } - return fleetCommanders; - } - - private static bool ApplyShipControlSurface( - SimulationWorld world, - FactionRuntime faction, - CommanderRuntime factionCommander, - CommanderRuntime commander, - ShipRuntime ship, - FactionOperationalObjectiveRuntime? objective, - IReadOnlyDictionary fleetCommanders, - DateTimeOffset nowUtc) - { - var desiredParentCommanderId = ResolveDelegatedParent(world, factionCommander, commander, objective, fleetCommanders); - var parentChanged = !string.Equals(commander.ParentCommanderId, desiredParentCommanderId, StringComparison.Ordinal); - var policyChanged = !string.Equals(commander.PolicySetId, factionCommander.PolicySetId, StringComparison.Ordinal); - commander.ParentCommanderId = desiredParentCommanderId; - commander.PolicySetId = factionCommander.PolicySetId; - ship.PolicySetId = commander.PolicySetId; - - var desiredBehavior = objective is null - ? BuildFallbackBehavior(world, ship) - : BuildBehaviorForObjective(world, ship, objective); - var behaviorChanged = !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior); - if (behaviorChanged) + private static List BuildProductionPrograms( + FactionRuntime faction, + IReadOnlyList theaters, + IReadOnlyList campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousPrograms) { - ApplyBehavior(ship.DefaultBehavior, desiredBehavior); + var programs = new List(); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-military", + Kind = "military-fleet", + Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active", + Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f), + ShipKind = "military", + TargetCount = economicAssessment.TargetMilitaryShipCount, + CurrentCount = economicAssessment.MilitaryShipCount, + Notes = "Maintain enough military hulls for all active fronts.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-miners", + Kind = "mining-fleet", + Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active", + Priority = 60f, + ShipKind = "mining", + TargetCount = economicAssessment.TargetMinerShipCount, + CurrentCount = economicAssessment.MinerShipCount, + Notes = "Maintain raw resource extraction capacity.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-transports", + Kind = "logistics-fleet", + Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active", + Priority = 62f, + ShipKind = "transport", + TargetCount = economicAssessment.TargetTransportShipCount, + CurrentCount = economicAssessment.TransportShipCount, + Notes = "Maintain logistics throughput across stations and fronts.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-constructors", + Kind = "construction-fleet", + Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active", + Priority = expansionProject is null ? 35f : 68f, + ShipKind = "construction", + TargetCount = economicAssessment.TargetConstructorShipCount, + CurrentCount = economicAssessment.ConstructorShipCount, + Notes = "Maintain construction capacity for frontier growth.", + })); + + if (!economicAssessment.HasWarIndustrySupplyChain) + { + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-war-industry", + Kind = "war-industry", + Status = "active", + Priority = 78f, + CommodityId = "hullparts", + TargetCount = 1, + CurrentCount = economicAssessment.HasWarIndustrySupplyChain ? 1 : 0, + Notes = "Stabilize war industry bottlenecks.", + })); + } + + if (expansionProject is not null) + { + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-expansion", + Kind = "expansion", + Status = "active", + Priority = 72f, + CampaignId = campaigns.FirstOrDefault(candidate => candidate.Kind == "expansion")?.Id, + CommodityId = expansionProject.CommodityId, + ModuleId = expansionProject.ModuleId, + TargetSystemId = expansionProject.SystemId, + TargetCount = 1, + CurrentCount = economicAssessment.PrimaryExpansionSiteId is null ? 0 : 1, + Notes = $"Expand into {expansionProject.SystemId}.", + })); + } + + return programs + .OrderByDescending(program => program.Priority) + .ThenBy(program => program.Id, StringComparer.Ordinal) + .ToList(); } - var desiredOrder = BuildAiOrderForObjective(world, ship, objective, nowUtc); - var ordersChanged = ReconcileAiOrders(ship, desiredOrder); - var desiredControlSourceKind = objective is null ? "faction-fallback" : "faction-objective"; - var desiredControlSourceId = objective?.Id ?? commander.Id; - var desiredControlReason = objective?.Notes ?? objective?.Kind ?? desiredBehavior.Kind; - var controlChanged = - !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) - || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) - || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); - ship.ControlSourceKind = desiredControlSourceKind; - ship.ControlSourceId = desiredControlSourceId; - ship.ControlReason = desiredControlReason; - ship.LastDeltaSignature = parentChanged || controlChanged ? string.Empty : ship.LastDeltaSignature; - return policyChanged || behaviorChanged || ordersChanged; - } - - private static string ResolveDelegatedParent( - SimulationWorld world, - CommanderRuntime factionCommander, - CommanderRuntime commander, - FactionOperationalObjectiveRuntime? objective, - IReadOnlyDictionary fleetCommanders) - { - if (objective?.CampaignId is not null - && fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander) - && (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))) + private static FactionProductionProgramRuntime CreateProductionProgram( + IReadOnlyDictionary previousPrograms, + FactionProductionProgramRuntime program) { - return fleetCommander.Id; + if (previousPrograms.TryGetValue(program.Id, out var previous)) + { + program.CampaignId ??= previous.CampaignId; + } + + return program; } - if (objective?.HomeStationId is not null - && world.Stations.FirstOrDefault(station => station.Id == objective.HomeStationId)?.CommanderId is { } stationCommanderId) + private static void ReconcileCampaignLifecycle( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyDictionary previousCampaigns, + IReadOnlyCollection campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) { - return stationCommanderId; + var activeIds = campaigns.Select(campaign => campaign.Id).ToHashSet(StringComparer.Ordinal); + foreach (var campaign in campaigns) + { + if (!previousCampaigns.ContainsKey(campaign.Id)) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{campaign.Id}-created", + Kind = "campaign-created", + Summary = $"Activated {campaign.Kind} campaign {campaign.Id}.", + RelatedEntityId = campaign.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + } + + foreach (var previous in previousCampaigns.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + UpdateCampaignMemoryFromOutcome(world, faction, previous, economicAssessment, threatAssessment, nowUtc); + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-completed-{faction.StrategicState.PlanCycle}", + Kind = "campaign-completed", + Summary = $"Closed {previous.Kind} campaign {previous.Id}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-{previous.Id}-{faction.StrategicState.PlanCycle}", + Kind = "campaign-completed", + Summary = $"Campaign {previous.Id} left the active strategic set.", + RelatedCampaignId = previous.Id, + OccurredAtUtc = nowUtc, + }); + } } - return factionCommander.Id; - } - - private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship) - { - var homeStation = ResolveFallbackHomeStation(world, ship); - if (HasShipCapabilities(ship.Definition, "mining")) + private static void ReconcileObjectiveLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousObjectives, + IReadOnlyCollection objectives, + DateTimeOffset nowUtc) { - return new DefaultBehaviorRuntime - { - Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", - HomeSystemId = homeStation?.SystemId ?? ship.SystemId, - HomeStationId = homeStation?.Id, - AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - PreferredItemId = null, - Radius = 24f, - MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1, - }; + var activeIds = objectives.Select(objective => objective.Id).ToHashSet(StringComparer.Ordinal); + foreach (var objective in objectives.Where(candidate => !previousObjectives.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{objective.Id}-created", + Kind = "objective-created", + Summary = $"Delegated objective {objective.Kind}.", + RelatedEntityId = objective.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousObjectives.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-retired-{faction.StrategicState.PlanCycle}", + Kind = "objective-retired", + Summary = $"Retired objective {previous.Kind}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } } - if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) + private static void ReconcileTheaterLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousTheaters, + IReadOnlyCollection theaters, + DateTimeOffset nowUtc) { - return new DefaultBehaviorRuntime - { - Kind = "advanced-auto-trade", - HomeSystemId = homeStation?.SystemId ?? ship.SystemId, - HomeStationId = homeStation?.Id, - AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - Radius = 24f, - MaxSystemRange = 2, - }; + var activeIds = theaters.Select(theater => theater.Id).ToHashSet(StringComparer.Ordinal); + foreach (var theater in theaters.Where(candidate => !previousTheaters.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{theater.Id}-opened", + Kind = "theater-opened", + Summary = $"Opened {theater.Kind} in {theater.SystemId}.", + RelatedEntityId = theater.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousTheaters.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-closed-{faction.StrategicState.PlanCycle}", + Kind = "theater-closed", + Summary = $"Closed {previous.Kind} in {previous.SystemId}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } } - if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) + private static void ReconcileProgramLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousPrograms, + IReadOnlyCollection programs, + DateTimeOffset nowUtc) { - return new DefaultBehaviorRuntime - { - Kind = "construct-station", - HomeSystemId = homeStation?.SystemId ?? ship.SystemId, - HomeStationId = homeStation?.Id, - AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - Radius = 28f, - MaxSystemRange = 2, - }; + var activeIds = programs.Select(program => program.Id).ToHashSet(StringComparer.Ordinal); + foreach (var program in programs.Where(candidate => !previousPrograms.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{program.Id}-started", + Kind = "program-started", + Summary = $"Started production program {program.Kind}.", + RelatedEntityId = program.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousPrograms.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-stopped-{faction.StrategicState.PlanCycle}", + Kind = "program-stopped", + Summary = $"Stopped production program {previous.Kind}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } } - if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + private static void ApplyDelegation( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + ICollection events, + DateTimeOffset nowUtc) { - var anchor = homeStation?.Position ?? ship.Position; - var patrolRadius = (homeStation?.Radius ?? 30f) + 90f; - return new DefaultBehaviorRuntime - { - Kind = "patrol", - HomeSystemId = homeStation?.SystemId ?? ship.SystemId, - HomeStationId = homeStation?.Id, - AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - TargetPosition = anchor, - PatrolPoints = BuildPatrolPoints(anchor, patrolRadius), - PatrolIndex = ship.DefaultBehavior.PatrolIndex, - Radius = patrolRadius, - MaxSystemRange = 1, - WaitSeconds = 2f, - RepeatIndex = ship.DefaultBehavior.RepeatIndex, - }; + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id)) + { + commander.ActiveObjectiveIds.Clear(); + } + + foreach (var objective in faction.StrategicState.Objectives.Where(candidate => candidate.CommanderId is not null)) + { + if (world.Commanders.FirstOrDefault(candidate => candidate.Id == objective.CommanderId) is not { } commander) + { + continue; + } + + commander.ActiveObjectiveIds.Add(objective.Id); + } + + var fleetCommanders = EnsureFleetCommanders(world, faction, factionCommander, nowUtc); + + var focusCampaign = faction.StrategicState.Campaigns + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + factionCommander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = focusCampaign?.Id ?? $"objective-strategic-{faction.Id}", + CampaignId = focusCampaign?.Id, + TheaterId = focusCampaign?.TheaterId, + Kind = "strategic-executive", + BehaviorKind = "strategic-executive", + Status = "active", + Priority = 100f, + HomeSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, + TargetSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.ThreatAssessment.PrimaryThreatSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, + TargetEntityId = focusCampaign?.TargetEntityId, + Notes = focusCampaign?.Summary ?? faction.StrategicState.Status, + UpdatedAtUtc = nowUtc, + }; + + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id && candidate.Kind is CommanderKind.Ship or CommanderKind.Station)) + { + var objective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (commander.Kind == CommanderKind.Ship + && world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } ship) + { + if (ApplyShipControlSurface(world, faction, factionCommander, commander, ship, objective, fleetCommanders, nowUtc)) + { + ship.NeedsReplan = true; + ship.LastReplanReason = objective is null ? "faction-fallback-updated" : "faction-objective-updated"; + } + } + + if (objective is not null) + { + commander.NeedsReplan = true; + } + } + + RefreshCommanderHierarchy(world, faction.Id); + + events.Add(new SimulationEventRecord( + "faction", + faction.Id, + "strategic-cycle", + $"{faction.Label} strategic cycle {faction.StrategicState.PlanCycle} updated {faction.StrategicState.Campaigns.Count} campaigns across {faction.StrategicState.Theaters.Count} theaters.", + nowUtc)); } - return new DefaultBehaviorRuntime + private static void UpdateFleetCommander(SimulationWorld world, CommanderRuntime commander) { - Kind = "idle", - HomeSystemId = homeStation?.SystemId ?? ship.SystemId, - HomeStationId = homeStation?.Id, - AreaSystemId = homeStation?.SystemId ?? ship.SystemId, - }; - } + commander.ReplanTimer = FleetCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); - private static StationRuntime? ResolveFallbackHomeStation(SimulationWorld world, ShipRuntime ship) => - world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); + var faction = FindFaction(world, commander.FactionId); + var campaign = faction?.StrategicState.Campaigns.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (faction is null || campaign is null) + { + commander.IsAlive = false; + commander.Assignment = null; + return; + } - private static DefaultBehaviorRuntime BuildBehaviorForObjective( - SimulationWorld world, - ShipRuntime ship, - FactionOperationalObjectiveRuntime objective) - { - var fallback = BuildFallbackBehavior(world, ship); - var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId; - var radius = objective.BehaviorKind switch - { - "protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius), - "follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f), - "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius), - _ => fallback.Radius, - }; - var maxRange = objective.BehaviorKind switch - { - "attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange), - "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange), - _ => fallback.MaxSystemRange, - }; + var objectives = faction.StrategicState.Objectives + .Where(candidate => candidate.CampaignId == campaign.Id && candidate.CommanderId is not null) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + foreach (var objective in objectives) + { + commander.ActiveObjectiveIds.Add(objective.Id); + } - return new DefaultBehaviorRuntime - { - Kind = objective.BehaviorKind, - HomeSystemId = objective.HomeSystemId ?? fallback.HomeSystemId ?? ship.SystemId, - HomeStationId = objective.HomeStationId ?? fallback.HomeStationId, - AreaSystemId = areaSystemId, - TargetEntityId = objective.TargetEntityId, - PreferredItemId = objective.ItemId ?? fallback.PreferredItemId, - PreferredNodeId = fallback.PreferredNodeId, - PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, - PreferredModuleId = fallback.PreferredModuleId, - TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, - WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds, - Radius = radius, - MaxSystemRange = maxRange, - KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations", - PatrolPoints = objective.BehaviorKind == "patrol" - ? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius) - : [], - PatrolIndex = ship.DefaultBehavior.PatrolIndex, - RepeatOrders = [], - RepeatIndex = ship.DefaultBehavior.RepeatIndex, - }; - } - - private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) - { - target.Kind = source.Kind; - target.HomeSystemId = source.HomeSystemId; - target.HomeStationId = source.HomeStationId; - target.AreaSystemId = source.AreaSystemId; - target.TargetEntityId = source.TargetEntityId; - target.PreferredItemId = source.PreferredItemId; - target.PreferredNodeId = source.PreferredNodeId; - target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; - target.PreferredModuleId = source.PreferredModuleId; - target.TargetPosition = source.TargetPosition; - target.WaitSeconds = source.WaitSeconds; - target.Radius = source.Radius; - target.MaxSystemRange = source.MaxSystemRange; - target.KnownStationsOnly = source.KnownStationsOnly; - target.PatrolPoints = source.PatrolPoints.ToList(); - target.PatrolIndex = source.PatrolIndex; - target.RepeatOrders = source.RepeatOrders.ToList(); - target.RepeatIndex = source.RepeatIndex; - } - - private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => - string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) - && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) - && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) - && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) - && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly - && left.PatrolPoints.SequenceEqual(right.PatrolPoints) - && left.RepeatOrders.Count == right.RepeatOrders.Count - && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); - - private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => - string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.Label, right.Label, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) - && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) - && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly; - - private static ShipOrderRuntime? BuildAiOrderForObjective( - SimulationWorld world, - ShipRuntime ship, - FactionOperationalObjectiveRuntime? objective, - DateTimeOffset nowUtc) - { - if (objective is null || !objective.UseOrders || string.IsNullOrWhiteSpace(objective.StagingOrderKind)) - { - return null; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = campaign.Id, + CampaignId = campaign.Id, + TheaterId = campaign.TheaterId, + Kind = "fleet-command", + BehaviorKind = campaign.Kind switch + { + "offense" => "attack-target", + "defense" => "protect-position", + "expansion" => "protect-position", + _ => "patrol", + }, + Status = campaign.Status, + Priority = campaign.Priority, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetEntityId = campaign.TargetEntityId, + Notes = campaign.Summary, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; } - var targetSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? ship.SystemId; - var targetPosition = objective.TargetPosition - ?? ResolveEntityPosition(world, objective.TargetEntityId) - ?? ship.Position; - var alreadyInSystem = string.Equals(ship.SystemId, targetSystemId, StringComparison.Ordinal); - var inPosition = alreadyInSystem && ship.Position.DistanceTo(targetPosition) <= MathF.Max(14f, objective.ReinforcementLevel * 18f); - if (inPosition) + private static CommanderAssignmentRuntime BuildStationFocusAssignment( + SimulationWorld world, + FactionRuntime faction, + StationRuntime station, + ConstructionSiteRuntime? activeSite) { - return null; + var economic = faction.StrategicState.EconomicAssessment; + var role = StationSimulationService.DetermineStationRole(station); + var bottleneckItem = economic.IndustrialBottleneckItemId; + var nowUtc = DateTimeOffset.UtcNow; + + if (HasStationModules(station, "module_gen_build_l_01") + && economic.MilitaryShipCount < economic.TargetMilitaryShipCount) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-ship-production", + Kind = "ship-production-focus", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 55f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem ?? "hullparts", + Notes = "Prioritize military replacement output", + UpdatedAtUtc = nowUtc, + }; + } + + if (!string.IsNullOrWhiteSpace(bottleneckItem) && StationCanProduceItem(world, station, bottleneckItem)) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}", + Kind = "commodity-focus", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 45f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem, + Notes = $"Stabilize {bottleneckItem} production", + UpdatedAtUtc = nowUtc, + }; + } + + if (activeSite is not null) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-expansion-support", + Kind = "expansion-support", + BehaviorKind = "find-build-tasks", + Status = "active", + Priority = 40f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = activeSite.SystemId, + TargetEntityId = activeSite.Id, + ItemId = economic.IndustrialBottleneckItemId, + Notes = $"Support {activeSite.BlueprintId}", + UpdatedAtUtc = nowUtc, + }; + } + + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-oversight", + Kind = "station-oversight", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 30f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem, + Notes = role, + UpdatedAtUtc = nowUtc, + }; } - return new ShipOrderRuntime + private static IReadOnlyDictionary EnsureFleetCommanders( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + DateTimeOffset nowUtc) { - Id = $"ai-order-{objective.Id}", - Kind = objective.StagingOrderKind, - Priority = 90 + objective.ReinforcementLevel, - InterruptCurrentPlan = true, - Label = $"{objective.Kind} staging", - TargetEntityId = objective.TargetEntityId, - TargetSystemId = targetSystemId, - TargetPosition = targetPosition, - DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null, - ItemId = objective.ItemId, - WaitSeconds = 0f, - Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f), - MaxSystemRange = null, - KnownStationsOnly = false, - }; - } + var activeCampaignIds = faction.StrategicState.Campaigns + .Where(campaign => + campaign.Status == "active" && + faction.StrategicState.Objectives.Any(objective => + objective.CampaignId == campaign.Id && + objective.CommanderId is not null && + (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))) + .Select(campaign => campaign.Id) + .ToHashSet(StringComparer.Ordinal); - private static Vector3? ResolveEntityPosition(SimulationWorld world, string? entityId) - { - if (entityId is null) - { - return null; + world.Commanders.RemoveAll(commander => + commander.Kind == CommanderKind.Fleet && + commander.FactionId == faction.Id && + (commander.ControlledEntityId is null || !activeCampaignIds.Contains(commander.ControlledEntityId))); + + var fleetCommanders = new Dictionary(StringComparer.Ordinal); + foreach (var campaign in faction.StrategicState.Campaigns.Where(campaign => activeCampaignIds.Contains(campaign.Id))) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Fleet && + candidate.FactionId == faction.Id && + string.Equals(candidate.ControlledEntityId, campaign.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-fleet-{campaign.Id}", + Kind = CommanderKind.Fleet, + FactionId = faction.Id, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = campaign.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "fleet-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp(factionCommander.Skills.Leadership, 3, 5), + Coordination = Math.Clamp(factionCommander.Skills.Coordination + 1, 3, 5), + Strategy = Math.Clamp(factionCommander.Skills.Strategy, 3, 5), + }, + ReplanTimer = 0f, + NeedsReplan = true, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = factionCommander.Id; + commander.PolicySetId = factionCommander.PolicySetId; + commander.IsAlive = true; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = campaign.Id, + CampaignId = campaign.Id, + TheaterId = campaign.TheaterId, + Kind = "fleet-command", + BehaviorKind = campaign.Kind switch + { + "offense" => "attack-target", + "defense" => "protect-position", + "expansion" => "protect-position", + _ => "patrol", + }, + Status = campaign.Status, + Priority = campaign.Priority + 1f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetEntityId = campaign.TargetEntityId, + Notes = campaign.Summary, + UpdatedAtUtc = nowUtc, + }; + campaign.FleetCommanderId = commander.Id; + fleetCommanders[campaign.Id] = commander; + } + + return fleetCommanders; } - var shipPosition = world.Ships.FirstOrDefault(ship => ship.Id == entityId)?.Position; - if (shipPosition is not null) + private static bool ApplyShipControlSurface( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + CommanderRuntime commander, + ShipRuntime ship, + FactionOperationalObjectiveRuntime? objective, + IReadOnlyDictionary fleetCommanders, + DateTimeOffset nowUtc) { - return shipPosition; + var desiredParentCommanderId = ResolveDelegatedParent(world, factionCommander, commander, objective, fleetCommanders); + var parentChanged = !string.Equals(commander.ParentCommanderId, desiredParentCommanderId, StringComparison.Ordinal); + var policyChanged = !string.Equals(commander.PolicySetId, factionCommander.PolicySetId, StringComparison.Ordinal); + commander.ParentCommanderId = desiredParentCommanderId; + commander.PolicySetId = factionCommander.PolicySetId; + ship.PolicySetId = commander.PolicySetId; + + var desiredBehavior = objective is null + ? BuildFallbackBehavior(world, ship) + : BuildBehaviorForObjective(world, ship, objective); + var behaviorChanged = !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior); + if (behaviorChanged) + { + ApplyBehavior(ship.DefaultBehavior, desiredBehavior); + } + + var desiredOrder = BuildAiOrderForObjective(world, ship, objective, nowUtc); + var ordersChanged = ReconcileAiOrders(ship, desiredOrder); + var desiredControlSourceKind = objective is null ? "faction-fallback" : "faction-objective"; + var desiredControlSourceId = objective?.Id ?? commander.Id; + var desiredControlReason = objective?.Notes ?? objective?.Kind ?? desiredBehavior.Kind; + var controlChanged = + !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) + || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) + || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + ship.LastDeltaSignature = parentChanged || controlChanged ? string.Empty : ship.LastDeltaSignature; + return policyChanged || behaviorChanged || ordersChanged; } - var stationPosition = world.Stations.FirstOrDefault(station => station.Id == entityId)?.Position; - if (stationPosition is not null) + private static string ResolveDelegatedParent( + SimulationWorld world, + CommanderRuntime factionCommander, + CommanderRuntime commander, + FactionOperationalObjectiveRuntime? objective, + IReadOnlyDictionary fleetCommanders) { - return stationPosition; + if (objective?.CampaignId is not null + && fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander) + && (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))) + { + return fleetCommander.Id; + } + + if (objective?.HomeStationId is not null + && world.Stations.FirstOrDefault(station => station.Id == objective.HomeStationId)?.CommanderId is { } stationCommanderId) + { + return stationCommanderId; + } + + return factionCommander.Id; } - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId); - if (site?.CelestialId is { } celestialId) + private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship) { - return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position; + var homeStation = ResolveFallbackHomeStation(world, ship); + if (HasShipCapabilities(ship.Definition, "mining")) + { + return new DefaultBehaviorRuntime + { + Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + PreferredItemId = null, + Radius = 24f, + MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1, + }; + } + + if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "advanced-auto-trade", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + Radius = 24f, + MaxSystemRange = 2, + }; + } + + if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "construct-station", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + Radius = 28f, + MaxSystemRange = 2, + }; + } + + if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + { + var anchor = homeStation?.Position ?? ship.Position; + var patrolRadius = (homeStation?.Radius ?? 30f) + 90f; + return new DefaultBehaviorRuntime + { + Kind = "patrol", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + TargetPosition = anchor, + PatrolPoints = BuildPatrolPoints(anchor, patrolRadius), + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + Radius = patrolRadius, + MaxSystemRange = 1, + WaitSeconds = 2f, + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; + } + + return new DefaultBehaviorRuntime + { + Kind = "idle", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + }; } - return null; - } + private static StationRuntime? ResolveFallbackHomeStation(SimulationWorld world, ShipRuntime ship) => + world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); - private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder) - { - var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0; - if (desiredOrder is null) + private static DefaultBehaviorRuntime BuildBehaviorForObjective( + SimulationWorld world, + ShipRuntime ship, + FactionOperationalObjectiveRuntime objective) { - return changed; + var fallback = BuildFallbackBehavior(world, ship); + var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId; + var radius = objective.BehaviorKind switch + { + "protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius), + "follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f), + "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius), + _ => fallback.Radius, + }; + var maxRange = objective.BehaviorKind switch + { + "attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange), + "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange), + _ => fallback.MaxSystemRange, + }; + + return new DefaultBehaviorRuntime + { + Kind = objective.BehaviorKind, + HomeSystemId = objective.HomeSystemId ?? fallback.HomeSystemId ?? ship.SystemId, + HomeStationId = objective.HomeStationId ?? fallback.HomeStationId, + AreaSystemId = areaSystemId, + TargetEntityId = objective.TargetEntityId, + PreferredItemId = objective.ItemId ?? fallback.PreferredItemId, + PreferredNodeId = fallback.PreferredNodeId, + PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, + PreferredModuleId = fallback.PreferredModuleId, + TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, + WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds, + Radius = radius, + MaxSystemRange = maxRange, + KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations", + PatrolPoints = objective.BehaviorKind == "patrol" + ? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius) + : [], + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + RepeatOrders = [], + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; } - var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); - if (existing is not null) + private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) { - if (ShipOrdersEqual(existing, desiredOrder)) - { + target.Kind = source.Kind; + target.HomeSystemId = source.HomeSystemId; + target.HomeStationId = source.HomeStationId; + target.AreaSystemId = source.AreaSystemId; + target.TargetEntityId = source.TargetEntityId; + target.PreferredItemId = source.PreferredItemId; + target.PreferredNodeId = source.PreferredNodeId; + target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; + target.PreferredModuleId = source.PreferredModuleId; + target.TargetPosition = source.TargetPosition; + target.WaitSeconds = source.WaitSeconds; + target.Radius = source.Radius; + target.MaxSystemRange = source.MaxSystemRange; + target.KnownStationsOnly = source.KnownStationsOnly; + target.PatrolPoints = source.PatrolPoints.ToList(); + target.PatrolIndex = source.PatrolIndex; + target.RepeatOrders = source.RepeatOrders.ToList(); + target.RepeatIndex = source.RepeatIndex; + } + + private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly + && left.PatrolPoints.SequenceEqual(right.PatrolPoints) + && left.RepeatOrders.Count == right.RepeatOrders.Count + && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); + + private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static ShipOrderRuntime? BuildAiOrderForObjective( + SimulationWorld world, + ShipRuntime ship, + FactionOperationalObjectiveRuntime? objective, + DateTimeOffset nowUtc) + { + if (objective is null || !objective.UseOrders || string.IsNullOrWhiteSpace(objective.StagingOrderKind)) + { + return null; + } + + var targetSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? ship.SystemId; + var targetPosition = objective.TargetPosition + ?? ResolveEntityPosition(world, objective.TargetEntityId) + ?? ship.Position; + var alreadyInSystem = string.Equals(ship.SystemId, targetSystemId, StringComparison.Ordinal); + var inPosition = alreadyInSystem && ship.Position.DistanceTo(targetPosition) <= MathF.Max(14f, objective.ReinforcementLevel * 18f); + if (inPosition) + { + return null; + } + + return new ShipOrderRuntime + { + Id = $"ai-order-{objective.Id}", + Kind = objective.StagingOrderKind, + Priority = 90 + objective.ReinforcementLevel, + InterruptCurrentPlan = true, + Label = $"{objective.Kind} staging", + TargetEntityId = objective.TargetEntityId, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null, + ItemId = objective.ItemId, + WaitSeconds = 0f, + Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f), + MaxSystemRange = null, + KnownStationsOnly = false, + }; + } + + private static Vector3? ResolveEntityPosition(SimulationWorld world, string? entityId) + { + if (entityId is null) + { + return null; + } + + var shipPosition = world.Ships.FirstOrDefault(ship => ship.Id == entityId)?.Position; + if (shipPosition is not null) + { + return shipPosition; + } + + var stationPosition = world.Stations.FirstOrDefault(station => station.Id == entityId)?.Position; + if (stationPosition is not null) + { + return stationPosition; + } + + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId); + if (site?.CelestialId is { } celestialId) + { + return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position; + } + + return null; + } + + private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder) + { + var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0; + if (desiredOrder is null) + { + return changed; + } + + var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); + if (existing is not null) + { + if (ShipOrdersEqual(existing, desiredOrder)) + { + return changed; + } + + ship.OrderQueue.Remove(existing); + changed = true; + } + + if (ship.OrderQueue.Count >= MaxAiOrdersPerShip) + { + changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0; + } + + if (ship.OrderQueue.Count < 8) + { + ship.OrderQueue.Add(desiredOrder); + changed = true; + } + return changed; - } - - ship.OrderQueue.Remove(existing); - changed = true; } - if (ship.OrderQueue.Count >= MaxAiOrdersPerShip) + private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.Priority == right.Priority + && left.InterruptCurrentPlan == right.InterruptCurrentPlan + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static void RefreshCommanderHierarchy(SimulationWorld world, string factionId) { - changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0; - } - - if (ship.OrderQueue.Count < 8) - { - ship.OrderQueue.Add(desiredOrder); - changed = true; - } - - return changed; - } - - private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => - string.Equals(left.Id, right.Id, StringComparison.Ordinal) - && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && left.Priority == right.Priority - && left.InterruptCurrentPlan == right.InterruptCurrentPlan - && string.Equals(left.Label, right.Label, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) - && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) - && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly; - - private static void RefreshCommanderHierarchy(SimulationWorld world, string factionId) - { - foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId)) - { - commander.SubordinateCommanderIds.Clear(); - } - - var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); - foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId && candidate.ParentCommanderId is not null)) - { - if (commander.ParentCommanderId is { } parentCommanderId && commandersById.TryGetValue(parentCommanderId, out var parent)) - { - parent.SubordinateCommanderIds.Add(commander.Id); - } - } - } - - private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) => - objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police"; - - private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId) - { - var stationValue = world.Stations.Count(station => station.FactionId == factionId && station.SystemId == systemId) * 35f; - var shipValue = world.Ships.Count(ship => ship.FactionId == factionId && ship.SystemId == systemId && ship.Health > 0f) * 6f; - return stationValue + shipValue; - } - - private static bool CanRunOffensivePosture( - FactionRuntime faction, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment) - { - var defenseLoad = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system"); - var militarySurplus = economicAssessment.MilitaryShipCount - - Math.Max(1, economicAssessment.TargetMilitaryShipCount - faction.Doctrine.ReinforcementLeadPerFront); - return economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold - && economicAssessment.LogisticsSecurityScore >= faction.Doctrine.SupplySecurityBias - && militarySurplus > 0 - && defenseLoad <= Math.Max(1, economicAssessment.ControlledSystemCount / 2); - } - - private static bool CanSupportExpansion( - FactionRuntime faction, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment) => - economicAssessment.ConstructorShipCount > 0 - && economicAssessment.SustainmentScore >= 0.52f - && threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") <= 1 - && faction.StrategicState.Budget.ExpansionCredits > 0f; - - private static float ComputeSystemRisk(SimulationWorld world, FactionRuntime faction, string systemId) => - MathF.Max( - faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == systemId)?.RouteRisk ?? 0f, - GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id)); - - private static SectorStrategicProfileRuntime? FindStrategicProfile(SimulationWorld world, string systemId) => - world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => string.Equals(profile.SystemId, systemId, StringComparison.Ordinal)); - - private static TerritoryPressureRuntime? FindTerritoryPressure(SimulationWorld world, string factionId, string systemId) => - world.Geopolitics?.Territory.Pressures - .Where(pressure => string.Equals(pressure.SystemId, systemId, StringComparison.Ordinal) - && (pressure.FactionId is null || string.Equals(pressure.FactionId, factionId, StringComparison.Ordinal))) - .OrderByDescending(pressure => pressure.PressureScore + pressure.CorridorRisk) - .ThenBy(pressure => pressure.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - private static RegionalSecurityAssessmentRuntime? FindRegionalSecurityAssessment(SimulationWorld world, string factionId, string systemId) - { - var regionId = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId)?.Id; - return regionId is null - ? null - : world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, regionId, StringComparison.Ordinal)); - } - - private static IEnumerable SelectOffensiveTargets( - SimulationWorld world, - FactionRuntime faction, - FactionThreatAssessmentRuntime threatAssessment, - FactionEconomicAssessmentRuntime economicAssessment) - { - return threatAssessment.ThreatSignals - .Where(signal => signal.ScopeKind == "hostile-system") - .Select(signal => - { - var memory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); - var distancePenalty = faction.Memory.SystemMemories - .Where(candidate => candidate.ControlledByFaction) - .Select(candidate => GetSystemDistanceTier(world, candidate.SystemId, signal.ScopeId)) - .DefaultIfEmpty(world.Systems.Count) - .Min(); - var failurePenalty = ((memory?.OffensiveFailures ?? 0) * 16f) + ((memory?.DefensiveFailures ?? 0) * 4f); - var value = (signal.EnemyStationCount * 28f) + (signal.EnemyShipCount * 8f); - var pressureBias = MathF.Max(0f, economicAssessment.SustainmentScore - 0.5f) * 25f; - var supplyRisk = memory?.RouteRisk ?? 0.1f; - var priority = 62f + value + pressureBias - (distancePenalty * 9f) - failurePenalty - (supplyRisk * 18f); - return new OffensiveTargetCandidate( - signal.ScopeId, - signal.EnemyFactionId, - ResolvePrimaryOffensiveAnchor(world, faction.Id, signal.ScopeId), - ResolveSystemAnchor(world, signal.ScopeId), - MathF.Max(0f, priority), - supplyRisk, - value); - }) - .Where(candidate => candidate.Priority > 35f) - .OrderByDescending(candidate => candidate.Priority) - .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal); - } - - private static string? ResolvePrimaryOffensiveAnchor(SimulationWorld world, string factionId, string systemId) => - world.Stations - .Where(station => station.FactionId != factionId && station.SystemId == systemId) - .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .Select(station => station.Id) - .FirstOrDefault(); - - private static bool StationCanProduceItem(SimulationWorld world, StationRuntime station, string itemId) => - world.Recipes.Values.Any(recipe => - StationSimulationService.RecipeAppliesToStation(station, recipe) - && recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))); - - private static float ComputeCampaignSupplyAdequacy( - FactionRuntime faction, - FactionTheaterRuntime theater, - FactionEconomicAssessmentRuntime economicAssessment) - { - var riskPenalty = theater.SupplyRisk * 0.45f; - var assetBias = MathF.Min(0.25f, theater.FriendlyAssetValue / 200f); - return Math.Clamp((economicAssessment.SustainmentScore * 0.75f) + assetBias - riskPenalty, 0f, 1f); - } - - private static float ComputeCampaignContinuationScore( - FactionRuntime faction, - FactionTheaterRuntime theater, - FactionEconomicAssessmentRuntime economicAssessment, - FactionSystemMemoryRuntime? systemMemory) - { - var successBias = theater.Kind switch - { - "offense-front" => (systemMemory?.OffensiveSuccesses ?? 0) * 0.08f, - _ => (systemMemory?.DefensiveSuccesses ?? 0) * 0.06f, - }; - var failurePenalty = theater.Kind switch - { - "offense-front" => (systemMemory?.OffensiveFailures ?? 0) * (0.09f + faction.Doctrine.FailureAversion * 0.1f), - _ => (systemMemory?.DefensiveFailures ?? 0) * 0.06f, - }; - return Math.Clamp( - 0.35f - + (theater.Priority / 160f) - + (economicAssessment.SustainmentScore * 0.4f) - + successBias - - failurePenalty - - (theater.SupplyRisk * 0.3f), - 0f, - 1.4f); - } - - private static string? ResolveCampaignPauseReason( - FactionRuntime faction, - FactionTheaterRuntime theater, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment, - FactionSystemMemoryRuntime? systemMemory) - { - return theater.Kind switch - { - "offense-front" when !CanRunOffensivePosture(faction, economicAssessment, threatAssessment) => "offensive-readiness-insufficient", - "offense-front" when (systemMemory?.OffensiveFailures ?? 0) >= 2 && economicAssessment.SustainmentScore < 0.75f => "recover-after-failure", - "offense-front" when economicAssessment.ReplacementPressure > Math.Max(4f, economicAssessment.TargetMilitaryShipCount * 0.4f) => "replacement-pressure", - "expansion-front" when !CanSupportExpansion(faction, economicAssessment, threatAssessment) => "expansion-delayed", - "economic-front" when economicAssessment.CriticalShortageCount == 0 && economicAssessment.SustainmentScore > 0.7f => "economy-stable", - _ => null, - }; - } - - private static int GetCampaignFailureCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => - theaterKind switch - { - "offense-front" => systemMemory?.OffensiveFailures ?? 0, - _ => systemMemory?.DefensiveFailures ?? 0, - }; - - private static int GetCampaignSuccessCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => - theaterKind switch - { - "offense-front" => systemMemory?.OffensiveSuccesses ?? 0, - _ => systemMemory?.DefensiveSuccesses ?? 0, - }; - - private static bool RequiresReinforcement( - FactionTheaterRuntime theater, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment) => - theater.Kind == "defense-front" - ? theater.Priority > 105f || threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") > 1 - : theater.Kind == "offense-front" - ? economicAssessment.SustainmentScore > 0.7f && economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount - : theater.Kind == "economic-front" - ? economicAssessment.CriticalShortageCount > 2 - : false; - - private static void UpdateCampaignMemoryFromOutcome( - SimulationWorld world, - FactionRuntime faction, - FactionCampaignRuntime campaign, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment, - DateTimeOffset nowUtc) - { - var systemMemory = campaign.TargetSystemId is null - ? null - : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == campaign.TargetSystemId); - var success = campaign.Kind switch - { - "defense" => campaign.TargetSystemId is not null - && FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) - && threatAssessment.ThreatSignals.All(signal => signal.ScopeId != campaign.TargetSystemId || signal.ScopeKind == "hostile-system"), - "offense" => campaign.TargetSystemId is not null - && (FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) - || world.Stations.All(station => station.FactionId == faction.Id || station.SystemId != campaign.TargetSystemId)), - "expansion" => campaign.TargetSystemId is not null - && (world.ConstructionSites.Any(site => site.FactionId == faction.Id && site.SystemId == campaign.TargetSystemId) - || world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId)), - "economic-stabilization" => campaign.CommodityId is not null - && economicAssessment.CommoditySignals.FirstOrDefault(signal => signal.ItemId == campaign.CommodityId) is { Level: not ("critical" or "low") }, - "force-build-up" => economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount, - _ => true, - }; - - if (systemMemory is not null) - { - if (campaign.Kind == "offense") - { - if (success) + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId)) { - systemMemory.OffensiveSuccesses += 1; + commander.SubordinateCommanderIds.Clear(); } - else + + var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId && candidate.ParentCommanderId is not null)) { - systemMemory.OffensiveFailures += 1; + if (commander.ParentCommanderId is { } parentCommanderId && commandersById.TryGetValue(parentCommanderId, out var parent)) + { + parent.SubordinateCommanderIds.Add(commander.Id); + } } - } - else if (campaign.Kind == "defense") + } + + private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) => + objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police"; + + private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId) + { + var stationValue = world.Stations.Count(station => station.FactionId == factionId && station.SystemId == systemId) * 35f; + var shipValue = world.Ships.Count(ship => ship.FactionId == factionId && ship.SystemId == systemId && ship.Health > 0f) * 6f; + return stationValue + shipValue; + } + + private static bool CanRunOffensivePosture( + FactionRuntime faction, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) + { + var defenseLoad = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system"); + var militarySurplus = economicAssessment.MilitaryShipCount + - Math.Max(1, economicAssessment.TargetMilitaryShipCount - faction.Doctrine.ReinforcementLeadPerFront); + return economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold + && economicAssessment.LogisticsSecurityScore >= faction.Doctrine.SupplySecurityBias + && militarySurplus > 0 + && defenseLoad <= Math.Max(1, economicAssessment.ControlledSystemCount / 2); + } + + private static bool CanSupportExpansion( + FactionRuntime faction, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) => + economicAssessment.ConstructorShipCount > 0 + && economicAssessment.SustainmentScore >= 0.52f + && threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") <= 1 + && faction.StrategicState.Budget.ExpansionCredits > 0f; + + private static float ComputeSystemRisk(SimulationWorld world, FactionRuntime faction, string systemId) => + MathF.Max( + faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == systemId)?.RouteRisk ?? 0f, + GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id)); + + private static SectorStrategicProfileRuntime? FindStrategicProfile(SimulationWorld world, string systemId) => + world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => string.Equals(profile.SystemId, systemId, StringComparison.Ordinal)); + + private static TerritoryPressureRuntime? FindTerritoryPressure(SimulationWorld world, string factionId, string systemId) => + world.Geopolitics?.Territory.Pressures + .Where(pressure => string.Equals(pressure.SystemId, systemId, StringComparison.Ordinal) + && (pressure.FactionId is null || string.Equals(pressure.FactionId, factionId, StringComparison.Ordinal))) + .OrderByDescending(pressure => pressure.PressureScore + pressure.CorridorRisk) + .ThenBy(pressure => pressure.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static RegionalSecurityAssessmentRuntime? FindRegionalSecurityAssessment(SimulationWorld world, string factionId, string systemId) + { + var regionId = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId)?.Id; + return regionId is null + ? null + : world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, regionId, StringComparison.Ordinal)); + } + + private static IEnumerable SelectOffensiveTargets( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment) + { + return threatAssessment.ThreatSignals + .Where(signal => signal.ScopeKind == "hostile-system") + .Select(signal => + { + var memory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); + var distancePenalty = faction.Memory.SystemMemories + .Where(candidate => candidate.ControlledByFaction) + .Select(candidate => GetSystemDistanceTier(world, candidate.SystemId, signal.ScopeId)) + .DefaultIfEmpty(world.Systems.Count) + .Min(); + var failurePenalty = ((memory?.OffensiveFailures ?? 0) * 16f) + ((memory?.DefensiveFailures ?? 0) * 4f); + var value = (signal.EnemyStationCount * 28f) + (signal.EnemyShipCount * 8f); + var pressureBias = MathF.Max(0f, economicAssessment.SustainmentScore - 0.5f) * 25f; + var supplyRisk = memory?.RouteRisk ?? 0.1f; + var priority = 62f + value + pressureBias - (distancePenalty * 9f) - failurePenalty - (supplyRisk * 18f); + return new OffensiveTargetCandidate( + signal.ScopeId, + signal.EnemyFactionId, + ResolvePrimaryOffensiveAnchor(world, faction.Id, signal.ScopeId), + ResolveSystemAnchor(world, signal.ScopeId), + MathF.Max(0f, priority), + supplyRisk, + value); + }) + .Where(candidate => candidate.Priority > 35f) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal); + } + + private static string? ResolvePrimaryOffensiveAnchor(SimulationWorld world, string factionId, string systemId) => + world.Stations + .Where(station => station.FactionId != factionId && station.SystemId == systemId) + .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Select(station => station.Id) + .FirstOrDefault(); + + private static bool StationCanProduceItem(SimulationWorld world, StationRuntime station, string itemId) => + world.Recipes.Values.Any(recipe => + StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))); + + private static float ComputeCampaignSupplyAdequacy( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment) + { + var riskPenalty = theater.SupplyRisk * 0.45f; + var assetBias = MathF.Min(0.25f, theater.FriendlyAssetValue / 200f); + return Math.Clamp((economicAssessment.SustainmentScore * 0.75f) + assetBias - riskPenalty, 0f, 1f); + } + + private static float ComputeCampaignContinuationScore( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionSystemMemoryRuntime? systemMemory) + { + var successBias = theater.Kind switch + { + "offense-front" => (systemMemory?.OffensiveSuccesses ?? 0) * 0.08f, + _ => (systemMemory?.DefensiveSuccesses ?? 0) * 0.06f, + }; + var failurePenalty = theater.Kind switch + { + "offense-front" => (systemMemory?.OffensiveFailures ?? 0) * (0.09f + faction.Doctrine.FailureAversion * 0.1f), + _ => (systemMemory?.DefensiveFailures ?? 0) * 0.06f, + }; + return Math.Clamp( + 0.35f + + (theater.Priority / 160f) + + (economicAssessment.SustainmentScore * 0.4f) + + successBias + - failurePenalty + - (theater.SupplyRisk * 0.3f), + 0f, + 1.4f); + } + + private static string? ResolveCampaignPauseReason( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + FactionSystemMemoryRuntime? systemMemory) + { + return theater.Kind switch + { + "offense-front" when !CanRunOffensivePosture(faction, economicAssessment, threatAssessment) => "offensive-readiness-insufficient", + "offense-front" when (systemMemory?.OffensiveFailures ?? 0) >= 2 && economicAssessment.SustainmentScore < 0.75f => "recover-after-failure", + "offense-front" when economicAssessment.ReplacementPressure > Math.Max(4f, economicAssessment.TargetMilitaryShipCount * 0.4f) => "replacement-pressure", + "expansion-front" when !CanSupportExpansion(faction, economicAssessment, threatAssessment) => "expansion-delayed", + "economic-front" when economicAssessment.CriticalShortageCount == 0 && economicAssessment.SustainmentScore > 0.7f => "economy-stable", + _ => null, + }; + } + + private static int GetCampaignFailureCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => + theaterKind switch { - if (success) + "offense-front" => systemMemory?.OffensiveFailures ?? 0, + _ => systemMemory?.DefensiveFailures ?? 0, + }; + + private static int GetCampaignSuccessCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => + theaterKind switch + { + "offense-front" => systemMemory?.OffensiveSuccesses ?? 0, + _ => systemMemory?.DefensiveSuccesses ?? 0, + }; + + private static bool RequiresReinforcement( + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) => + theater.Kind == "defense-front" + ? theater.Priority > 105f || threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") > 1 + : theater.Kind == "offense-front" + ? economicAssessment.SustainmentScore > 0.7f && economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount + : theater.Kind == "economic-front" + ? economicAssessment.CriticalShortageCount > 2 + : false; + + private static void UpdateCampaignMemoryFromOutcome( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) + { + var systemMemory = campaign.TargetSystemId is null + ? null + : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == campaign.TargetSystemId); + var success = campaign.Kind switch { - systemMemory.DefensiveSuccesses += 1; - systemMemory.FrontierPressure *= 0.75f; - systemMemory.RouteRisk *= 0.8f; - } - else + "defense" => campaign.TargetSystemId is not null + && FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) + && threatAssessment.ThreatSignals.All(signal => signal.ScopeId != campaign.TargetSystemId || signal.ScopeKind == "hostile-system"), + "offense" => campaign.TargetSystemId is not null + && (FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) + || world.Stations.All(station => station.FactionId == faction.Id || station.SystemId != campaign.TargetSystemId)), + "expansion" => campaign.TargetSystemId is not null + && (world.ConstructionSites.Any(site => site.FactionId == faction.Id && site.SystemId == campaign.TargetSystemId) + || world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId)), + "economic-stabilization" => campaign.CommodityId is not null + && economicAssessment.CommoditySignals.FirstOrDefault(signal => signal.ItemId == campaign.CommodityId) is { Level: not ("critical" or "low") }, + "force-build-up" => economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount, + _ => true, + }; + + if (systemMemory is not null) { - systemMemory.DefensiveFailures += 1; + if (campaign.Kind == "offense") + { + if (success) + { + systemMemory.OffensiveSuccesses += 1; + } + else + { + systemMemory.OffensiveFailures += 1; + } + } + else if (campaign.Kind == "defense") + { + if (success) + { + systemMemory.DefensiveSuccesses += 1; + systemMemory.FrontierPressure *= 0.75f; + systemMemory.RouteRisk *= 0.8f; + } + else + { + systemMemory.DefensiveFailures += 1; + } + } } - } + + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-{campaign.Id}-{nowUtc.ToUnixTimeMilliseconds()}", + Kind = success ? "campaign-success" : "campaign-failure", + Summary = $"{campaign.Kind} campaign {campaign.Id} {(success ? "succeeded" : "failed")}.", + RelatedCampaignId = campaign.Id, + OccurredAtUtc = nowUtc, + }); } - AppendOutcome(faction, new FactionOutcomeRecordRuntime + private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) { - Id = $"outcome-{campaign.Id}-{nowUtc.ToUnixTimeMilliseconds()}", - Kind = success ? "campaign-success" : "campaign-failure", - Summary = $"{campaign.Kind} campaign {campaign.Id} {(success ? "succeeded" : "failed")}.", - RelatedCampaignId = campaign.Id, - OccurredAtUtc = nowUtc, - }); - } + if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) + { + return 0; + } - private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) - { - if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) - { - return 0; + var originPosition = world.Systems.FirstOrDefault(system => system.Definition.Id == originSystemId)?.Position ?? Vector3.Zero; + return world.Systems + .OrderBy(system => system.Position.DistanceTo(originPosition)) + .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) + .Select(system => system.Definition.Id) + .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) + .Count(); } - var originPosition = world.Systems.FirstOrDefault(system => system.Definition.Id == originSystemId)?.Position ?? Vector3.Zero; - return world.Systems - .OrderBy(system => system.Position.DistanceTo(originPosition)) - .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) - .Select(system => system.Definition.Id) - .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) - .Count(); - } - - private static List BuildPatrolPoints(Vector3 anchor, float radius) => - [ - new Vector3(anchor.X + radius, anchor.Y, anchor.Z), + private static List BuildPatrolPoints(Vector3 anchor, float radius) => + [ + new Vector3(anchor.X + radius, anchor.Y, anchor.Z), new Vector3(anchor.X, anchor.Y, anchor.Z + radius), new Vector3(anchor.X - radius, anchor.Y, anchor.Z), new Vector3(anchor.X, anchor.Y, anchor.Z - radius), ]; - private sealed record OffensiveTargetCandidate( - string SystemId, - string? TargetFactionId, - string? AnchorEntityId, - Vector3 AnchorPosition, - float Priority, - float SupplyRisk, - float Value); + private sealed record OffensiveTargetCandidate( + string SystemId, + string? TargetFactionId, + string? AnchorEntityId, + Vector3 AnchorPosition, + float Priority, + float SupplyRisk, + float Value); - private static CommanderAssignmentRuntime ToAssignment(FactionOperationalObjectiveRuntime objective) => new() - { - ObjectiveId = objective.Id, - CampaignId = objective.CampaignId, - TheaterId = objective.TheaterId, - Kind = objective.Kind, - BehaviorKind = objective.BehaviorKind, - Status = objective.Status, - Priority = objective.Priority, - HomeSystemId = objective.HomeSystemId, - HomeStationId = objective.HomeStationId, - TargetSystemId = objective.TargetSystemId, - TargetEntityId = objective.TargetEntityId, - TargetPosition = objective.TargetPosition, - ItemId = objective.ItemId, - Notes = objective.Notes, - UpdatedAtUtc = objective.UpdatedAtUtc, - }; - - private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) - { - if (ReferenceEquals(left, right)) + private static CommanderAssignmentRuntime ToAssignment(FactionOperationalObjectiveRuntime objective) => new() { - return true; - } - - if (left is null || right is null) - { - return false; - } - - return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) - && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) - && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) - && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) - && string.Equals(left.Status, right.Status, StringComparison.Ordinal) - && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) - && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && left.Priority.Equals(right.Priority); - } - - private static void AppendDecision(FactionRuntime faction, FactionDecisionLogEntryRuntime entry) - { - if (faction.DecisionLog.Any(candidate => candidate.Id == entry.Id)) - { - return; - } - - faction.DecisionLog.Add(entry); - if (faction.DecisionLog.Count > MaxDecisionLogEntries) - { - faction.DecisionLog.RemoveRange(0, faction.DecisionLog.Count - MaxDecisionLogEntries); - } - } - - private static void AppendOutcome(FactionRuntime faction, FactionOutcomeRecordRuntime entry) - { - if (faction.Memory.RecentOutcomes.Any(candidate => candidate.Id == entry.Id)) - { - return; - } - - faction.Memory.RecentOutcomes.Add(entry); - if (faction.Memory.RecentOutcomes.Count > MaxOutcomeEntries) - { - faction.Memory.RecentOutcomes.RemoveRange(0, faction.Memory.RecentOutcomes.Count - MaxOutcomeEntries); - } - } - - private static string ResolveStrategicStatus( - IReadOnlyCollection theaters, - IReadOnlyCollection campaigns, - FactionEconomicAssessmentRuntime economicAssessment, - FactionThreatAssessmentRuntime threatAssessment) - { - if (threatAssessment.ThreatSignals.Any(signal => signal.ScopeKind == "controlled-system")) - { - return "defending"; - } - - if (campaigns.Any(campaign => campaign.Kind == "offense")) - { - return "offensive"; - } - - if (campaigns.Any(campaign => campaign.Kind == "expansion")) - { - return "expanding"; - } - - if (economicAssessment.CommoditySignals.Any(signal => signal.Level is "critical" or "low")) - { - return "stabilizing"; - } - - return theaters.Count == 0 ? "stable" : "active"; - } - - private static float ComputeCommodityPriority(FactionCommoditySignalRuntime signal) - { - var levelBias = signal.Level switch - { - "critical" => 60f, - "low" => 35f, - _ => 10f, - }; - return levelBias - + signal.BuyBacklog - + signal.ReservedForConstruction - + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f); - } - - private static bool CanMineItem(SimulationWorld world, string itemId) => - world.Nodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal)); - - private static string? FindPrimaryCommoditySystem(SimulationWorld world, string factionId, string itemId) => - world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) - .Join( - world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), - bottleneck => bottleneck.RegionId, - region => region.Id, - (bottleneck, region) => new { bottleneck, region }) - .OrderByDescending(entry => entry.bottleneck.Severity) - .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) - .Select(entry => entry.region.CoreSystemId) - .FirstOrDefault() - ?? world.Stations - .Where(station => station.FactionId == factionId && GetInventoryAmount(station.Inventory, itemId) > 0.01f) - .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .Select(station => station.SystemId) - .FirstOrDefault(); - - private static StationRuntime? ResolveCommodityAnchorStation(SimulationWorld world, string factionId, string itemId) => - world.Stations - .Where(station => station.FactionId == factionId) - .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) - .ThenByDescending(station => station.MarketOrderIds.Count(orderId => - world.MarketOrders.Any(order => order.Id == orderId && order.ItemId == itemId && order.Kind == MarketOrderKinds.Buy))) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - private static string BuildCampaignSummary(FactionTheaterRuntime theater, IndustryExpansionProject? expansionProject) => - theater.Kind switch - { - "defense-front" => $"Defend {theater.SystemId} from hostile pressure.", - "offense-front" => $"Project force into {theater.SystemId}.", - "expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.", - "economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.", - _ => theater.Kind, + ObjectiveId = objective.Id, + CampaignId = objective.CampaignId, + TheaterId = objective.TheaterId, + Kind = objective.Kind, + BehaviorKind = objective.BehaviorKind, + Status = objective.Status, + Priority = objective.Priority, + HomeSystemId = objective.HomeSystemId, + HomeStationId = objective.HomeStationId, + TargetSystemId = objective.TargetSystemId, + TargetEntityId = objective.TargetEntityId, + TargetPosition = objective.TargetPosition, + ItemId = objective.ItemId, + Notes = objective.Notes, + UpdatedAtUtc = objective.UpdatedAtUtc, }; - private static string? ResolveCommodityFromTheaterId(string? theaterId) - { - if (string.IsNullOrWhiteSpace(theaterId)) + private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) { - return null; + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) + && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) + && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) + && string.Equals(left.Status, right.Status, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && left.Priority.Equals(right.Priority); } - const string prefix = "theater-economy-"; - return theaterId.StartsWith(prefix, StringComparison.Ordinal) ? theaterId[prefix.Length..] : null; - } - - private static Vector3 ResolveSystemAnchor(SimulationWorld world, string? systemId) - { - if (systemId is null) + private static void AppendDecision(FactionRuntime faction, FactionDecisionLogEntryRuntime entry) { - return Vector3.Zero; + if (faction.DecisionLog.Any(candidate => candidate.Id == entry.Id)) + { + return; + } + + faction.DecisionLog.Add(entry); + if (faction.DecisionLog.Count > MaxDecisionLogEntries) + { + faction.DecisionLog.RemoveRange(0, faction.DecisionLog.Count - MaxDecisionLogEntries); + } } - var station = world.Stations - .Where(candidate => candidate.SystemId == systemId) - .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (station is not null) + private static void AppendOutcome(FactionRuntime faction, FactionOutcomeRecordRuntime entry) { - return station.Position; + if (faction.Memory.RecentOutcomes.Any(candidate => candidate.Id == entry.Id)) + { + return; + } + + faction.Memory.RecentOutcomes.Add(entry); + if (faction.Memory.RecentOutcomes.Count > MaxOutcomeEntries) + { + faction.Memory.RecentOutcomes.RemoveRange(0, faction.Memory.RecentOutcomes.Count - MaxOutcomeEntries); + } } - var celestial = world.Celestials - .Where(candidate => candidate.SystemId == systemId) - .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - return celestial?.Position ?? Vector3.Zero; - } - - private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project) - { - if (project.SiteId is not null - && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site - && world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial) + private static string ResolveStrategicStatus( + IReadOnlyCollection theaters, + IReadOnlyCollection campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) { - return siteCelestial.Position; + if (threatAssessment.ThreatSignals.Any(signal => signal.ScopeKind == "controlled-system")) + { + return "defending"; + } + + if (campaigns.Any(campaign => campaign.Kind == "offense")) + { + return "offensive"; + } + + if (campaigns.Any(campaign => campaign.Kind == "expansion")) + { + return "expanding"; + } + + if (economicAssessment.CommoditySignals.Any(signal => signal.Level is "critical" or "low")) + { + return "stabilizing"; + } + + return theaters.Count == 0 ? "stable" : "active"; } - return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position - ?? ResolveSystemAnchor(world, project.SystemId); - } + private static float ComputeCommodityPriority(FactionCommoditySignalRuntime signal) + { + var levelBias = signal.Level switch + { + "critical" => 60f, + "low" => 35f, + _ => 10f, + }; + return levelBias + + signal.BuyBacklog + + signal.ReservedForConstruction + + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f); + } - private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) - => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); + private static bool CanMineItem(SimulationWorld world, string itemId) => + world.Nodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal)); + + private static string? FindPrimaryCommoditySystem(SimulationWorld world, string factionId, string itemId) => + world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) + .Join( + world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), + bottleneck => bottleneck.RegionId, + region => region.Id, + (bottleneck, region) => new { bottleneck, region }) + .OrderByDescending(entry => entry.bottleneck.Severity) + .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) + .Select(entry => entry.region.CoreSystemId) + .FirstOrDefault() + ?? world.Stations + .Where(station => station.FactionId == factionId && GetInventoryAmount(station.Inventory, itemId) > 0.01f) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Select(station => station.SystemId) + .FirstOrDefault(); + + private static StationRuntime? ResolveCommodityAnchorStation(SimulationWorld world, string factionId, string itemId) => + world.Stations + .Where(station => station.FactionId == factionId) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) + .ThenByDescending(station => station.MarketOrderIds.Count(orderId => + world.MarketOrders.Any(order => order.Id == orderId && order.ItemId == itemId && order.Kind == MarketOrderKinds.Buy))) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static string BuildCampaignSummary(FactionTheaterRuntime theater, IndustryExpansionProject? expansionProject) => + theater.Kind switch + { + "defense-front" => $"Defend {theater.SystemId} from hostile pressure.", + "offense-front" => $"Project force into {theater.SystemId}.", + "expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.", + "economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.", + _ => theater.Kind, + }; + + private static string? ResolveCommodityFromTheaterId(string? theaterId) + { + if (string.IsNullOrWhiteSpace(theaterId)) + { + return null; + } + + const string prefix = "theater-economy-"; + return theaterId.StartsWith(prefix, StringComparison.Ordinal) ? theaterId[prefix.Length..] : null; + } + + private static Vector3 ResolveSystemAnchor(SimulationWorld world, string? systemId) + { + if (systemId is null) + { + return Vector3.Zero; + } + + var station = world.Stations + .Where(candidate => candidate.SystemId == systemId) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (station is not null) + { + return station.Position; + } + + var celestial = world.Celestials + .Where(candidate => candidate.SystemId == systemId) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return celestial?.Position ?? Vector3.Zero; + } + + private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project) + { + if (project.SiteId is not null + && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site + && world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial) + { + return siteCelestial.Position; + } + + return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position + ?? ResolveSystemAnchor(world, project.SystemId); + } + + private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) + => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); } diff --git a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs index fd6a1c6..2a1b548 100644 --- a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs +++ b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs @@ -2,347 +2,347 @@ namespace SpaceGame.Api.Factions.Runtime; public sealed class FactionRuntime { - public required string Id { get; init; } - public required string Label { get; init; } - public required string Color { get; init; } - public float Credits { get; set; } - public float PopulationTotal { get; set; } - public float OreMined { get; set; } - public float GoodsProduced { get; set; } - public int ShipsBuilt { get; set; } - public int ShipsLost { get; set; } - public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); - public string? DefaultPolicySetId { get; set; } - public FactionDoctrineRuntime Doctrine { get; set; } = new(); - public FactionMemoryRuntime Memory { get; set; } = new(); - public FactionStrategicStateRuntime StrategicState { get; set; } = new(); - public List DecisionLog { get; } = []; - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string Label { get; init; } + public required string Color { get; init; } + public float Credits { get; set; } + public float PopulationTotal { get; set; } + public float OreMined { get; set; } + public float GoodsProduced { get; set; } + public int ShipsBuilt { get; set; } + public int ShipsLost { get; set; } + public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); + public string? DefaultPolicySetId { get; set; } + public FactionDoctrineRuntime Doctrine { get; set; } = new(); + public FactionMemoryRuntime Memory { get; set; } = new(); + public FactionStrategicStateRuntime StrategicState { get; set; } = new(); + public List DecisionLog { get; } = []; + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class CommanderRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string FactionId { get; init; } - public string? ParentCommanderId { get; set; } - public string? ControlledEntityId { get; set; } - public string? PolicySetId { get; set; } - public string? Doctrine { get; set; } - public float ReplanTimer { get; set; } - public bool NeedsReplan { get; set; } = true; - public CommanderAssignmentRuntime? Assignment { get; set; } - public CommanderSkillProfileRuntime Skills { get; set; } = new(); - public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); - public HashSet ActiveObjectiveIds { get; } = new(StringComparer.Ordinal); - public bool IsAlive { get; set; } = true; - public int PlanningCycle { get; set; } - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string FactionId { get; init; } + public string? ParentCommanderId { get; set; } + public string? ControlledEntityId { get; set; } + public string? PolicySetId { get; set; } + public string? Doctrine { get; set; } + public float ReplanTimer { get; set; } + public bool NeedsReplan { get; set; } = true; + public CommanderAssignmentRuntime? Assignment { get; set; } + public CommanderSkillProfileRuntime Skills { get; set; } = new(); + public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); + public HashSet ActiveObjectiveIds { get; } = new(StringComparer.Ordinal); + public bool IsAlive { get; set; } = true; + public int PlanningCycle { get; set; } + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class CommanderAssignmentRuntime { - public required string ObjectiveId { get; set; } - public string? CampaignId { get; set; } - public string? TheaterId { get; set; } - public required string Kind { get; set; } - public required string BehaviorKind { get; set; } - public string Status { get; set; } = "active"; - public float Priority { get; set; } - public string? HomeSystemId { get; set; } - public string? HomeStationId { get; set; } - public string? TargetSystemId { get; set; } - public string? TargetEntityId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? ItemId { get; set; } - public string? Notes { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string ObjectiveId { get; set; } + public string? CampaignId { get; set; } + public string? TheaterId { get; set; } + public required string Kind { get; set; } + public required string BehaviorKind { get; set; } + public string Status { get; set; } = "active"; + public float Priority { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetEntityId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? ItemId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class CommanderSkillProfileRuntime { - public int Leadership { get; set; } = 3; - public int Coordination { get; set; } = 3; - public int Strategy { get; set; } = 3; + public int Leadership { get; set; } = 3; + public int Coordination { get; set; } = 3; + public int Strategy { get; set; } = 3; } public sealed class FactionDoctrineRuntime { - public string StrategicPosture { get; set; } = "balanced"; - public string ExpansionPosture { get; set; } = "measured"; - public string MilitaryPosture { get; set; } = "defensive"; - public string EconomicPosture { get; set; } = "self-sufficient"; - public int DesiredControlledSystems { get; set; } = 3; - public int DesiredMilitaryPerFront { get; set; } = 2; - public int DesiredMinersPerSystem { get; set; } = 1; - public int DesiredTransportsPerSystem { get; set; } = 1; - public int DesiredConstructors { get; set; } = 1; - public float ReserveCreditsRatio { get; set; } = 0.2f; - public float ExpansionBudgetRatio { get; set; } = 0.25f; - public float WarBudgetRatio { get; set; } = 0.35f; - public float ReserveMilitaryRatio { get; set; } = 0.2f; - public float OffensiveReadinessThreshold { get; set; } = 0.62f; - public float SupplySecurityBias { get; set; } = 0.55f; - public float FailureAversion { get; set; } = 0.45f; - public int ReinforcementLeadPerFront { get; set; } = 1; + public string StrategicPosture { get; set; } = "balanced"; + public string ExpansionPosture { get; set; } = "measured"; + public string MilitaryPosture { get; set; } = "defensive"; + public string EconomicPosture { get; set; } = "self-sufficient"; + public int DesiredControlledSystems { get; set; } = 3; + public int DesiredMilitaryPerFront { get; set; } = 2; + public int DesiredMinersPerSystem { get; set; } = 1; + public int DesiredTransportsPerSystem { get; set; } = 1; + public int DesiredConstructors { get; set; } = 1; + public float ReserveCreditsRatio { get; set; } = 0.2f; + public float ExpansionBudgetRatio { get; set; } = 0.25f; + public float WarBudgetRatio { get; set; } = 0.35f; + public float ReserveMilitaryRatio { get; set; } = 0.2f; + public float OffensiveReadinessThreshold { get; set; } = 0.62f; + public float SupplySecurityBias { get; set; } = 0.55f; + public float FailureAversion { get; set; } = 0.45f; + public int ReinforcementLeadPerFront { get; set; } = 1; } public sealed class FactionMemoryRuntime { - public int LastPlanCycle { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } - public int LastObservedShipsBuilt { get; set; } - public int LastObservedShipsLost { get; set; } - public float LastObservedCredits { get; set; } - public HashSet KnownSystemIds { get; } = new(StringComparer.Ordinal); - public HashSet KnownEnemyFactionIds { get; } = new(StringComparer.Ordinal); - public List SystemMemories { get; } = []; - public List CommodityMemories { get; } = []; - public List RecentOutcomes { get; } = []; + public int LastPlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int LastObservedShipsBuilt { get; set; } + public int LastObservedShipsLost { get; set; } + public float LastObservedCredits { get; set; } + public HashSet KnownSystemIds { get; } = new(StringComparer.Ordinal); + public HashSet KnownEnemyFactionIds { get; } = new(StringComparer.Ordinal); + public List SystemMemories { get; } = []; + public List CommodityMemories { get; } = []; + public List RecentOutcomes { get; } = []; } public sealed class FactionSystemMemoryRuntime { - public required string SystemId { get; init; } - public DateTimeOffset LastSeenAtUtc { get; set; } - public int LastEnemyShipCount { get; set; } - public int LastEnemyStationCount { get; set; } - public bool ControlledByFaction { get; set; } - public string? LastRole { get; set; } - public float FrontierPressure { get; set; } - public float RouteRisk { get; set; } - public float HistoricalShortagePressure { get; set; } - public int OffensiveFailures { get; set; } - public int DefensiveFailures { get; set; } - public int OffensiveSuccesses { get; set; } - public int DefensiveSuccesses { get; set; } - public DateTimeOffset? LastContestedAtUtc { get; set; } - public DateTimeOffset? LastShortageAtUtc { get; set; } + public required string SystemId { get; init; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public int LastEnemyShipCount { get; set; } + public int LastEnemyStationCount { get; set; } + public bool ControlledByFaction { get; set; } + public string? LastRole { get; set; } + public float FrontierPressure { get; set; } + public float RouteRisk { get; set; } + public float HistoricalShortagePressure { get; set; } + public int OffensiveFailures { get; set; } + public int DefensiveFailures { get; set; } + public int OffensiveSuccesses { get; set; } + public int DefensiveSuccesses { get; set; } + public DateTimeOffset? LastContestedAtUtc { get; set; } + public DateTimeOffset? LastShortageAtUtc { get; set; } } public sealed class FactionCommodityMemoryRuntime { - public required string ItemId { get; init; } - public float HistoricalShortageScore { get; set; } - public float HistoricalSurplusScore { get; set; } - public float LastObservedBacklog { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } - public DateTimeOffset? LastCriticalAtUtc { get; set; } + public required string ItemId { get; init; } + public float HistoricalShortageScore { get; set; } + public float HistoricalSurplusScore { get; set; } + public float LastObservedBacklog { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public DateTimeOffset? LastCriticalAtUtc { get; set; } } public sealed class FactionOutcomeRecordRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string Summary { get; set; } - public string? RelatedCampaignId { get; set; } - public string? RelatedObjectiveId { get; set; } - public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedCampaignId { get; set; } + public string? RelatedObjectiveId { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class FactionStrategicStateRuntime { - public int PlanCycle { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } - public string Status { get; set; } = "stable"; - public FactionBudgetRuntime Budget { get; set; } = new(); - public FactionEconomicAssessmentRuntime EconomicAssessment { get; set; } = new(); - public FactionThreatAssessmentRuntime ThreatAssessment { get; set; } = new(); - public List Theaters { get; } = []; - public List Campaigns { get; } = []; - public List Objectives { get; } = []; - public List Reservations { get; } = []; - public List ProductionPrograms { get; } = []; + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public string Status { get; set; } = "stable"; + public FactionBudgetRuntime Budget { get; set; } = new(); + public FactionEconomicAssessmentRuntime EconomicAssessment { get; set; } = new(); + public FactionThreatAssessmentRuntime ThreatAssessment { get; set; } = new(); + public List Theaters { get; } = []; + public List Campaigns { get; } = []; + public List Objectives { get; } = []; + public List Reservations { get; } = []; + public List ProductionPrograms { get; } = []; } public sealed class FactionBudgetRuntime { - public float ReservedCredits { get; set; } - public float ExpansionCredits { get; set; } - public float WarCredits { get; set; } - public int ReservedMilitaryAssets { get; set; } - public int ReservedLogisticsAssets { get; set; } - public int ReservedConstructionAssets { get; set; } + public float ReservedCredits { get; set; } + public float ExpansionCredits { get; set; } + public float WarCredits { get; set; } + public int ReservedMilitaryAssets { get; set; } + public int ReservedLogisticsAssets { get; set; } + public int ReservedConstructionAssets { get; set; } } public sealed class FactionEconomicAssessmentRuntime { - public int PlanCycle { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } - public int MilitaryShipCount { get; set; } - public int MinerShipCount { get; set; } - public int TransportShipCount { get; set; } - public int ConstructorShipCount { get; set; } - public int ControlledSystemCount { get; set; } - public int TargetMilitaryShipCount { get; set; } - public int TargetMinerShipCount { get; set; } - public int TargetTransportShipCount { get; set; } - public int TargetConstructorShipCount { get; set; } - public bool HasShipyard { get; set; } - public bool HasWarIndustrySupplyChain { get; set; } - public string? PrimaryExpansionSiteId { get; set; } - public string? PrimaryExpansionSystemId { get; set; } - public float ReplacementPressure { get; set; } - public float SustainmentScore { get; set; } - public float LogisticsSecurityScore { get; set; } - public int CriticalShortageCount { get; set; } - public string? IndustrialBottleneckItemId { get; set; } - public List CommoditySignals { get; } = []; + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int MilitaryShipCount { get; set; } + public int MinerShipCount { get; set; } + public int TransportShipCount { get; set; } + public int ConstructorShipCount { get; set; } + public int ControlledSystemCount { get; set; } + public int TargetMilitaryShipCount { get; set; } + public int TargetMinerShipCount { get; set; } + public int TargetTransportShipCount { get; set; } + public int TargetConstructorShipCount { get; set; } + public bool HasShipyard { get; set; } + public bool HasWarIndustrySupplyChain { get; set; } + public string? PrimaryExpansionSiteId { get; set; } + public string? PrimaryExpansionSystemId { get; set; } + public float ReplacementPressure { get; set; } + public float SustainmentScore { get; set; } + public float LogisticsSecurityScore { get; set; } + public int CriticalShortageCount { get; set; } + public string? IndustrialBottleneckItemId { get; set; } + public List CommoditySignals { get; } = []; } public sealed class FactionThreatAssessmentRuntime { - public int PlanCycle { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } - public int EnemyFactionCount { get; set; } - public int EnemyShipCount { get; set; } - public int EnemyStationCount { get; set; } - public string? PrimaryThreatFactionId { get; set; } - public string? PrimaryThreatSystemId { get; set; } - public List ThreatSignals { get; } = []; + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int EnemyFactionCount { get; set; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } + public string? PrimaryThreatFactionId { get; set; } + public string? PrimaryThreatSystemId { get; set; } + public List ThreatSignals { get; } = []; } public sealed class FactionTheaterRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string SystemId { get; set; } - public string Status { get; set; } = "active"; - public float Priority { get; set; } - public float SupplyRisk { get; set; } - public float FriendlyAssetValue { get; set; } - public string? TargetFactionId { get; set; } - public string? AnchorEntityId { get; set; } - public Vector3? AnchorPosition { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List CampaignIds { get; } = []; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string SystemId { get; set; } + public string Status { get; set; } = "active"; + public float Priority { get; set; } + public float SupplyRisk { get; set; } + public float FriendlyAssetValue { get; set; } + public string? TargetFactionId { get; set; } + public string? AnchorEntityId { get; set; } + public Vector3? AnchorPosition { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List CampaignIds { get; } = []; } public sealed class FactionCampaignRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public string Status { get; set; } = "planned"; - public float Priority { get; set; } - public string? TheaterId { get; set; } - public string? TargetFactionId { get; set; } - public string? TargetSystemId { get; set; } - public string? TargetEntityId { get; set; } - public string? CommodityId { get; set; } - public string? SupportStationId { get; set; } - public int CurrentStepIndex { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public string? Summary { get; set; } - public string? PauseReason { get; set; } - public float ContinuationScore { get; set; } - public float SupplyAdequacy { get; set; } - public float ReplacementPressure { get; set; } - public int FailureCount { get; set; } - public int SuccessCount { get; set; } - public string? FleetCommanderId { get; set; } - public bool RequiresReinforcement { get; set; } - public List Steps { get; } = []; - public List ObjectiveIds { get; } = []; + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? TheaterId { get; set; } + public string? TargetFactionId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetEntityId { get; set; } + public string? CommodityId { get; set; } + public string? SupportStationId { get; set; } + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public string? Summary { get; set; } + public string? PauseReason { get; set; } + public float ContinuationScore { get; set; } + public float SupplyAdequacy { get; set; } + public float ReplacementPressure { get; set; } + public int FailureCount { get; set; } + public int SuccessCount { get; set; } + public string? FleetCommanderId { get; set; } + public bool RequiresReinforcement { get; set; } + public List Steps { get; } = []; + public List ObjectiveIds { get; } = []; } public sealed class FactionOperationalObjectiveRuntime { - public required string Id { get; init; } - public required string CampaignId { get; set; } - public string? TheaterId { get; set; } - public required string Kind { get; set; } - public required string DelegationKind { get; set; } - public required string BehaviorKind { get; set; } - public string Status { get; set; } = "planned"; - public float Priority { get; set; } - public string? CommanderId { get; set; } - public string? HomeSystemId { get; set; } - public string? HomeStationId { get; set; } - public string? TargetSystemId { get; set; } - public string? TargetEntityId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? ItemId { get; set; } - public string? Notes { get; set; } - public int CurrentStepIndex { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public bool UseOrders { get; set; } - public string? StagingOrderKind { get; set; } - public int ReinforcementLevel { get; set; } - public List Steps { get; } = []; - public List ReservedAssetIds { get; } = []; + public required string Id { get; init; } + public required string CampaignId { get; set; } + public string? TheaterId { get; set; } + public required string Kind { get; set; } + public required string DelegationKind { get; set; } + public required string BehaviorKind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? CommanderId { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetEntityId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? ItemId { get; set; } + public string? Notes { get; set; } + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public int ReinforcementLevel { get; set; } + public List Steps { get; } = []; + public List ReservedAssetIds { get; } = []; } public sealed class FactionPlanStepRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public string Status { get; set; } = "planned"; - public string? Summary { get; set; } - public string? BlockingReason { get; set; } + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public string? Summary { get; set; } + public string? BlockingReason { get; set; } } public sealed class FactionAssetReservationRuntime { - public required string Id { get; init; } - public required string ObjectiveId { get; set; } - public string? CampaignId { get; set; } - public required string AssetKind { get; set; } - public required string AssetId { get; set; } - public float Priority { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string ObjectiveId { get; set; } + public string? CampaignId { get; set; } + public required string AssetKind { get; set; } + public required string AssetId { get; set; } + public float Priority { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class FactionProductionProgramRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public string Status { get; set; } = "planned"; - public float Priority { get; set; } - public string? CampaignId { get; set; } - public string? CommodityId { get; set; } - public string? ModuleId { get; set; } - public string? ShipKind { get; set; } - public string? TargetSystemId { get; set; } - public int TargetCount { get; set; } - public int CurrentCount { get; set; } - public string? Notes { get; set; } + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? CampaignId { get; set; } + public string? CommodityId { get; set; } + public string? ModuleId { get; set; } + public string? ShipKind { get; set; } + public string? TargetSystemId { get; set; } + public int TargetCount { get; set; } + public int CurrentCount { get; set; } + public string? Notes { get; set; } } public sealed class FactionDecisionLogEntryRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string Summary { get; set; } - public string? RelatedEntityId { get; set; } - public int PlanCycle { get; set; } - public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedEntityId { get; set; } + public int PlanCycle { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class FactionCommoditySignalRuntime { - public required string ItemId { get; init; } - public float AvailableStock { get; set; } - public float OnHand { get; set; } - public float ProductionRatePerSecond { get; set; } - public float CommittedProductionRatePerSecond { get; set; } - public float UsageRatePerSecond { get; set; } - public float NetRatePerSecond { get; set; } - public float ProjectedNetRatePerSecond { get; set; } - public float LevelSeconds { get; set; } - public string Level { get; set; } = "unknown"; - public float ProjectedProductionRatePerSecond { get; set; } - public float BuyBacklog { get; set; } - public float ReservedForConstruction { get; set; } + public required string ItemId { get; init; } + public float AvailableStock { get; set; } + public float OnHand { get; set; } + public float ProductionRatePerSecond { get; set; } + public float CommittedProductionRatePerSecond { get; set; } + public float UsageRatePerSecond { get; set; } + public float NetRatePerSecond { get; set; } + public float ProjectedNetRatePerSecond { get; set; } + public float LevelSeconds { get; set; } + public string Level { get; set; } = "unknown"; + public float ProjectedProductionRatePerSecond { get; set; } + public float BuyBacklog { get; set; } + public float ReservedForConstruction { get; set; } } public sealed class FactionThreatSignalRuntime { - public required string ScopeId { get; init; } - public required string ScopeKind { get; init; } - public int EnemyShipCount { get; set; } - public int EnemyStationCount { get; set; } - public string? EnemyFactionId { get; set; } + public required string ScopeId { get; init; } + public required string ScopeKind { get; init; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } + public string? EnemyFactionId { get; set; } } diff --git a/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs index 3e3dba2..c914ab4 100644 --- a/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs +++ b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs @@ -2,335 +2,335 @@ namespace SpaceGame.Api.Geopolitics.Runtime; public sealed class GeopoliticalStateRuntime { - public int Cycle { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List Routes { get; } = []; - public DiplomaticStateRuntime Diplomacy { get; set; } = new(); - public TerritoryStateRuntime Territory { get; set; } = new(); - public EconomyRegionStateRuntime EconomyRegions { get; set; } = new(); - public string LastDeltaSignature { get; set; } = string.Empty; + public int Cycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List Routes { get; } = []; + public DiplomaticStateRuntime Diplomacy { get; set; } = new(); + public TerritoryStateRuntime Territory { get; set; } = new(); + public EconomyRegionStateRuntime EconomyRegions { get; set; } = new(); + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class SystemRouteLinkRuntime { - public required string Id { get; init; } - public required string SourceSystemId { get; set; } - public required string DestinationSystemId { get; set; } - public float Distance { get; set; } - public bool IsPrimaryLane { get; set; } = true; + public required string Id { get; init; } + public required string SourceSystemId { get; set; } + public required string DestinationSystemId { get; set; } + public float Distance { get; set; } + public bool IsPrimaryLane { get; set; } = true; } public sealed class DiplomaticStateRuntime { - public List Relations { get; } = []; - public List Treaties { get; } = []; - public List Incidents { get; } = []; - public List BorderTensions { get; } = []; - public List Wars { get; } = []; + public List Relations { get; } = []; + public List Treaties { get; } = []; + public List Incidents { get; } = []; + public List BorderTensions { get; } = []; + public List Wars { get; } = []; } public sealed class DiplomaticRelationRuntime { - public required string Id { get; init; } - public required string FactionAId { get; set; } - public required string FactionBId { get; set; } - public string Status { get; set; } = "active"; - public string Posture { get; set; } = "neutral"; - public float TrustScore { get; set; } - public float TensionScore { get; set; } - public float GrievanceScore { get; set; } - public string TradeAccessPolicy { get; set; } = "restricted"; - public string MilitaryAccessPolicy { get; set; } = "restricted"; - public string? WarStateId { get; set; } - public DateTimeOffset? CeasefireUntilUtc { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List ActiveTreatyIds { get; } = []; - public List ActiveIncidentIds { get; } = []; + public required string Id { get; init; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public string Posture { get; set; } = "neutral"; + public float TrustScore { get; set; } + public float TensionScore { get; set; } + public float GrievanceScore { get; set; } + public string TradeAccessPolicy { get; set; } = "restricted"; + public string MilitaryAccessPolicy { get; set; } = "restricted"; + public string? WarStateId { get; set; } + public DateTimeOffset? CeasefireUntilUtc { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ActiveTreatyIds { get; } = []; + public List ActiveIncidentIds { get; } = []; } public sealed class TreatyRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public string Status { get; set; } = "active"; - public string TradeAccessPolicy { get; set; } = "restricted"; - public string MilitaryAccessPolicy { get; set; } = "restricted"; - public string? Summary { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List FactionIds { get; } = []; + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "active"; + public string TradeAccessPolicy { get; set; } = "restricted"; + public string MilitaryAccessPolicy { get; set; } = "restricted"; + public string? Summary { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List FactionIds { get; } = []; } public sealed class DiplomaticIncidentRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public string Status { get; set; } = "active"; - public required string SourceFactionId { get; set; } - public required string TargetFactionId { get; set; } - public string? SystemId { get; set; } - public string? BorderEdgeId { get; set; } - public required string Summary { get; set; } - public float Severity { get; set; } - public float EscalationScore { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset LastObservedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "active"; + public required string SourceFactionId { get; set; } + public required string TargetFactionId { get; set; } + public string? SystemId { get; set; } + public string? BorderEdgeId { get; set; } + public required string Summary { get; set; } + public float Severity { get; set; } + public float EscalationScore { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastObservedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class BorderTensionRuntime { - public required string Id { get; init; } - public required string RelationId { get; set; } - public required string BorderEdgeId { get; set; } - public required string FactionAId { get; set; } - public required string FactionBId { get; set; } - public string Status { get; set; } = "active"; - public float TensionScore { get; set; } - public float IncidentScore { get; set; } - public float MilitaryPressure { get; set; } - public float AccessFriction { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List SystemIds { get; } = []; + public required string Id { get; init; } + public required string RelationId { get; set; } + public required string BorderEdgeId { get; set; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public float TensionScore { get; set; } + public float IncidentScore { get; set; } + public float MilitaryPressure { get; set; } + public float AccessFriction { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemIds { get; } = []; } public sealed class WarStateRuntime { - public required string Id { get; init; } - public required string RelationId { get; set; } - public required string FactionAId { get; set; } - public required string FactionBId { get; set; } - public string Status { get; set; } = "active"; - public string WarGoal { get; set; } = "territorial-pressure"; - public float EscalationScore { get; set; } - public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset? CeasefireUntilUtc { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List ActiveFrontLineIds { get; } = []; + public required string Id { get; init; } + public required string RelationId { get; set; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public string WarGoal { get; set; } = "territorial-pressure"; + public float EscalationScore { get; set; } + public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CeasefireUntilUtc { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ActiveFrontLineIds { get; } = []; } public sealed class TerritoryStateRuntime { - public List Claims { get; } = []; - public List Influences { get; } = []; - public List ControlStates { get; } = []; - public List StrategicProfiles { get; } = []; - public List BorderEdges { get; } = []; - public List FrontLines { get; } = []; - public List Zones { get; } = []; - public List Pressures { get; } = []; + public List Claims { get; } = []; + public List Influences { get; } = []; + public List ControlStates { get; } = []; + public List StrategicProfiles { get; } = []; + public List BorderEdges { get; } = []; + public List FrontLines { get; } = []; + public List Zones { get; } = []; + public List Pressures { get; } = []; } public sealed class TerritoryClaimRuntime { - public required string Id { get; init; } - public string? SourceClaimId { get; set; } - public required string FactionId { get; set; } - public required string SystemId { get; set; } - public required string CelestialId { get; set; } - public string Status { get; set; } = "active"; - public string ClaimKind { get; set; } = "infrastructure"; - public float ClaimStrength { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public string? SourceClaimId { get; set; } + public required string FactionId { get; set; } + public required string SystemId { get; set; } + public required string CelestialId { get; set; } + public string Status { get; set; } = "active"; + public string ClaimKind { get; set; } = "infrastructure"; + public float ClaimStrength { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class TerritoryInfluenceRuntime { - public required string Id { get; init; } - public required string SystemId { get; set; } - public required string FactionId { get; set; } - public float ClaimStrength { get; set; } - public float AssetStrength { get; set; } - public float LogisticsStrength { get; set; } - public float TotalInfluence { get; set; } - public bool IsContesting { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string SystemId { get; set; } + public required string FactionId { get; set; } + public float ClaimStrength { get; set; } + public float AssetStrength { get; set; } + public float LogisticsStrength { get; set; } + public float TotalInfluence { get; set; } + public bool IsContesting { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class TerritoryControlStateRuntime { - public required string SystemId { get; init; } - public string? ControllerFactionId { get; set; } - public string? PrimaryClaimantFactionId { get; set; } - public string ControlKind { get; set; } = "unclaimed"; - public bool IsContested { get; set; } - public float ControlScore { get; set; } - public float StrategicValue { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List ClaimantFactionIds { get; } = []; - public List InfluencingFactionIds { get; } = []; + public required string SystemId { get; init; } + public string? ControllerFactionId { get; set; } + public string? PrimaryClaimantFactionId { get; set; } + public string ControlKind { get; set; } = "unclaimed"; + public bool IsContested { get; set; } + public float ControlScore { get; set; } + public float StrategicValue { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ClaimantFactionIds { get; } = []; + public List InfluencingFactionIds { get; } = []; } public sealed class SectorStrategicProfileRuntime { - public required string SystemId { get; init; } - public string? ControllerFactionId { get; set; } - public string ZoneKind { get; set; } = "unclaimed"; - public bool IsContested { get; set; } - public float StrategicValue { get; set; } - public float SecurityRating { get; set; } - public float TerritorialPressure { get; set; } - public float LogisticsValue { get; set; } - public string? EconomicRegionId { get; set; } - public string? FrontLineId { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string SystemId { get; init; } + public string? ControllerFactionId { get; set; } + public string ZoneKind { get; set; } = "unclaimed"; + public bool IsContested { get; set; } + public float StrategicValue { get; set; } + public float SecurityRating { get; set; } + public float TerritorialPressure { get; set; } + public float LogisticsValue { get; set; } + public string? EconomicRegionId { get; set; } + public string? FrontLineId { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class BorderEdgeRuntime { - public required string Id { get; init; } - public required string SourceSystemId { get; set; } - public required string DestinationSystemId { get; set; } - public string? SourceFactionId { get; set; } - public string? DestinationFactionId { get; set; } - public bool IsContested { get; set; } - public string? RelationId { get; set; } - public float TensionScore { get; set; } - public float CorridorImportance { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string SourceSystemId { get; set; } + public required string DestinationSystemId { get; set; } + public string? SourceFactionId { get; set; } + public string? DestinationFactionId { get; set; } + public bool IsContested { get; set; } + public string? RelationId { get; set; } + public float TensionScore { get; set; } + public float CorridorImportance { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class FrontLineRuntime { - public required string Id { get; init; } - public string Kind { get; set; } = "border-front"; - public string Status { get; set; } = "active"; - public string? AnchorSystemId { get; set; } - public float PressureScore { get; set; } - public float SupplyRisk { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List FactionIds { get; } = []; - public List SystemIds { get; } = []; - public List BorderEdgeIds { get; } = []; + public required string Id { get; init; } + public string Kind { get; set; } = "border-front"; + public string Status { get; set; } = "active"; + public string? AnchorSystemId { get; set; } + public float PressureScore { get; set; } + public float SupplyRisk { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List FactionIds { get; } = []; + public List SystemIds { get; } = []; + public List BorderEdgeIds { get; } = []; } public sealed class TerritoryZoneRuntime { - public required string Id { get; init; } - public required string SystemId { get; set; } - public string? FactionId { get; set; } - public string Kind { get; set; } = "unclaimed"; - public string Status { get; set; } = "active"; - public string? Reason { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string SystemId { get; set; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "unclaimed"; + public string Status { get; set; } = "active"; + public string? Reason { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class TerritoryPressureRuntime { - public required string Id { get; init; } - public required string SystemId { get; set; } - public string? FactionId { get; set; } - public string Kind { get; set; } = "border-pressure"; - public float PressureScore { get; set; } - public float SecurityScore { get; set; } - public float HostileInfluence { get; set; } - public float CorridorRisk { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string SystemId { get; set; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "border-pressure"; + public float PressureScore { get; set; } + public float SecurityScore { get; set; } + public float HostileInfluence { get; set; } + public float CorridorRisk { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class EconomyRegionStateRuntime { - public List Regions { get; } = []; - public List SupplyNetworks { get; } = []; - public List Corridors { get; } = []; - public List ProductionProfiles { get; } = []; - public List TradeBalances { get; } = []; - public List Bottlenecks { get; } = []; - public List SecurityAssessments { get; } = []; - public List EconomicAssessments { get; } = []; + public List Regions { get; } = []; + public List SupplyNetworks { get; } = []; + public List Corridors { get; } = []; + public List ProductionProfiles { get; } = []; + public List TradeBalances { get; } = []; + public List Bottlenecks { get; } = []; + public List SecurityAssessments { get; } = []; + public List EconomicAssessments { get; } = []; } public sealed class EconomicRegionRuntime { - public required string Id { get; init; } - public string? FactionId { get; set; } - public required string Label { get; set; } - public string Kind { get; set; } = "balanced-region"; - public string Status { get; set; } = "active"; - public required string CoreSystemId { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List SystemIds { get; } = []; - public List StationIds { get; } = []; - public List FrontLineIds { get; } = []; - public List CorridorIds { get; } = []; + public required string Id { get; init; } + public string? FactionId { get; set; } + public required string Label { get; set; } + public string Kind { get; set; } = "balanced-region"; + public string Status { get; set; } = "active"; + public required string CoreSystemId { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemIds { get; } = []; + public List StationIds { get; } = []; + public List FrontLineIds { get; } = []; + public List CorridorIds { get; } = []; } public sealed class SupplyNetworkRuntime { - public required string Id { get; init; } - public required string RegionId { get; set; } - public float ThroughputScore { get; set; } - public float RiskScore { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List StationIds { get; } = []; - public List ProducerItemIds { get; } = []; - public List ConsumerItemIds { get; } = []; - public List ConstructionItemIds { get; } = []; + public required string Id { get; init; } + public required string RegionId { get; set; } + public float ThroughputScore { get; set; } + public float RiskScore { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List StationIds { get; } = []; + public List ProducerItemIds { get; } = []; + public List ConsumerItemIds { get; } = []; + public List ConstructionItemIds { get; } = []; } public sealed class LogisticsCorridorRuntime { - public required string Id { get; init; } - public string? FactionId { get; set; } - public string Kind { get; set; } = "supply-corridor"; - public string Status { get; set; } = "active"; - public float RiskScore { get; set; } - public float ThroughputScore { get; set; } - public string AccessState { get; set; } = "restricted"; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List SystemPathIds { get; } = []; - public List RegionIds { get; } = []; - public List BorderEdgeIds { get; } = []; + public required string Id { get; init; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "supply-corridor"; + public string Status { get; set; } = "active"; + public float RiskScore { get; set; } + public float ThroughputScore { get; set; } + public string AccessState { get; set; } = "restricted"; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemPathIds { get; } = []; + public List RegionIds { get; } = []; + public List BorderEdgeIds { get; } = []; } public sealed class RegionalProductionProfileRuntime { - public required string RegionId { get; set; } - public string PrimaryIndustry { get; set; } = "mixed"; - public int ShipyardCount { get; set; } - public int StationCount { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public List ProducedItemIds { get; } = []; - public List ScarceItemIds { get; } = []; + public required string RegionId { get; set; } + public string PrimaryIndustry { get; set; } = "mixed"; + public int ShipyardCount { get; set; } + public int StationCount { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ProducedItemIds { get; } = []; + public List ScarceItemIds { get; } = []; } public sealed class RegionalTradeBalanceRuntime { - public required string RegionId { get; set; } - public int ImportsRequiredCount { get; set; } - public int ExportsSurplusCount { get; set; } - public int CriticalShortageCount { get; set; } - public float NetTradeScore { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string RegionId { get; set; } + public int ImportsRequiredCount { get; set; } + public int ExportsSurplusCount { get; set; } + public int CriticalShortageCount { get; set; } + public float NetTradeScore { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class RegionalBottleneckRuntime { - public required string Id { get; init; } - public required string RegionId { get; set; } - public required string ItemId { get; set; } - public string Cause { get; set; } = "regional-shortage"; - public string Status { get; set; } = "active"; - public float Severity { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string RegionId { get; set; } + public required string ItemId { get; set; } + public string Cause { get; set; } = "regional-shortage"; + public string Status { get; set; } = "active"; + public float Severity { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class RegionalSecurityAssessmentRuntime { - public required string RegionId { get; set; } - public float SupplyRisk { get; set; } - public float BorderPressure { get; set; } - public int ActiveWarCount { get; set; } - public int HostileRelationCount { get; set; } - public float AccessFriction { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string RegionId { get; set; } + public float SupplyRisk { get; set; } + public float BorderPressure { get; set; } + public int ActiveWarCount { get; set; } + public int HostileRelationCount { get; set; } + public float AccessFriction { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class RegionalEconomicAssessmentRuntime { - public required string RegionId { get; set; } - public float SustainmentScore { get; set; } - public float ProductionDepth { get; set; } - public float ConstructionPressure { get; set; } - public float CorridorDependency { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string RegionId { get; set; } + public float SustainmentScore { get; set; } + public float ProductionDepth { get; set; } + public float ConstructionPressure { get; set; } + public float CorridorDependency { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } diff --git a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs index af650f2..de0b3f7 100644 --- a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs +++ b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs @@ -4,920 +4,920 @@ namespace SpaceGame.Api.Geopolitics.Simulation; internal sealed class GeopoliticalSimulationService { - internal void Update(SimulationWorld world, float deltaSeconds, ICollection events) - { - var state = EnsureState(world); - state.Cycle += 1; - state.UpdatedAtUtc = world.GeneratedAtUtc; - - RebuildRoutes(world, state); - RebuildTerritory(world, state); - RebuildDiplomacy(world, state, events); - RebuildEconomyRegions(world, state); - } - - internal static GeopoliticalStateRuntime EnsureState(SimulationWorld world) - { - world.Geopolitics ??= new GeopoliticalStateRuntime(); - return world.Geopolitics; - } - - internal static DiplomaticRelationRuntime? FindRelation(SimulationWorld world, string factionAId, string factionBId) - { - var state = EnsureState(world); - return state.Diplomacy.Relations.FirstOrDefault(relation => string.Equals(relation.Id, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal)); - } - - internal static WarStateRuntime? FindWarState(SimulationWorld world, string factionAId, string factionBId) => - EnsureState(world).Diplomacy.Wars.FirstOrDefault(war => string.Equals(war.RelationId, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal) && war.Status == "active"); - - internal static TerritoryControlStateRuntime? GetSystemControlState(SimulationWorld world, string systemId) => - EnsureState(world).Territory.ControlStates.FirstOrDefault(state => string.Equals(state.SystemId, systemId, StringComparison.Ordinal)); - - internal static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) => - string.Equals(GetSystemControlState(world, systemId)?.ControllerFactionId, factionId, StringComparison.Ordinal); - - internal static IReadOnlyList GetControlledSystems(SimulationWorld world, string factionId) => - EnsureState(world).Territory.ControlStates - .Where(state => string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal)) - .OrderBy(state => state.SystemId, StringComparer.Ordinal) - .Select(state => state.SystemId) - .ToList(); - - internal static float GetSystemRouteRisk(SimulationWorld world, string systemId, string? factionId = null) - { - var pressure = EnsureState(world).Territory.Pressures - .Where(entry => string.Equals(entry.SystemId, systemId, StringComparison.Ordinal) - && (factionId is null || string.Equals(entry.FactionId, factionId, StringComparison.Ordinal))) - .OrderByDescending(entry => entry.CorridorRisk) - .ThenBy(entry => entry.Id, StringComparer.Ordinal) - .FirstOrDefault(); - return pressure?.CorridorRisk - ?? EnsureState(world).Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == systemId)?.TerritorialPressure - ?? 0f; - } - - internal static bool HasHostileRelation(SimulationWorld world, string factionAId, string factionBId) - { - if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + internal void Update(SimulationWorld world, float deltaSeconds, ICollection events) { - return false; + var state = EnsureState(world); + state.Cycle += 1; + state.UpdatedAtUtc = world.GeneratedAtUtc; + + RebuildRoutes(world, state); + RebuildTerritory(world, state); + RebuildDiplomacy(world, state, events); + RebuildEconomyRegions(world, state); } - var relation = FindRelation(world, factionAId, factionBId); - return relation is not null && relation.Posture is "hostile" or "war"; - } - - internal static bool HasTradeAccess(SimulationWorld world, string factionAId, string factionBId) - { - if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + internal static GeopoliticalStateRuntime EnsureState(SimulationWorld world) { - return true; + world.Geopolitics ??= new GeopoliticalStateRuntime(); + return world.Geopolitics; } - var relation = FindRelation(world, factionAId, factionBId); - return relation?.TradeAccessPolicy is "open" or "allied"; - } - - internal static bool HasMilitaryAccess(SimulationWorld world, string factionAId, string factionBId) - { - if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + internal static DiplomaticRelationRuntime? FindRelation(SimulationWorld world, string factionAId, string factionBId) { - return true; + var state = EnsureState(world); + return state.Diplomacy.Relations.FirstOrDefault(relation => string.Equals(relation.Id, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal)); } - var relation = FindRelation(world, factionAId, factionBId); - return relation?.MilitaryAccessPolicy is "open" or "allied"; - } + internal static WarStateRuntime? FindWarState(SimulationWorld world, string factionAId, string factionBId) => + EnsureState(world).Diplomacy.Wars.FirstOrDefault(war => string.Equals(war.RelationId, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal) && war.Status == "active"); - internal static EconomicRegionRuntime? GetPrimaryEconomicRegion(SimulationWorld world, string factionId, string systemId) => - EnsureState(world).EconomyRegions.Regions.FirstOrDefault(region => - string.Equals(region.FactionId, factionId, StringComparison.Ordinal) - && region.SystemIds.Contains(systemId, StringComparer.Ordinal)); + internal static TerritoryControlStateRuntime? GetSystemControlState(SimulationWorld world, string systemId) => + EnsureState(world).Territory.ControlStates.FirstOrDefault(state => string.Equals(state.SystemId, systemId, StringComparison.Ordinal)); - private static void RebuildRoutes(SimulationWorld world, GeopoliticalStateRuntime state) - { - state.Routes.Clear(); - if (world.Systems.Count <= 1) - { - return; - } + internal static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) => + string.Equals(GetSystemControlState(world, systemId)?.ControllerFactionId, factionId, StringComparison.Ordinal); - var systems = world.Systems - .OrderBy(system => system.Definition.Id, StringComparer.Ordinal) - .ToList(); - var routeIds = new HashSet(StringComparer.Ordinal); - - foreach (var system in systems) - { - foreach (var neighbor in systems - .Where(candidate => candidate.Definition.Id != system.Definition.Id) - .Select(candidate => new - { - candidate.Definition.Id, - Distance = system.Position.DistanceTo(candidate.Position), - }) - .OrderBy(candidate => candidate.Distance) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .Take(Math.Min(3, systems.Count - 1))) - { - var routeId = BuildPairId("route", system.Definition.Id, neighbor.Id); - if (!routeIds.Add(routeId)) - { - continue; - } - - state.Routes.Add(new SystemRouteLinkRuntime - { - Id = routeId, - SourceSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? system.Definition.Id : neighbor.Id, - DestinationSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? neighbor.Id : system.Definition.Id, - Distance = neighbor.Distance, - IsPrimaryLane = true, - }); - } - } - } - - private static void RebuildTerritory(SimulationWorld world, GeopoliticalStateRuntime state) - { - state.Territory.Claims.Clear(); - state.Territory.Influences.Clear(); - state.Territory.ControlStates.Clear(); - state.Territory.StrategicProfiles.Clear(); - state.Territory.BorderEdges.Clear(); - state.Territory.FrontLines.Clear(); - state.Territory.Zones.Clear(); - state.Territory.Pressures.Clear(); - - var nowUtc = world.GeneratedAtUtc; - foreach (var claim in world.Claims.Where(claim => claim.State != ClaimStateKinds.Destroyed)) - { - state.Territory.Claims.Add(new TerritoryClaimRuntime - { - Id = $"territory-{claim.Id}", - SourceClaimId = claim.Id, - FactionId = claim.FactionId, - SystemId = claim.SystemId, - CelestialId = claim.CelestialId, - Status = claim.State, - ClaimKind = "infrastructure", - ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f, - UpdatedAtUtc = nowUtc, - }); - } - - var influencesBySystem = new Dictionary>(StringComparer.Ordinal); - foreach (var system in world.Systems) - { - var claimsByFaction = state.Territory.Claims - .Where(claim => claim.SystemId == system.Definition.Id) - .GroupBy(claim => claim.FactionId, StringComparer.Ordinal); - var stationsByFaction = world.Stations - .Where(station => station.SystemId == system.Definition.Id) - .GroupBy(station => station.FactionId, StringComparer.Ordinal); - var shipsByFaction = world.Ships - .Where(ship => ship.SystemId == system.Definition.Id && ship.Health > 0f) - .GroupBy(ship => ship.FactionId, StringComparer.Ordinal); - var sitesByFaction = world.ConstructionSites - .Where(site => site.SystemId == system.Definition.Id && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) - .GroupBy(site => site.FactionId, StringComparer.Ordinal); - - var factionIds = claimsByFaction.Select(group => group.Key) - .Concat(stationsByFaction.Select(group => group.Key)) - .Concat(shipsByFaction.Select(group => group.Key)) - .Concat(sitesByFaction.Select(group => group.Key)) - .Distinct(StringComparer.Ordinal) - .OrderBy(id => id, StringComparer.Ordinal) + internal static IReadOnlyList GetControlledSystems(SimulationWorld world, string factionId) => + EnsureState(world).Territory.ControlStates + .Where(state => string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal)) + .OrderBy(state => state.SystemId, StringComparer.Ordinal) + .Select(state => state.SystemId) .ToList(); - var influences = new List(); - foreach (var factionId in factionIds) - { - var claimStrength = claimsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(claim => claim.ClaimStrength * 40f) ?? 0f; - var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f; - var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f; - var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship => - ship.Definition.Kind switch + internal static float GetSystemRouteRisk(SimulationWorld world, string systemId, string? factionId = null) + { + var pressure = EnsureState(world).Territory.Pressures + .Where(entry => string.Equals(entry.SystemId, systemId, StringComparison.Ordinal) + && (factionId is null || string.Equals(entry.FactionId, factionId, StringComparison.Ordinal))) + .OrderByDescending(entry => entry.CorridorRisk) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return pressure?.CorridorRisk + ?? EnsureState(world).Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == systemId)?.TerritorialPressure + ?? 0f; + } + + internal static bool HasHostileRelation(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return false; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation is not null && relation.Posture is "hostile" or "war"; + } + + internal static bool HasTradeAccess(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return true; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation?.TradeAccessPolicy is "open" or "allied"; + } + + internal static bool HasMilitaryAccess(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return true; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation?.MilitaryAccessPolicy is "open" or "allied"; + } + + internal static EconomicRegionRuntime? GetPrimaryEconomicRegion(SimulationWorld world, string factionId, string systemId) => + EnsureState(world).EconomyRegions.Regions.FirstOrDefault(region => + string.Equals(region.FactionId, factionId, StringComparison.Ordinal) + && region.SystemIds.Contains(systemId, StringComparer.Ordinal)); + + private static void RebuildRoutes(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.Routes.Clear(); + if (world.Systems.Count <= 1) + { + return; + } + + var systems = world.Systems + .OrderBy(system => system.Definition.Id, StringComparer.Ordinal) + .ToList(); + var routeIds = new HashSet(StringComparer.Ordinal); + + foreach (var system in systems) + { + foreach (var neighbor in systems + .Where(candidate => candidate.Definition.Id != system.Definition.Id) + .Select(candidate => new + { + candidate.Definition.Id, + Distance = system.Position.DistanceTo(candidate.Position), + }) + .OrderBy(candidate => candidate.Distance) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .Take(Math.Min(3, systems.Count - 1))) + { + var routeId = BuildPairId("route", system.Definition.Id, neighbor.Id); + if (!routeIds.Add(routeId)) + { + continue; + } + + state.Routes.Add(new SystemRouteLinkRuntime + { + Id = routeId, + SourceSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? system.Definition.Id : neighbor.Id, + DestinationSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? neighbor.Id : system.Definition.Id, + Distance = neighbor.Distance, + IsPrimaryLane = true, + }); + } + } + } + + private static void RebuildTerritory(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.Territory.Claims.Clear(); + state.Territory.Influences.Clear(); + state.Territory.ControlStates.Clear(); + state.Territory.StrategicProfiles.Clear(); + state.Territory.BorderEdges.Clear(); + state.Territory.FrontLines.Clear(); + state.Territory.Zones.Clear(); + state.Territory.Pressures.Clear(); + + var nowUtc = world.GeneratedAtUtc; + foreach (var claim in world.Claims.Where(claim => claim.State != ClaimStateKinds.Destroyed)) + { + state.Territory.Claims.Add(new TerritoryClaimRuntime + { + Id = $"territory-{claim.Id}", + SourceClaimId = claim.Id, + FactionId = claim.FactionId, + SystemId = claim.SystemId, + CelestialId = claim.CelestialId, + Status = claim.State, + ClaimKind = "infrastructure", + ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f, + UpdatedAtUtc = nowUtc, + }); + } + + var influencesBySystem = new Dictionary>(StringComparer.Ordinal); + foreach (var system in world.Systems) + { + var claimsByFaction = state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .GroupBy(claim => claim.FactionId, StringComparer.Ordinal); + var stationsByFaction = world.Stations + .Where(station => station.SystemId == system.Definition.Id) + .GroupBy(station => station.FactionId, StringComparer.Ordinal); + var shipsByFaction = world.Ships + .Where(ship => ship.SystemId == system.Definition.Id && ship.Health > 0f) + .GroupBy(ship => ship.FactionId, StringComparer.Ordinal); + var sitesByFaction = world.ConstructionSites + .Where(site => site.SystemId == system.Definition.Id && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) + .GroupBy(site => site.FactionId, StringComparer.Ordinal); + + var factionIds = claimsByFaction.Select(group => group.Key) + .Concat(stationsByFaction.Select(group => group.Key)) + .Concat(shipsByFaction.Select(group => group.Key)) + .Concat(sitesByFaction.Select(group => group.Key)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + var influences = new List(); + foreach (var factionId in factionIds) + { + var claimStrength = claimsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(claim => claim.ClaimStrength * 40f) ?? 0f; + var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f; + var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f; + var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship => + ship.Definition.Kind switch + { + "military" => 9f, + "construction" => 4f, + "transport" => 3f, + _ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f, + _ => 2f, + }) ?? 0f; + var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength; + influences.Add(new TerritoryInfluenceRuntime + { + Id = $"influence-{system.Definition.Id}-{factionId}", + SystemId = system.Definition.Id, + FactionId = factionId, + ClaimStrength = claimStrength, + AssetStrength = stationStrength + shipStrength, + LogisticsStrength = logisticsStrength, + TotalInfluence = claimStrength + stationStrength + shipStrength + logisticsStrength, + UpdatedAtUtc = nowUtc, + }); + } + + influences.Sort((left, right) => + { + var total = right.TotalInfluence.CompareTo(left.TotalInfluence); + return total != 0 ? total : string.Compare(left.FactionId, right.FactionId, StringComparison.Ordinal); + }); + if (influences.Count > 1) + { + var lead = influences[0].TotalInfluence; + foreach (var influence in influences.Skip(1)) + { + influence.IsContesting = influence.TotalInfluence >= (lead * 0.7f); + } + + influences[0].IsContesting = influences[1].TotalInfluence >= (lead * 0.7f); + } + + influencesBySystem[system.Definition.Id] = influences; + state.Territory.Influences.AddRange(influences); + + var top = influences.FirstOrDefault(); + var second = influences.Skip(1).FirstOrDefault(); + var contested = top is not null && second is not null && second.TotalInfluence >= (top.TotalInfluence * 0.7f); + var controllerFactionId = top is not null && (!contested || top.TotalInfluence >= second!.TotalInfluence + 20f) + ? top.FactionId + : null; + var primaryClaimantFactionId = state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .GroupBy(claim => claim.FactionId, StringComparer.Ordinal) + .OrderByDescending(group => group.Sum(claim => claim.ClaimStrength)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.Key) + .FirstOrDefault(); + + var strategicValue = EstimateSystemStrategicValue(world, system.Definition.Id); + var controlState = new TerritoryControlStateRuntime + { + SystemId = system.Definition.Id, + ControllerFactionId = controllerFactionId, + PrimaryClaimantFactionId = primaryClaimantFactionId, + ControlKind = contested + ? "contested" + : controllerFactionId is not null + ? "controlled" + : primaryClaimantFactionId is not null + ? "claimed" + : "unclaimed", + IsContested = contested, + ControlScore = top?.TotalInfluence ?? 0f, + StrategicValue = strategicValue, + UpdatedAtUtc = nowUtc, + }; + controlState.ClaimantFactionIds.AddRange(state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .Select(claim => claim.FactionId) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal)); + controlState.InfluencingFactionIds.AddRange(influences + .Select(influence => influence.FactionId) + .OrderBy(id => id, StringComparer.Ordinal)); + state.Territory.ControlStates.Add(controlState); + } + + foreach (var route in state.Routes) + { + var left = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.SourceSystemId); + var right = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.DestinationSystemId); + var differentControllers = !string.Equals(left.ControllerFactionId, right.ControllerFactionId, StringComparison.Ordinal); + var contested = left.IsContested || right.IsContested || differentControllers; + if (!contested && left.ControllerFactionId is null && right.ControllerFactionId is null) + { + continue; + } + + state.Territory.BorderEdges.Add(new BorderEdgeRuntime + { + Id = $"border-{route.Id}", + SourceSystemId = route.SourceSystemId, + DestinationSystemId = route.DestinationSystemId, + SourceFactionId = left.ControllerFactionId ?? left.PrimaryClaimantFactionId, + DestinationFactionId = right.ControllerFactionId ?? right.PrimaryClaimantFactionId, + IsContested = contested, + TensionScore = MathF.Min(1f, MathF.Abs((left.ControlScore - right.ControlScore) / MathF.Max(50f, left.ControlScore + right.ControlScore))), + CorridorImportance = route.Distance <= 0.01f ? 0f : Math.Clamp((left.StrategicValue + right.StrategicValue) / MathF.Max(route.Distance, 1f), 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var control in state.Territory.ControlStates) + { + var adjacentBorders = state.Territory.BorderEdges.Where(edge => edge.SourceSystemId == control.SystemId || edge.DestinationSystemId == control.SystemId).ToList(); + var hostileBorderCount = adjacentBorders.Count(edge => edge.IsContested); + var corridorImportance = adjacentBorders.Sum(edge => edge.CorridorImportance); + var zoneKind = control.IsContested + ? "contested" + : control.ControllerFactionId is null && control.PrimaryClaimantFactionId is not null + ? "buffer" + : control.ControllerFactionId is not null && hostileBorderCount == 0 + ? "core" + : control.ControllerFactionId is not null && corridorImportance > 1.1f + ? "corridor" + : control.ControllerFactionId is not null + ? "frontier" + : "unclaimed"; + state.Territory.Zones.Add(new TerritoryZoneRuntime + { + Id = $"zone-{control.SystemId}", + SystemId = control.SystemId, + FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, + Kind = zoneKind, + Status = "active", + Reason = zoneKind == "corridor" ? "high-corridor-importance" : zoneKind == "frontier" ? "hostile-border-contact" : zoneKind, + UpdatedAtUtc = nowUtc, + }); + state.Territory.StrategicProfiles.Add(new SectorStrategicProfileRuntime + { + SystemId = control.SystemId, + ControllerFactionId = control.ControllerFactionId, + ZoneKind = zoneKind, + IsContested = control.IsContested, + StrategicValue = control.StrategicValue, + SecurityRating = Math.Clamp(1f - (hostileBorderCount * 0.22f), 0f, 1f), + TerritorialPressure = Math.Clamp(hostileBorderCount * 0.25f, 0f, 1f), + LogisticsValue = Math.Clamp(corridorImportance, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + state.Territory.Pressures.Add(new TerritoryPressureRuntime + { + Id = $"pressure-{control.SystemId}", + SystemId = control.SystemId, + FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, + Kind = control.IsContested ? "contested-pressure" : "territorial-pressure", + PressureScore = Math.Clamp(hostileBorderCount * 0.28f, 0f, 1f), + SecurityScore = Math.Clamp(1f - (hostileBorderCount * 0.2f), 0f, 1f), + HostileInfluence = influencesBySystem.GetValueOrDefault(control.SystemId)?.Skip(control.ControllerFactionId is null ? 0 : 1).Sum(entry => entry.TotalInfluence) ?? 0f, + CorridorRisk = Math.Clamp(corridorImportance > 0.8f && hostileBorderCount > 0 ? 0.7f : hostileBorderCount * 0.2f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + } + + private static void RebuildDiplomacy(SimulationWorld world, GeopoliticalStateRuntime state, ICollection events) + { + state.Diplomacy.Relations.Clear(); + state.Diplomacy.Treaties.Clear(); + state.Diplomacy.BorderTensions.Clear(); + state.Diplomacy.Wars.Clear(); + + var nowUtc = world.GeneratedAtUtc; + var factionPairs = world.Factions + .OrderBy(faction => faction.Id, StringComparer.Ordinal) + .SelectMany((left, index) => world.Factions.Skip(index + 1).Select(right => (left, right))); + + foreach (var (leftFaction, rightFaction) in factionPairs) + { + var borderEdges = state.Territory.BorderEdges + .Where(edge => + (string.Equals(edge.SourceFactionId, leftFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, rightFaction.Id, StringComparison.Ordinal)) + || (string.Equals(edge.SourceFactionId, rightFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, leftFaction.Id, StringComparison.Ordinal))) + .OrderBy(edge => edge.Id, StringComparer.Ordinal) + .ToList(); + var sharedBorderPressure = borderEdges.Sum(edge => edge.TensionScore + (edge.IsContested ? 0.25f : 0f)); + var conflictSystems = borderEdges.SelectMany(edge => new[] { edge.SourceSystemId, edge.DestinationSystemId }).Distinct(StringComparer.Ordinal).ToList(); + var hostilePresence = world.Ships.Count(ship => + ship.Health > 0f + && ((ship.FactionId == leftFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)) + || (ship.FactionId == rightFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)))); + var incidentSeverity = Math.Clamp(sharedBorderPressure + (hostilePresence * 0.03f), 0f, 1.6f); + var relationId = BuildRelationId(leftFaction.Id, rightFaction.Id); + var posture = incidentSeverity switch + { + >= 1.1f => "war", + >= 0.65f => "hostile", + >= 0.3f => "wary", + _ => "neutral", + }; + + var relation = new DiplomaticRelationRuntime + { + Id = relationId, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = "active", + Posture = posture, + TrustScore = Math.Clamp(0.7f - incidentSeverity, 0f, 1f), + TensionScore = Math.Clamp(incidentSeverity, 0f, 1f), + GrievanceScore = Math.Clamp(sharedBorderPressure, 0f, 1f), + TradeAccessPolicy = posture is "war" or "hostile" ? "restricted" : "open", + MilitaryAccessPolicy = posture == "neutral" ? "transit" : posture == "wary" ? "restricted" : "denied", + UpdatedAtUtc = nowUtc, + }; + + if (relation.Posture == "neutral") + { + var treaty = new TreatyRuntime + { + Id = $"treaty-open-trade-{relationId}", + Kind = "trade-understanding", + Status = "active", + TradeAccessPolicy = "open", + MilitaryAccessPolicy = "restricted", + Summary = $"Open civilian trade between {leftFaction.Label} and {rightFaction.Label}.", + CreatedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + treaty.FactionIds.Add(leftFaction.Id); + treaty.FactionIds.Add(rightFaction.Id); + state.Diplomacy.Treaties.Add(treaty); + relation.ActiveTreatyIds.Add(treaty.Id); + relation.TradeAccessPolicy = "open"; + } + + state.Diplomacy.Relations.Add(relation); + + foreach (var borderEdge in borderEdges) + { + borderEdge.RelationId = relation.Id; + borderEdge.TensionScore = Math.Clamp(borderEdge.TensionScore + (relation.TensionScore * 0.35f), 0f, 1f); + var tension = new BorderTensionRuntime + { + Id = $"tension-{borderEdge.Id}", + RelationId = relation.Id, + BorderEdgeId = borderEdge.Id, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = relation.Posture is "war" or "hostile" ? "escalating" : "stable", + TensionScore = relation.TensionScore, + IncidentScore = incidentSeverity, + MilitaryPressure = Math.Clamp(hostilePresence * 0.05f, 0f, 1f), + AccessFriction = relation.TradeAccessPolicy == "open" ? 0.15f : 0.75f, + UpdatedAtUtc = nowUtc, + }; + tension.SystemIds.Add(borderEdge.SourceSystemId); + tension.SystemIds.Add(borderEdge.DestinationSystemId); + state.Diplomacy.BorderTensions.Add(tension); + + if (tension.TensionScore >= 0.35f) + { + var incidentId = $"incident-border-{relationId}-{borderEdge.Id}"; + var incident = new DiplomaticIncidentRuntime + { + Id = incidentId, + Kind = borderEdge.IsContested ? "border-clash" : "border-friction", + Status = relation.Posture == "war" ? "escalated" : "active", + SourceFactionId = leftFaction.Id, + TargetFactionId = rightFaction.Id, + SystemId = borderEdge.SourceSystemId, + BorderEdgeId = borderEdge.Id, + Summary = $"{leftFaction.Label} and {rightFaction.Label} are under pressure on {borderEdge.SourceSystemId}/{borderEdge.DestinationSystemId}.", + Severity = tension.TensionScore, + EscalationScore = tension.IncidentScore, + CreatedAtUtc = nowUtc, + LastObservedAtUtc = nowUtc, + }; + state.Diplomacy.Incidents.Add(incident); + relation.ActiveIncidentIds.Add(incident.Id); + } + } + + if (relation.Posture == "war") + { + var warId = $"war-{relationId}"; + var war = new WarStateRuntime + { + Id = warId, + RelationId = relation.Id, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = "active", + WarGoal = "border-dominance", + EscalationScore = relation.TensionScore, + StartedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + relation.WarStateId = war.Id; + state.Diplomacy.Wars.Add(war); + } + } + + BuildFrontLines(state, nowUtc, events); + } + + private static void BuildFrontLines(GeopoliticalStateRuntime state, DateTimeOffset nowUtc, ICollection events) + { + foreach (var group in state.Diplomacy.BorderTensions + .Where(tension => tension.TensionScore >= 0.35f) + .GroupBy(tension => BuildPairId("front", tension.FactionAId, tension.FactionBId), StringComparer.Ordinal)) + { + var tensions = group.OrderByDescending(tension => tension.TensionScore).ThenBy(tension => tension.Id, StringComparer.Ordinal).ToList(); + var front = new FrontLineRuntime + { + Id = group.Key, + Kind = state.Diplomacy.Wars.Any(war => war.RelationId == tensions[0].RelationId && war.Status == "active") ? "war-front" : "border-front", + Status = "active", + AnchorSystemId = tensions.SelectMany(tension => tension.SystemIds).GroupBy(systemId => systemId, StringComparer.Ordinal).OrderByDescending(entry => entry.Count()).ThenBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key).FirstOrDefault(), + PressureScore = Math.Clamp(tensions.Sum(tension => tension.TensionScore) / tensions.Count, 0f, 1f), + SupplyRisk = Math.Clamp(tensions.Sum(tension => tension.AccessFriction) / tensions.Count, 0f, 1f), + UpdatedAtUtc = nowUtc, + }; + front.FactionIds.Add(tensions[0].FactionAId); + front.FactionIds.Add(tensions[0].FactionBId); + front.SystemIds.AddRange(tensions.SelectMany(tension => tension.SystemIds).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); + front.BorderEdgeIds.AddRange(tensions.Select(tension => tension.BorderEdgeId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); + state.Territory.FrontLines.Add(front); + + foreach (var war in state.Diplomacy.Wars.Where(war => string.Equals(war.RelationId, tensions[0].RelationId, StringComparison.Ordinal))) + { + war.ActiveFrontLineIds.Add(front.Id); + } + + events.Add(new SimulationEventRecord("front-line", front.Id, "front-updated", $"Front {front.Id} pressure {front.PressureScore.ToString("0.00", CultureInfo.InvariantCulture)}.", nowUtc, "geopolitics")); + } + + foreach (var profile in state.Territory.StrategicProfiles) + { + profile.FrontLineId = state.Territory.FrontLines.FirstOrDefault(front => front.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; + } + } + + private static void RebuildEconomyRegions(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.EconomyRegions.Regions.Clear(); + state.EconomyRegions.SupplyNetworks.Clear(); + state.EconomyRegions.Corridors.Clear(); + state.EconomyRegions.ProductionProfiles.Clear(); + state.EconomyRegions.TradeBalances.Clear(); + state.EconomyRegions.Bottlenecks.Clear(); + state.EconomyRegions.SecurityAssessments.Clear(); + state.EconomyRegions.EconomicAssessments.Clear(); + + var nowUtc = world.GeneratedAtUtc; + foreach (var faction in world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal)) + { + var factionSystems = state.Territory.ControlStates + .Where(control => string.Equals(control.ControllerFactionId ?? control.PrimaryClaimantFactionId, faction.Id, StringComparison.Ordinal)) + .Select(control => control.SystemId) + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .ToList(); + if (factionSystems.Count == 0) + { + continue; + } + + var connectedComponents = BuildConnectedComponents(factionSystems, state.Routes); + foreach (var component in connectedComponents) + { + var coreSystemId = component + .OrderByDescending(systemId => world.Stations.Count(station => station.FactionId == faction.Id && station.SystemId == systemId)) + .ThenBy(systemId => systemId, StringComparer.Ordinal) + .First(); + var regionId = $"region-{faction.Id}-{coreSystemId}"; + var stations = world.Stations + .Where(station => station.FactionId == faction.Id && component.Contains(station.SystemId, StringComparer.Ordinal)) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .ToList(); + var economy = BuildRegionalEconomy(world, faction.Id, component); + var regionKind = ResolveRegionKind(stations, economy); + var frontLineIds = state.Territory.FrontLines + .Where(front => front.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))) + .Select(front => front.Id) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + var region = new EconomicRegionRuntime + { + Id = regionId, + FactionId = faction.Id, + Label = $"{faction.Label} {coreSystemId}", + Kind = regionKind, + Status = "active", + CoreSystemId = coreSystemId, + UpdatedAtUtc = nowUtc, + }; + region.SystemIds.AddRange(component.OrderBy(id => id, StringComparer.Ordinal)); + region.StationIds.AddRange(stations.Select(station => station.Id)); + region.FrontLineIds.AddRange(frontLineIds); + state.EconomyRegions.Regions.Add(region); + + var producerItems = economy.Commodities + .Where(entry => entry.Value.ProductionRatePerSecond > 0.01f) + .OrderByDescending(entry => entry.Value.ProductionRatePerSecond) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Take(8) + .Select(entry => entry.Key) + .ToList(); + var scarceItems = economy.Commodities + .Where(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low) + .OrderByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Value, 240f)) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Take(8) + .Select(entry => entry.Key) + .ToList(); + + var supplyNetwork = new SupplyNetworkRuntime + { + Id = $"network-{regionId}", + RegionId = regionId, + ThroughputScore = Math.Clamp(stations.Count * 0.18f, 0f, 1f), + RiskScore = Math.Clamp(frontLineIds.Count * 0.24f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }; + supplyNetwork.StationIds.AddRange(stations.Select(station => station.Id)); + supplyNetwork.ProducerItemIds.AddRange(producerItems); + supplyNetwork.ConsumerItemIds.AddRange(scarceItems); + supplyNetwork.ConstructionItemIds.AddRange(world.ConstructionSites + .Where(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) + .SelectMany(site => site.RequiredItems.Keys) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal)); + state.EconomyRegions.SupplyNetworks.Add(supplyNetwork); + + var productionProfile = new RegionalProductionProfileRuntime + { + RegionId = regionId, + PrimaryIndustry = regionKind, + ShipyardCount = stations.Count(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), + StationCount = stations.Count, + UpdatedAtUtc = nowUtc, + }; + productionProfile.ProducedItemIds.AddRange(producerItems); + productionProfile.ScarceItemIds.AddRange(scarceItems); + state.EconomyRegions.ProductionProfiles.Add(productionProfile); + + state.EconomyRegions.TradeBalances.Add(new RegionalTradeBalanceRuntime + { + RegionId = regionId, + ImportsRequiredCount = economy.Commodities.Count(entry => entry.Value.BuyBacklog > 0.01f), + ExportsSurplusCount = economy.Commodities.Count(entry => entry.Value.SellBacklog > 0.01f || entry.Value.Level == CommodityLevelKind.Surplus), + CriticalShortageCount = scarceItems.Count, + NetTradeScore = Math.Clamp((economy.Commodities.Sum(entry => entry.Value.ProjectedNetRatePerSecond) + 5f) / 10f, -1f, 1f), + UpdatedAtUtc = nowUtc, + }); + + if (scarceItems.FirstOrDefault() is { } bottleneckItemId) + { + state.EconomyRegions.Bottlenecks.Add(new RegionalBottleneckRuntime + { + Id = $"bottleneck-{regionId}-{bottleneckItemId}", + RegionId = regionId, + ItemId = bottleneckItemId, + Cause = "regional-shortage", + Status = "active", + Severity = Math.Clamp(CommodityOperationalSignal.ComputeNeedScore(economy.GetCommodity(bottleneckItemId), 240f), 0f, 10f), + UpdatedAtUtc = nowUtc, + }); + } + + var supplyRisk = Math.Clamp(frontLineIds.Count * 0.2f, 0f, 1f); + state.EconomyRegions.SecurityAssessments.Add(new RegionalSecurityAssessmentRuntime + { + RegionId = regionId, + SupplyRisk = supplyRisk, + BorderPressure = Math.Clamp(frontLineIds.Count * 0.22f, 0f, 1f), + ActiveWarCount = state.Diplomacy.Wars.Count(war => war.ActiveFrontLineIds.Intersect(frontLineIds, StringComparer.Ordinal).Any()), + HostileRelationCount = state.Diplomacy.Relations.Count(relation => relation.Posture is "hostile" or "war"), + AccessFriction = Math.Clamp(state.Diplomacy.BorderTensions.Where(tension => tension.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))).DefaultIfEmpty().Average(tension => tension?.AccessFriction ?? 0f), 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + + state.EconomyRegions.EconomicAssessments.Add(new RegionalEconomicAssessmentRuntime + { + RegionId = regionId, + SustainmentScore = Math.Clamp(1f - (scarceItems.Count * 0.12f) - (supplyRisk * 0.35f), 0f, 1f), + ProductionDepth = Math.Clamp(producerItems.Count / 8f, 0f, 1f), + ConstructionPressure = Math.Clamp(world.ConstructionSites.Count(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) * 0.22f, 0f, 1f), + CorridorDependency = Math.Clamp(frontLineIds.Count * 0.18f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + } + + BuildCorridors(world, state, nowUtc); + foreach (var profile in state.Territory.StrategicProfiles) + { + profile.EconomicRegionId = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; + } + } + + private static void BuildCorridors(SimulationWorld world, GeopoliticalStateRuntime state, DateTimeOffset nowUtc) + { + foreach (var route in state.Routes) + { + var sourceRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.SourceSystemId, StringComparer.Ordinal)); + var destinationRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.DestinationSystemId, StringComparer.Ordinal)); + if (sourceRegion is null && destinationRegion is null) + { + continue; + } + + var borderEdge = state.Territory.BorderEdges.FirstOrDefault(edge => + (edge.SourceSystemId == route.SourceSystemId && edge.DestinationSystemId == route.DestinationSystemId) + || (edge.SourceSystemId == route.DestinationSystemId && edge.DestinationSystemId == route.SourceSystemId)); + var risk = borderEdge?.TensionScore ?? 0f; + var corridor = new LogisticsCorridorRuntime + { + Id = $"corridor-{route.Id}", + FactionId = sourceRegion?.FactionId ?? destinationRegion?.FactionId, + Kind = borderEdge?.IsContested == true ? "frontier-corridor" : "supply-corridor", + Status = borderEdge?.IsContested == true ? "risky" : "active", + RiskScore = Math.Clamp(risk + ((sourceRegion is not null && destinationRegion is not null && sourceRegion.Id != destinationRegion.Id) ? 0.15f : 0f), 0f, 1f), + ThroughputScore = Math.Clamp(((sourceRegion?.StationIds.Count ?? 0) + (destinationRegion?.StationIds.Count ?? 0)) / 10f, 0f, 1f), + AccessState = ResolveCorridorAccessState(world, borderEdge, sourceRegion, destinationRegion), + UpdatedAtUtc = nowUtc, + }; + corridor.SystemPathIds.Add(route.SourceSystemId); + corridor.SystemPathIds.Add(route.DestinationSystemId); + if (sourceRegion is not null) + { + corridor.RegionIds.Add(sourceRegion.Id); + } + if (destinationRegion is not null && !corridor.RegionIds.Contains(destinationRegion.Id, StringComparer.Ordinal)) + { + corridor.RegionIds.Add(destinationRegion.Id); + } + if (borderEdge is not null) + { + corridor.BorderEdgeIds.Add(borderEdge.Id); + } + + state.EconomyRegions.Corridors.Add(corridor); + if (sourceRegion is not null && !sourceRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) + { + sourceRegion.CorridorIds.Add(corridor.Id); + } + if (destinationRegion is not null && !destinationRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) + { + destinationRegion.CorridorIds.Add(corridor.Id); + } + } + } + + private static string ResolveCorridorAccessState( + SimulationWorld world, + BorderEdgeRuntime? borderEdge, + EconomicRegionRuntime? sourceRegion, + EconomicRegionRuntime? destinationRegion) + { + if (sourceRegion?.FactionId is null || destinationRegion?.FactionId is null) + { + return borderEdge?.IsContested == true ? "restricted" : "open"; + } + + var relation = FindRelation(world, sourceRegion.FactionId, destinationRegion.FactionId); + if (relation is null) + { + return "restricted"; + } + + return relation.Posture switch + { + "war" => "denied", + "hostile" => "restricted", + _ => relation.TradeAccessPolicy, + }; + } + + private static FactionEconomySnapshot BuildRegionalEconomy(SimulationWorld world, string factionId, IReadOnlyCollection systemIds) + { + var snapshot = new FactionEconomySnapshot(); + foreach (var station in world.Stations.Where(station => station.FactionId == factionId && systemIds.Contains(station.SystemId, StringComparer.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); + 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 => order.FactionId == factionId)) + { + var relatedSystemId = world.Stations.FirstOrDefault(station => station.Id == order.StationId)?.SystemId + ?? world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId)?.SystemId; + if (relatedSystemId is null || !systemIds.Contains(relatedSystemId, StringComparer.Ordinal)) + { + continue; + } + + var commodity = snapshot.GetCommodity(order.ItemId); + if (order.Kind == MarketOrderKinds.Buy) + { + commodity.BuyBacklog += order.RemainingAmount; + } + else if (order.Kind == MarketOrderKinds.Sell) + { + commodity.SellBacklog += order.RemainingAmount; + } + } + + foreach (var site in world.ConstructionSites.Where(site => site.FactionId == factionId && systemIds.Contains(site.SystemId, StringComparer.Ordinal))) + { + foreach (var required in site.RequiredItems) + { + var remaining = MathF.Max(0f, required.Value - (site.DeliveredItems.TryGetValue(required.Key, out var delivered) ? delivered : 0f)); + if (remaining > 0.01f) + { + snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining; + } + } + } + + return snapshot; + } + + private static List> BuildConnectedComponents(IReadOnlyCollection systems, IReadOnlyCollection routes) + { + var remaining = systems.ToHashSet(StringComparer.Ordinal); + var adjacency = routes + .SelectMany(route => new[] { - "military" => 9f, - "construction" => 4f, - "transport" => 3f, - _ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f, - _ => 2f, - }) ?? 0f; - var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength; - influences.Add(new TerritoryInfluenceRuntime - { - Id = $"influence-{system.Definition.Id}-{factionId}", - SystemId = system.Definition.Id, - FactionId = factionId, - ClaimStrength = claimStrength, - AssetStrength = stationStrength + shipStrength, - LogisticsStrength = logisticsStrength, - TotalInfluence = claimStrength + stationStrength + shipStrength + logisticsStrength, - UpdatedAtUtc = nowUtc, - }); - } - - influences.Sort((left, right) => - { - var total = right.TotalInfluence.CompareTo(left.TotalInfluence); - return total != 0 ? total : string.Compare(left.FactionId, right.FactionId, StringComparison.Ordinal); - }); - if (influences.Count > 1) - { - var lead = influences[0].TotalInfluence; - foreach (var influence in influences.Skip(1)) - { - influence.IsContesting = influence.TotalInfluence >= (lead * 0.7f); - } - - influences[0].IsContesting = influences[1].TotalInfluence >= (lead * 0.7f); - } - - influencesBySystem[system.Definition.Id] = influences; - state.Territory.Influences.AddRange(influences); - - var top = influences.FirstOrDefault(); - var second = influences.Skip(1).FirstOrDefault(); - var contested = top is not null && second is not null && second.TotalInfluence >= (top.TotalInfluence * 0.7f); - var controllerFactionId = top is not null && (!contested || top.TotalInfluence >= second!.TotalInfluence + 20f) - ? top.FactionId - : null; - var primaryClaimantFactionId = state.Territory.Claims - .Where(claim => claim.SystemId == system.Definition.Id) - .GroupBy(claim => claim.FactionId, StringComparer.Ordinal) - .OrderByDescending(group => group.Sum(claim => claim.ClaimStrength)) - .ThenBy(group => group.Key, StringComparer.Ordinal) - .Select(group => group.Key) - .FirstOrDefault(); - - var strategicValue = EstimateSystemStrategicValue(world, system.Definition.Id); - var controlState = new TerritoryControlStateRuntime - { - SystemId = system.Definition.Id, - ControllerFactionId = controllerFactionId, - PrimaryClaimantFactionId = primaryClaimantFactionId, - ControlKind = contested - ? "contested" - : controllerFactionId is not null - ? "controlled" - : primaryClaimantFactionId is not null - ? "claimed" - : "unclaimed", - IsContested = contested, - ControlScore = top?.TotalInfluence ?? 0f, - StrategicValue = strategicValue, - UpdatedAtUtc = nowUtc, - }; - controlState.ClaimantFactionIds.AddRange(state.Territory.Claims - .Where(claim => claim.SystemId == system.Definition.Id) - .Select(claim => claim.FactionId) - .Distinct(StringComparer.Ordinal) - .OrderBy(id => id, StringComparer.Ordinal)); - controlState.InfluencingFactionIds.AddRange(influences - .Select(influence => influence.FactionId) - .OrderBy(id => id, StringComparer.Ordinal)); - state.Territory.ControlStates.Add(controlState); - } - - foreach (var route in state.Routes) - { - var left = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.SourceSystemId); - var right = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.DestinationSystemId); - var differentControllers = !string.Equals(left.ControllerFactionId, right.ControllerFactionId, StringComparison.Ordinal); - var contested = left.IsContested || right.IsContested || differentControllers; - if (!contested && left.ControllerFactionId is null && right.ControllerFactionId is null) - { - continue; - } - - state.Territory.BorderEdges.Add(new BorderEdgeRuntime - { - Id = $"border-{route.Id}", - SourceSystemId = route.SourceSystemId, - DestinationSystemId = route.DestinationSystemId, - SourceFactionId = left.ControllerFactionId ?? left.PrimaryClaimantFactionId, - DestinationFactionId = right.ControllerFactionId ?? right.PrimaryClaimantFactionId, - IsContested = contested, - TensionScore = MathF.Min(1f, MathF.Abs((left.ControlScore - right.ControlScore) / MathF.Max(50f, left.ControlScore + right.ControlScore))), - CorridorImportance = route.Distance <= 0.01f ? 0f : Math.Clamp((left.StrategicValue + right.StrategicValue) / MathF.Max(route.Distance, 1f), 0f, 1f), - UpdatedAtUtc = nowUtc, - }); - } - - foreach (var control in state.Territory.ControlStates) - { - var adjacentBorders = state.Territory.BorderEdges.Where(edge => edge.SourceSystemId == control.SystemId || edge.DestinationSystemId == control.SystemId).ToList(); - var hostileBorderCount = adjacentBorders.Count(edge => edge.IsContested); - var corridorImportance = adjacentBorders.Sum(edge => edge.CorridorImportance); - var zoneKind = control.IsContested - ? "contested" - : control.ControllerFactionId is null && control.PrimaryClaimantFactionId is not null - ? "buffer" - : control.ControllerFactionId is not null && hostileBorderCount == 0 - ? "core" - : control.ControllerFactionId is not null && corridorImportance > 1.1f - ? "corridor" - : control.ControllerFactionId is not null - ? "frontier" - : "unclaimed"; - state.Territory.Zones.Add(new TerritoryZoneRuntime - { - Id = $"zone-{control.SystemId}", - SystemId = control.SystemId, - FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, - Kind = zoneKind, - Status = "active", - Reason = zoneKind == "corridor" ? "high-corridor-importance" : zoneKind == "frontier" ? "hostile-border-contact" : zoneKind, - UpdatedAtUtc = nowUtc, - }); - state.Territory.StrategicProfiles.Add(new SectorStrategicProfileRuntime - { - SystemId = control.SystemId, - ControllerFactionId = control.ControllerFactionId, - ZoneKind = zoneKind, - IsContested = control.IsContested, - StrategicValue = control.StrategicValue, - SecurityRating = Math.Clamp(1f - (hostileBorderCount * 0.22f), 0f, 1f), - TerritorialPressure = Math.Clamp(hostileBorderCount * 0.25f, 0f, 1f), - LogisticsValue = Math.Clamp(corridorImportance, 0f, 1f), - UpdatedAtUtc = nowUtc, - }); - state.Territory.Pressures.Add(new TerritoryPressureRuntime - { - Id = $"pressure-{control.SystemId}", - SystemId = control.SystemId, - FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, - Kind = control.IsContested ? "contested-pressure" : "territorial-pressure", - PressureScore = Math.Clamp(hostileBorderCount * 0.28f, 0f, 1f), - SecurityScore = Math.Clamp(1f - (hostileBorderCount * 0.2f), 0f, 1f), - HostileInfluence = influencesBySystem.GetValueOrDefault(control.SystemId)?.Skip(control.ControllerFactionId is null ? 0 : 1).Sum(entry => entry.TotalInfluence) ?? 0f, - CorridorRisk = Math.Clamp(corridorImportance > 0.8f && hostileBorderCount > 0 ? 0.7f : hostileBorderCount * 0.2f, 0f, 1f), - UpdatedAtUtc = nowUtc, - }); - } - } - - private static void RebuildDiplomacy(SimulationWorld world, GeopoliticalStateRuntime state, ICollection events) - { - state.Diplomacy.Relations.Clear(); - state.Diplomacy.Treaties.Clear(); - state.Diplomacy.BorderTensions.Clear(); - state.Diplomacy.Wars.Clear(); - - var nowUtc = world.GeneratedAtUtc; - var factionPairs = world.Factions - .OrderBy(faction => faction.Id, StringComparer.Ordinal) - .SelectMany((left, index) => world.Factions.Skip(index + 1).Select(right => (left, right))); - - foreach (var (leftFaction, rightFaction) in factionPairs) - { - var borderEdges = state.Territory.BorderEdges - .Where(edge => - (string.Equals(edge.SourceFactionId, leftFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, rightFaction.Id, StringComparison.Ordinal)) - || (string.Equals(edge.SourceFactionId, rightFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, leftFaction.Id, StringComparison.Ordinal))) - .OrderBy(edge => edge.Id, StringComparer.Ordinal) - .ToList(); - var sharedBorderPressure = borderEdges.Sum(edge => edge.TensionScore + (edge.IsContested ? 0.25f : 0f)); - var conflictSystems = borderEdges.SelectMany(edge => new[] { edge.SourceSystemId, edge.DestinationSystemId }).Distinct(StringComparer.Ordinal).ToList(); - var hostilePresence = world.Ships.Count(ship => - ship.Health > 0f - && ((ship.FactionId == leftFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)) - || (ship.FactionId == rightFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)))); - var incidentSeverity = Math.Clamp(sharedBorderPressure + (hostilePresence * 0.03f), 0f, 1.6f); - var relationId = BuildRelationId(leftFaction.Id, rightFaction.Id); - var posture = incidentSeverity switch - { - >= 1.1f => "war", - >= 0.65f => "hostile", - >= 0.3f => "wary", - _ => "neutral", - }; - - var relation = new DiplomaticRelationRuntime - { - Id = relationId, - FactionAId = leftFaction.Id, - FactionBId = rightFaction.Id, - Status = "active", - Posture = posture, - TrustScore = Math.Clamp(0.7f - incidentSeverity, 0f, 1f), - TensionScore = Math.Clamp(incidentSeverity, 0f, 1f), - GrievanceScore = Math.Clamp(sharedBorderPressure, 0f, 1f), - TradeAccessPolicy = posture is "war" or "hostile" ? "restricted" : "open", - MilitaryAccessPolicy = posture == "neutral" ? "transit" : posture == "wary" ? "restricted" : "denied", - UpdatedAtUtc = nowUtc, - }; - - if (relation.Posture == "neutral") - { - var treaty = new TreatyRuntime - { - Id = $"treaty-open-trade-{relationId}", - Kind = "trade-understanding", - Status = "active", - TradeAccessPolicy = "open", - MilitaryAccessPolicy = "restricted", - Summary = $"Open civilian trade between {leftFaction.Label} and {rightFaction.Label}.", - CreatedAtUtc = nowUtc, - UpdatedAtUtc = nowUtc, - }; - treaty.FactionIds.Add(leftFaction.Id); - treaty.FactionIds.Add(rightFaction.Id); - state.Diplomacy.Treaties.Add(treaty); - relation.ActiveTreatyIds.Add(treaty.Id); - relation.TradeAccessPolicy = "open"; - } - - state.Diplomacy.Relations.Add(relation); - - foreach (var borderEdge in borderEdges) - { - borderEdge.RelationId = relation.Id; - borderEdge.TensionScore = Math.Clamp(borderEdge.TensionScore + (relation.TensionScore * 0.35f), 0f, 1f); - var tension = new BorderTensionRuntime - { - Id = $"tension-{borderEdge.Id}", - RelationId = relation.Id, - BorderEdgeId = borderEdge.Id, - FactionAId = leftFaction.Id, - FactionBId = rightFaction.Id, - Status = relation.Posture is "war" or "hostile" ? "escalating" : "stable", - TensionScore = relation.TensionScore, - IncidentScore = incidentSeverity, - MilitaryPressure = Math.Clamp(hostilePresence * 0.05f, 0f, 1f), - AccessFriction = relation.TradeAccessPolicy == "open" ? 0.15f : 0.75f, - UpdatedAtUtc = nowUtc, - }; - tension.SystemIds.Add(borderEdge.SourceSystemId); - tension.SystemIds.Add(borderEdge.DestinationSystemId); - state.Diplomacy.BorderTensions.Add(tension); - - if (tension.TensionScore >= 0.35f) - { - var incidentId = $"incident-border-{relationId}-{borderEdge.Id}"; - var incident = new DiplomaticIncidentRuntime - { - Id = incidentId, - Kind = borderEdge.IsContested ? "border-clash" : "border-friction", - Status = relation.Posture == "war" ? "escalated" : "active", - SourceFactionId = leftFaction.Id, - TargetFactionId = rightFaction.Id, - SystemId = borderEdge.SourceSystemId, - BorderEdgeId = borderEdge.Id, - Summary = $"{leftFaction.Label} and {rightFaction.Label} are under pressure on {borderEdge.SourceSystemId}/{borderEdge.DestinationSystemId}.", - Severity = tension.TensionScore, - EscalationScore = tension.IncidentScore, - CreatedAtUtc = nowUtc, - LastObservedAtUtc = nowUtc, - }; - state.Diplomacy.Incidents.Add(incident); - relation.ActiveIncidentIds.Add(incident.Id); - } - } - - if (relation.Posture == "war") - { - var warId = $"war-{relationId}"; - var war = new WarStateRuntime - { - Id = warId, - RelationId = relation.Id, - FactionAId = leftFaction.Id, - FactionBId = rightFaction.Id, - Status = "active", - WarGoal = "border-dominance", - EscalationScore = relation.TensionScore, - StartedAtUtc = nowUtc, - UpdatedAtUtc = nowUtc, - }; - relation.WarStateId = war.Id; - state.Diplomacy.Wars.Add(war); - } - } - - BuildFrontLines(state, nowUtc, events); - } - - private static void BuildFrontLines(GeopoliticalStateRuntime state, DateTimeOffset nowUtc, ICollection events) - { - foreach (var group in state.Diplomacy.BorderTensions - .Where(tension => tension.TensionScore >= 0.35f) - .GroupBy(tension => BuildPairId("front", tension.FactionAId, tension.FactionBId), StringComparer.Ordinal)) - { - var tensions = group.OrderByDescending(tension => tension.TensionScore).ThenBy(tension => tension.Id, StringComparer.Ordinal).ToList(); - var front = new FrontLineRuntime - { - Id = group.Key, - Kind = state.Diplomacy.Wars.Any(war => war.RelationId == tensions[0].RelationId && war.Status == "active") ? "war-front" : "border-front", - Status = "active", - AnchorSystemId = tensions.SelectMany(tension => tension.SystemIds).GroupBy(systemId => systemId, StringComparer.Ordinal).OrderByDescending(entry => entry.Count()).ThenBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key).FirstOrDefault(), - PressureScore = Math.Clamp(tensions.Sum(tension => tension.TensionScore) / tensions.Count, 0f, 1f), - SupplyRisk = Math.Clamp(tensions.Sum(tension => tension.AccessFriction) / tensions.Count, 0f, 1f), - UpdatedAtUtc = nowUtc, - }; - front.FactionIds.Add(tensions[0].FactionAId); - front.FactionIds.Add(tensions[0].FactionBId); - front.SystemIds.AddRange(tensions.SelectMany(tension => tension.SystemIds).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); - front.BorderEdgeIds.AddRange(tensions.Select(tension => tension.BorderEdgeId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); - state.Territory.FrontLines.Add(front); - - foreach (var war in state.Diplomacy.Wars.Where(war => string.Equals(war.RelationId, tensions[0].RelationId, StringComparison.Ordinal))) - { - war.ActiveFrontLineIds.Add(front.Id); - } - - events.Add(new SimulationEventRecord("front-line", front.Id, "front-updated", $"Front {front.Id} pressure {front.PressureScore.ToString("0.00", CultureInfo.InvariantCulture)}.", nowUtc, "geopolitics")); - } - - foreach (var profile in state.Territory.StrategicProfiles) - { - profile.FrontLineId = state.Territory.FrontLines.FirstOrDefault(front => front.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; - } - } - - private static void RebuildEconomyRegions(SimulationWorld world, GeopoliticalStateRuntime state) - { - state.EconomyRegions.Regions.Clear(); - state.EconomyRegions.SupplyNetworks.Clear(); - state.EconomyRegions.Corridors.Clear(); - state.EconomyRegions.ProductionProfiles.Clear(); - state.EconomyRegions.TradeBalances.Clear(); - state.EconomyRegions.Bottlenecks.Clear(); - state.EconomyRegions.SecurityAssessments.Clear(); - state.EconomyRegions.EconomicAssessments.Clear(); - - var nowUtc = world.GeneratedAtUtc; - foreach (var faction in world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal)) - { - var factionSystems = state.Territory.ControlStates - .Where(control => string.Equals(control.ControllerFactionId ?? control.PrimaryClaimantFactionId, faction.Id, StringComparison.Ordinal)) - .Select(control => control.SystemId) - .Distinct(StringComparer.Ordinal) - .OrderBy(systemId => systemId, StringComparer.Ordinal) - .ToList(); - if (factionSystems.Count == 0) - { - continue; - } - - var connectedComponents = BuildConnectedComponents(factionSystems, state.Routes); - foreach (var component in connectedComponents) - { - var coreSystemId = component - .OrderByDescending(systemId => world.Stations.Count(station => station.FactionId == faction.Id && station.SystemId == systemId)) - .ThenBy(systemId => systemId, StringComparer.Ordinal) - .First(); - var regionId = $"region-{faction.Id}-{coreSystemId}"; - var stations = world.Stations - .Where(station => station.FactionId == faction.Id && component.Contains(station.SystemId, StringComparer.Ordinal)) - .OrderBy(station => station.Id, StringComparer.Ordinal) - .ToList(); - var economy = BuildRegionalEconomy(world, faction.Id, component); - var regionKind = ResolveRegionKind(stations, economy); - var frontLineIds = state.Territory.FrontLines - .Where(front => front.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))) - .Select(front => front.Id) - .Distinct(StringComparer.Ordinal) - .OrderBy(id => id, StringComparer.Ordinal) - .ToList(); - - var region = new EconomicRegionRuntime - { - Id = regionId, - FactionId = faction.Id, - Label = $"{faction.Label} {coreSystemId}", - Kind = regionKind, - Status = "active", - CoreSystemId = coreSystemId, - UpdatedAtUtc = nowUtc, - }; - region.SystemIds.AddRange(component.OrderBy(id => id, StringComparer.Ordinal)); - region.StationIds.AddRange(stations.Select(station => station.Id)); - region.FrontLineIds.AddRange(frontLineIds); - state.EconomyRegions.Regions.Add(region); - - var producerItems = economy.Commodities - .Where(entry => entry.Value.ProductionRatePerSecond > 0.01f) - .OrderByDescending(entry => entry.Value.ProductionRatePerSecond) - .ThenBy(entry => entry.Key, StringComparer.Ordinal) - .Take(8) - .Select(entry => entry.Key) - .ToList(); - var scarceItems = economy.Commodities - .Where(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low) - .OrderByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Value, 240f)) - .ThenBy(entry => entry.Key, StringComparer.Ordinal) - .Take(8) - .Select(entry => entry.Key) - .ToList(); - - var supplyNetwork = new SupplyNetworkRuntime - { - Id = $"network-{regionId}", - RegionId = regionId, - ThroughputScore = Math.Clamp(stations.Count * 0.18f, 0f, 1f), - RiskScore = Math.Clamp(frontLineIds.Count * 0.24f, 0f, 1f), - UpdatedAtUtc = nowUtc, - }; - supplyNetwork.StationIds.AddRange(stations.Select(station => station.Id)); - supplyNetwork.ProducerItemIds.AddRange(producerItems); - supplyNetwork.ConsumerItemIds.AddRange(scarceItems); - supplyNetwork.ConstructionItemIds.AddRange(world.ConstructionSites - .Where(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) - .SelectMany(site => site.RequiredItems.Keys) - .Distinct(StringComparer.Ordinal) - .OrderBy(id => id, StringComparer.Ordinal)); - state.EconomyRegions.SupplyNetworks.Add(supplyNetwork); - - var productionProfile = new RegionalProductionProfileRuntime - { - RegionId = regionId, - PrimaryIndustry = regionKind, - ShipyardCount = stations.Count(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), - StationCount = stations.Count, - UpdatedAtUtc = nowUtc, - }; - productionProfile.ProducedItemIds.AddRange(producerItems); - productionProfile.ScarceItemIds.AddRange(scarceItems); - state.EconomyRegions.ProductionProfiles.Add(productionProfile); - - state.EconomyRegions.TradeBalances.Add(new RegionalTradeBalanceRuntime - { - RegionId = regionId, - ImportsRequiredCount = economy.Commodities.Count(entry => entry.Value.BuyBacklog > 0.01f), - ExportsSurplusCount = economy.Commodities.Count(entry => entry.Value.SellBacklog > 0.01f || entry.Value.Level == CommodityLevelKind.Surplus), - CriticalShortageCount = scarceItems.Count, - NetTradeScore = Math.Clamp((economy.Commodities.Sum(entry => entry.Value.ProjectedNetRatePerSecond) + 5f) / 10f, -1f, 1f), - UpdatedAtUtc = nowUtc, - }); - - if (scarceItems.FirstOrDefault() is { } bottleneckItemId) - { - state.EconomyRegions.Bottlenecks.Add(new RegionalBottleneckRuntime - { - Id = $"bottleneck-{regionId}-{bottleneckItemId}", - RegionId = regionId, - ItemId = bottleneckItemId, - Cause = "regional-shortage", - Status = "active", - Severity = Math.Clamp(CommodityOperationalSignal.ComputeNeedScore(economy.GetCommodity(bottleneckItemId), 240f), 0f, 10f), - UpdatedAtUtc = nowUtc, - }); - } - - var supplyRisk = Math.Clamp(frontLineIds.Count * 0.2f, 0f, 1f); - state.EconomyRegions.SecurityAssessments.Add(new RegionalSecurityAssessmentRuntime - { - RegionId = regionId, - SupplyRisk = supplyRisk, - BorderPressure = Math.Clamp(frontLineIds.Count * 0.22f, 0f, 1f), - ActiveWarCount = state.Diplomacy.Wars.Count(war => war.ActiveFrontLineIds.Intersect(frontLineIds, StringComparer.Ordinal).Any()), - HostileRelationCount = state.Diplomacy.Relations.Count(relation => relation.Posture is "hostile" or "war"), - AccessFriction = Math.Clamp(state.Diplomacy.BorderTensions.Where(tension => tension.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))).DefaultIfEmpty().Average(tension => tension?.AccessFriction ?? 0f), 0f, 1f), - UpdatedAtUtc = nowUtc, - }); - - state.EconomyRegions.EconomicAssessments.Add(new RegionalEconomicAssessmentRuntime - { - RegionId = regionId, - SustainmentScore = Math.Clamp(1f - (scarceItems.Count * 0.12f) - (supplyRisk * 0.35f), 0f, 1f), - ProductionDepth = Math.Clamp(producerItems.Count / 8f, 0f, 1f), - ConstructionPressure = Math.Clamp(world.ConstructionSites.Count(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) * 0.22f, 0f, 1f), - CorridorDependency = Math.Clamp(frontLineIds.Count * 0.18f, 0f, 1f), - UpdatedAtUtc = nowUtc, - }); - } - } - - BuildCorridors(world, state, nowUtc); - foreach (var profile in state.Territory.StrategicProfiles) - { - profile.EconomicRegionId = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; - } - } - - private static void BuildCorridors(SimulationWorld world, GeopoliticalStateRuntime state, DateTimeOffset nowUtc) - { - foreach (var route in state.Routes) - { - var sourceRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.SourceSystemId, StringComparer.Ordinal)); - var destinationRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.DestinationSystemId, StringComparer.Ordinal)); - if (sourceRegion is null && destinationRegion is null) - { - continue; - } - - var borderEdge = state.Territory.BorderEdges.FirstOrDefault(edge => - (edge.SourceSystemId == route.SourceSystemId && edge.DestinationSystemId == route.DestinationSystemId) - || (edge.SourceSystemId == route.DestinationSystemId && edge.DestinationSystemId == route.SourceSystemId)); - var risk = borderEdge?.TensionScore ?? 0f; - var corridor = new LogisticsCorridorRuntime - { - Id = $"corridor-{route.Id}", - FactionId = sourceRegion?.FactionId ?? destinationRegion?.FactionId, - Kind = borderEdge?.IsContested == true ? "frontier-corridor" : "supply-corridor", - Status = borderEdge?.IsContested == true ? "risky" : "active", - RiskScore = Math.Clamp(risk + ((sourceRegion is not null && destinationRegion is not null && sourceRegion.Id != destinationRegion.Id) ? 0.15f : 0f), 0f, 1f), - ThroughputScore = Math.Clamp(((sourceRegion?.StationIds.Count ?? 0) + (destinationRegion?.StationIds.Count ?? 0)) / 10f, 0f, 1f), - AccessState = ResolveCorridorAccessState(world, borderEdge, sourceRegion, destinationRegion), - UpdatedAtUtc = nowUtc, - }; - corridor.SystemPathIds.Add(route.SourceSystemId); - corridor.SystemPathIds.Add(route.DestinationSystemId); - if (sourceRegion is not null) - { - corridor.RegionIds.Add(sourceRegion.Id); - } - if (destinationRegion is not null && !corridor.RegionIds.Contains(destinationRegion.Id, StringComparer.Ordinal)) - { - corridor.RegionIds.Add(destinationRegion.Id); - } - if (borderEdge is not null) - { - corridor.BorderEdgeIds.Add(borderEdge.Id); - } - - state.EconomyRegions.Corridors.Add(corridor); - if (sourceRegion is not null && !sourceRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) - { - sourceRegion.CorridorIds.Add(corridor.Id); - } - if (destinationRegion is not null && !destinationRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) - { - destinationRegion.CorridorIds.Add(corridor.Id); - } - } - } - - private static string ResolveCorridorAccessState( - SimulationWorld world, - BorderEdgeRuntime? borderEdge, - EconomicRegionRuntime? sourceRegion, - EconomicRegionRuntime? destinationRegion) - { - if (sourceRegion?.FactionId is null || destinationRegion?.FactionId is null) - { - return borderEdge?.IsContested == true ? "restricted" : "open"; - } - - var relation = FindRelation(world, sourceRegion.FactionId, destinationRegion.FactionId); - if (relation is null) - { - return "restricted"; - } - - return relation.Posture switch - { - "war" => "denied", - "hostile" => "restricted", - _ => relation.TradeAccessPolicy, - }; - } - - private static FactionEconomySnapshot BuildRegionalEconomy(SimulationWorld world, string factionId, IReadOnlyCollection systemIds) - { - var snapshot = new FactionEconomySnapshot(); - foreach (var station in world.Stations.Where(station => station.FactionId == factionId && systemIds.Contains(station.SystemId, StringComparer.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); - 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 => order.FactionId == factionId)) - { - var relatedSystemId = world.Stations.FirstOrDefault(station => station.Id == order.StationId)?.SystemId - ?? world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId)?.SystemId; - if (relatedSystemId is null || !systemIds.Contains(relatedSystemId, StringComparer.Ordinal)) - { - continue; - } - - var commodity = snapshot.GetCommodity(order.ItemId); - if (order.Kind == MarketOrderKinds.Buy) - { - commodity.BuyBacklog += order.RemainingAmount; - } - else if (order.Kind == MarketOrderKinds.Sell) - { - commodity.SellBacklog += order.RemainingAmount; - } - } - - foreach (var site in world.ConstructionSites.Where(site => site.FactionId == factionId && systemIds.Contains(site.SystemId, StringComparer.Ordinal))) - { - foreach (var required in site.RequiredItems) - { - var remaining = MathF.Max(0f, required.Value - (site.DeliveredItems.TryGetValue(required.Key, out var delivered) ? delivered : 0f)); - if (remaining > 0.01f) - { - snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining; - } - } - } - - return snapshot; - } - - private static List> BuildConnectedComponents(IReadOnlyCollection systems, IReadOnlyCollection routes) - { - var remaining = systems.ToHashSet(StringComparer.Ordinal); - var adjacency = routes - .SelectMany(route => new[] - { (route.SourceSystemId, route.DestinationSystemId), (route.DestinationSystemId, route.SourceSystemId), - }) - .GroupBy(entry => entry.Item1, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.Select(entry => entry.Item2).ToList(), StringComparer.Ordinal); - var components = new List>(); + }) + .GroupBy(entry => entry.Item1, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Select(entry => entry.Item2).ToList(), StringComparer.Ordinal); + var components = new List>(); - while (remaining.Count > 0) - { - var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First(); - var frontier = new Queue(); - frontier.Enqueue(start); - remaining.Remove(start); - var component = new List(); - - while (frontier.Count > 0) - { - var current = frontier.Dequeue(); - component.Add(current); - foreach (var neighbor in adjacency.GetValueOrDefault(current, [])) + while (remaining.Count > 0) { - if (remaining.Remove(neighbor)) - { - frontier.Enqueue(neighbor); - } + var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First(); + var frontier = new Queue(); + frontier.Enqueue(start); + remaining.Remove(start); + var component = new List(); + + while (frontier.Count > 0) + { + var current = frontier.Dequeue(); + component.Add(current); + foreach (var neighbor in adjacency.GetValueOrDefault(current, [])) + { + if (remaining.Remove(neighbor)) + { + frontier.Enqueue(neighbor); + } + } + } + + components.Add(component); } - } - components.Add(component); + return components; } - return components; - } - - private static string ResolveRegionKind(IReadOnlyCollection stations, FactionEconomySnapshot economy) - { - if (stations.Any(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal))) + private static string ResolveRegionKind(IReadOnlyCollection stations, FactionEconomySnapshot economy) { - return "shipbuilding-region"; + if (stations.Any(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal))) + { + return "shipbuilding-region"; + } + + if (stations.Count(station => StationSimulationService.DetermineStationRole(station) == "refinery") >= 2) + { + return "industrial-core"; + } + + if (economy.Commodities.Any(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low)) + { + return "frontier-sustainment"; + } + + return stations.Count <= 2 ? "extraction-region" : "balanced-region"; } - if (stations.Count(station => StationSimulationService.DetermineStationRole(station) == "refinery") >= 2) + private static float EstimateSystemStrategicValue(SimulationWorld world, string systemId) { - return "industrial-core"; + var stationValue = world.Stations.Count(station => station.SystemId == systemId) * 30f; + var constructionValue = world.ConstructionSites.Count(site => site.SystemId == systemId && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) * 18f; + var nodeValue = world.Nodes.Count(node => node.SystemId == systemId) * 8f; + return stationValue + constructionValue + nodeValue; } - if (economy.Commodities.Any(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low)) + private static string BuildRelationId(string factionAId, string factionBId) => + BuildPairId("relation", factionAId, factionBId); + + private static string BuildPairId(string prefix, string leftId, string rightId) { - return "frontier-sustainment"; + return string.Compare(leftId, rightId, StringComparison.Ordinal) <= 0 + ? $"{prefix}-{leftId}-{rightId}" + : $"{prefix}-{rightId}-{leftId}"; } - - return stations.Count <= 2 ? "extraction-region" : "balanced-region"; - } - - private static float EstimateSystemStrategicValue(SimulationWorld world, string systemId) - { - var stationValue = world.Stations.Count(station => station.SystemId == systemId) * 30f; - var constructionValue = world.ConstructionSites.Count(site => site.SystemId == systemId && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) * 18f; - var nodeValue = world.Nodes.Count(node => node.SystemId == systemId) * 8f; - return stationValue + constructionValue + nodeValue; - } - - private static string BuildRelationId(string factionAId, string factionBId) => - BuildPairId("relation", factionAId, factionBId); - - private static string BuildPairId(string prefix, string leftId, string rightId) - { - return string.Compare(leftId, rightId, StringComparison.Ordinal) <= 0 - ? $"{prefix}-{leftId}-{rightId}" - : $"{prefix}-{rightId}-{leftId}"; - } } diff --git a/apps/backend/Industry/Planning/CommodityOperationalSignal.cs b/apps/backend/Industry/Planning/CommodityOperationalSignal.cs index b0c0f7d..6535269 100644 --- a/apps/backend/Industry/Planning/CommodityOperationalSignal.cs +++ b/apps/backend/Industry/Planning/CommodityOperationalSignal.cs @@ -2,53 +2,53 @@ namespace SpaceGame.Api.Industry.Planning; internal static class CommodityOperationalSignal { - internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds) - { - var productionDeficit = MathF.Max(0f, commodity.ConsumptionRatePerSecond - commodity.ProjectedProductionRatePerSecond); - var levelDeficit = MathF.Max(0f, targetLevelSeconds - commodity.LevelSeconds) / MathF.Max(targetLevelSeconds, 1f); - var backlogPressure = MathF.Max(0f, commodity.BuyBacklog + commodity.ReservedForConstruction - commodity.AvailableStock); - - var levelWeight = commodity.Level switch + internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds) { - CommodityLevelKind.Critical => 140f, - CommodityLevelKind.Low => 80f, - CommodityLevelKind.Stable => 20f, - _ => 0f, - }; + var productionDeficit = MathF.Max(0f, commodity.ConsumptionRatePerSecond - commodity.ProjectedProductionRatePerSecond); + var levelDeficit = MathF.Max(0f, targetLevelSeconds - commodity.LevelSeconds) / MathF.Max(targetLevelSeconds, 1f); + var backlogPressure = MathF.Max(0f, commodity.BuyBacklog + commodity.ReservedForConstruction - commodity.AvailableStock); - return levelWeight - + (productionDeficit * 140f) - + (levelDeficit * 120f) - + backlogPressure; - } + var levelWeight = commodity.Level switch + { + CommodityLevelKind.Critical => 140f, + CommodityLevelKind.Low => 80f, + CommodityLevelKind.Stable => 20f, + _ => 0f, + }; - internal static bool IsOperational(FactionCommoditySnapshot commodity, float targetLevelSeconds) => - commodity.ProjectedProductionRatePerSecond > 0.01f - && commodity.ProjectedNetRatePerSecond >= -0.01f - && commodity.LevelSeconds >= targetLevelSeconds - && commodity.Level is CommodityLevelKind.Stable or CommodityLevelKind.Surplus; - - internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) => - !IsOperational(commodity, targetLevelSeconds) - || commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low; - - internal static float ComputeFeasibilityFactor(FactionCommoditySnapshot commodity, float targetLevelSeconds) - { - if (commodity.AvailableStock <= 0.01f && commodity.ProjectedProductionRatePerSecond <= 0.01f) - { - return 0.65f; + return levelWeight + + (productionDeficit * 140f) + + (levelDeficit * 120f) + + backlogPressure; } - if (commodity.Level is CommodityLevelKind.Critical) - { - return 0.72f; - } + internal static bool IsOperational(FactionCommoditySnapshot commodity, float targetLevelSeconds) => + commodity.ProjectedProductionRatePerSecond > 0.01f + && commodity.ProjectedNetRatePerSecond >= -0.01f + && commodity.LevelSeconds >= targetLevelSeconds + && commodity.Level is CommodityLevelKind.Stable or CommodityLevelKind.Surplus; - if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds) - { - return 0.84f; - } + internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) => + !IsOperational(commodity, targetLevelSeconds) + || commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low; - return 1f; - } + internal static float ComputeFeasibilityFactor(FactionCommoditySnapshot commodity, float targetLevelSeconds) + { + if (commodity.AvailableStock <= 0.01f && commodity.ProjectedProductionRatePerSecond <= 0.01f) + { + return 0.65f; + } + + if (commodity.Level is CommodityLevelKind.Critical) + { + return 0.72f; + } + + if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds) + { + return 0.84f; + } + + return 1f; + } } diff --git a/apps/backend/Industry/Planning/FactionEconomySnapshot.cs b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs index e84822e..c65e28c 100644 --- a/apps/backend/Industry/Planning/FactionEconomySnapshot.cs +++ b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs @@ -4,202 +4,202 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; internal sealed class FactionEconomySnapshot { - private readonly Dictionary commodities = new(StringComparer.Ordinal); + private readonly Dictionary commodities = new(StringComparer.Ordinal); - internal IReadOnlyDictionary Commodities => commodities; + internal IReadOnlyDictionary Commodities => commodities; - internal FactionCommoditySnapshot GetCommodity(string itemId) - { - if (!commodities.TryGetValue(itemId, out var commodity)) + internal FactionCommoditySnapshot GetCommodity(string itemId) { - commodity = new FactionCommoditySnapshot(itemId); - commodities[itemId] = commodity; - } + if (!commodities.TryGetValue(itemId, out var commodity)) + { + commodity = new FactionCommoditySnapshot(itemId); + commodities[itemId] = commodity; + } - return commodity; - } + return commodity; + } } internal sealed class FactionCommoditySnapshot { - internal FactionCommoditySnapshot(string itemId) - { - ItemId = itemId; - } + 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 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 OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f); - internal float LevelSeconds => AvailableStock <= 0.01f - ? 0f - : AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f); + 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 OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f); + internal float LevelSeconds => AvailableStock <= 0.01f + ? 0f + : AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f); - internal CommodityLevelKind Level => - LevelSeconds switch - { - <= 60f => CommodityLevelKind.Critical, - <= 180f => CommodityLevelKind.Low, - <= 480f => CommodityLevelKind.Stable, - _ => CommodityLevelKind.Surplus, - }; + internal CommodityLevelKind Level => + LevelSeconds switch + { + <= 60f => CommodityLevelKind.Critical, + <= 180f => CommodityLevelKind.Low, + <= 480f => CommodityLevelKind.Stable, + _ => CommodityLevelKind.Surplus, + }; } internal enum CommodityLevelKind { - Critical, - Low, - Stable, - Surplus, + Critical, + Low, + Stable, + Surplus, } 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))) + internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId) { - foreach (var (itemId, amount) in station.Inventory) - { - snapshot.GetCommodity(itemId).OnHand += amount; - } + var snapshot = new FactionEconomySnapshot(); - foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station)) - { - var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey); - if (recipe is null) + foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))) { - continue; + 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; + } + } } - var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe); - var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f); - if (cyclesPerSecond <= 0.0001f) + foreach (var order in world.MarketOrders.Where(order => + string.Equals(order.FactionId, factionId, StringComparison.Ordinal) + && order.State != MarketOrderStateKinds.Cancelled + && order.RemainingAmount > 0.01f)) { - continue; + 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 input in recipe.Inputs) + 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)) { - snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond; + 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; + } + } } - foreach (var output in recipe.Outputs) + 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)) { - snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond; + return; } - } - } - 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) + 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) { - snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining; + 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; } - } } - - 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; - } - } } diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs index 78993d9..9b11db9 100644 --- a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -4,581 +4,581 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; internal static class FactionIndustryPlanner { - private const float CommodityTargetLevelSeconds = 240f; - private const float WaterTargetLevelSeconds = 300f; + private const float CommodityTargetLevelSeconds = 240f; + private const float WaterTargetLevelSeconds = 300f; - internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId, bool ignoreActiveExpansionProject = false) - { - if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) + internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId, bool ignoreActiveExpansionProject = false) { - return null; + if (!ignoreActiveExpansionProject && 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); } - var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId); - var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity); - if (moduleId is null) + internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { - return null; + if (!ignoreActiveExpansionProject && 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, + Commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId), + }) + .Where(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f + || CommodityOperationalSignal.IsStrained(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) + .OrderByDescending(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f ? 1 : 0) + .ThenByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) + .ThenBy(entry => entry.Commodity.AvailableStock) + .Select(entry => entry.ItemId) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) + { + return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity, ignoreActiveExpansionProject); + } + + return CreateShipyardFoundationProject(world, factionId, ignoreActiveExpansionProject); } - var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity); - if (targetCelestial is null) + internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) { - return null; + const string shipyardModuleId = "module_gen_build_l_01"; + if (!ignoreActiveExpansionProject && 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); } - var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); - if (supportStation is null) + internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId) { - return null; + 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); } - return new IndustryExpansionProject( - bottleneckCommodity, - moduleId, - targetCelestial.SystemId, - targetCelestial.Id, - supportStation.Id); - } - - internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) - { - if (!ignoreActiveExpansionProject && HasActiveExpansionProject(world, factionId)) + internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId) { - return null; + 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); } - 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))) + internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) { - return null; + if (project.SiteId is not null) + { + return; + } + + if (!CanEstablishExpansionSite(world, factionId, project)) + { + 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); } - if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe)) + private static string? SelectCommodityToExpand(SimulationWorld world, string factionId) { - return null; + 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); + + var threatAssessment = CommanderPlanningService.FindFactionThreatAssessment(world, factionId); + if (threatAssessment is not null && threatAssessment.EnemyFactionCount > 0) + { + demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; + demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; + } + + return demandByItem + .Select(entry => + { + var itemId = ResolveBottleneckCommodity(world, factionId, entry.Key); + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); + var score = entry.Value + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); + return (ItemId: itemId, Score: score); + }) + .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(); } - var bottleneckCommodity = shipyardRecipe.Inputs - .Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId)) - .Distinct(StringComparer.Ordinal) - .Select(itemId => new - { - ItemId = itemId, - Commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId), - }) - .Where(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f - || CommodityOperationalSignal.IsStrained(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) - .OrderByDescending(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f ? 1 : 0) - .ThenByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) - .ThenBy(entry => entry.Commodity.AvailableStock) - .Select(entry => entry.ItemId) - .FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) + private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId) { - return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity, ignoreActiveExpansionProject); - } + if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals")) + { + return "refinedmetals"; + } - return CreateShipyardFoundationProject(world, factionId, ignoreActiveExpansionProject); - } - - internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId, bool ignoreActiveExpansionProject = false) - { - const string shipyardModuleId = "module_gen_build_l_01"; - if (!ignoreActiveExpansionProject && 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) + private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId) { - return null; + var visited = new HashSet(StringComparer.Ordinal); + return ResolveBottleneckCommodity(world, factionId, itemId, visited); } - var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId); - if (moduleId is null) + private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet visited) { - return null; - } + if (!visited.Add(itemId)) + { + return itemId; + } - var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId); - if (targetCelestial is null) - { - return null; - } + var producers = world.ProductionGraph.GetProcessesForOutput(itemId); + if (producers.Count == 0) + { + return itemId; + } - 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; - } - - if (!CanEstablishExpansionSite(world, factionId, project)) - { - 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); - - var threatAssessment = CommanderPlanningService.FindFactionThreatAssessment(world, factionId); - if (threatAssessment is not null && threatAssessment.EnemyFactionCount > 0) - { - demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; - demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; - } - - return demandByItem - .Select(entry => - { - var itemId = ResolveBottleneckCommodity(world, factionId, entry.Key); - var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); - var score = entry.Value + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); - return (ItemId: itemId, Score: score); - }) - .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(StringComparer.Ordinal); - return ResolveBottleneckCommodity(world, factionId, itemId, visited); - } - - private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet 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 => - { - var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); - return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)), Stockpile: commodity.AvailableStock); - }) - .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 => - { - var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); - return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId))); - }) - .OrderByDescending(entry => entry.Score) - .FirstOrDefault(); - - return weakestInput.Score > GetCommodityNeedScore(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 ResolveRootResourceItems(SimulationWorld world, string commodityId) - { - var frontier = new Queue(); - var resources = new HashSet(StringComparer.Ordinal); - var visited = new HashSet(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 GetCommodityNeedScore(SimulationWorld world, string factionId, string itemId) - { - var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); - return CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); - } - - private static float GetTargetLevelSeconds(string itemId) => - string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; - - 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) - && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) - .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) - && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) - .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 => + var hasFactionProducer = producers + .SelectMany(process => process.RequiredModuleIds) + .Any(moduleId => world.Stations.Any(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)) - .Sum(station => station.Inventory.Values.Sum())) - .FirstOrDefault(); - } + && station.InstalledModules.Contains(moduleId, StringComparer.Ordinal))); + if (!hasFactionProducer) + { + return itemId; + } - private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection 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)); - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId); - var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId); - var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId); - var pressure = world.Geopolitics?.Territory.Pressures - .Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId) - .OrderByDescending(entry => entry.HostileInfluence) - .ThenBy(entry => entry.Id, StringComparer.Ordinal) - .FirstOrDefault(); - var controlBias = string.Equals(controlState?.ControllerFactionId, factionId, StringComparison.Ordinal) - ? 12_000f - : string.Equals(controlState?.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) - ? 4_000f - : 0f; - var regionBias = region is null - ? 0f - : region.Kind switch - { - "core-industry" => 4_500f, - "shipbuilding" => 3_250f, - "trade-hub" => 2_250f, - "corridor" => 1_500f, - _ => 1_000f, - }; - var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) - + ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) - + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f); - return resourceScore - + (factionPresence * 5_000f) - + controlBias - + regionBias - - securityPenalty; - } + var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId) + .Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId)) + .Select(inputId => + { + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); + return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)), Stockpile: commodity.AvailableStock); + }) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Stockpile) + .FirstOrDefault(); - private static bool IsExpansionSystemEligible(SimulationWorld world, string factionId, string systemId) - { - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); - if (controlState is null) - { - return true; + 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 => + { + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); + return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId))); + }) + .OrderByDescending(entry => entry.Score) + .FirstOrDefault(); + + return weakestInput.Score > GetCommodityNeedScore(world, factionId, itemId) * 0.6f + ? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited) + : itemId; } - var authorityFactionId = controlState.ControllerFactionId ?? controlState.PrimaryClaimantFactionId; - if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId) + => FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f; + + internal static IReadOnlyCollection ResolveRootResourceItems(SimulationWorld world, string commodityId) { - return true; + var frontier = new Queue(); + var resources = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(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]; } - if (!GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId)) + 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 GetCommodityNeedScore(SimulationWorld world, string factionId, string itemId) { - return false; + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); + return CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); } - return !GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId); - } + private static float GetTargetLevelSeconds(string itemId) => + string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; - private static bool CanEstablishExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) - { - if (!IsExpansionSystemEligible(world, factionId, project.SystemId)) + private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId) { - return false; + 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) + && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) + .OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) + .FirstOrDefault(); } - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, project.SystemId); - if (controlState?.IsContested == true) + private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId) { - return false; + 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) + && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) + .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(); } - var pressure = world.Geopolitics?.Territory.Pressures - .Where(entry => entry.SystemId == project.SystemId && entry.FactionId == factionId) - .OrderByDescending(entry => entry.CorridorRisk + entry.HostileInfluence) - .ThenBy(entry => entry.Id, StringComparer.Ordinal) - .FirstOrDefault(); - return pressure is null || (pressure.CorridorRisk < 0.8f && pressure.HostileInfluence < 1.2f); - } + private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection 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)); + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId); + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId); + var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId); + var pressure = world.Geopolitics?.Territory.Pressures + .Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId) + .OrderByDescending(entry => entry.HostileInfluence) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + var controlBias = string.Equals(controlState?.ControllerFactionId, factionId, StringComparison.Ordinal) + ? 12_000f + : string.Equals(controlState?.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) + ? 4_000f + : 0f; + var regionBias = region is null + ? 0f + : region.Kind switch + { + "core-industry" => 4_500f, + "shipbuilding" => 3_250f, + "trade-hub" => 2_250f, + "corridor" => 1_500f, + _ => 1_000f, + }; + var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) + + ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) + + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f); + return resourceScore + + (factionPresence * 5_000f) + + controlBias + + regionBias + - securityPenalty; + } - 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() - : []; + private static bool IsExpansionSystemEligible(SimulationWorld world, string factionId, string systemId) + { + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); + if (controlState is null) + { + return true; + } - 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(); - } + var authorityFactionId = controlState.ControllerFactionId ?? controlState.PrimaryClaimantFactionId; + if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + { + return true; + } + + if (!GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId)) + { + return false; + } + + return !GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId); + } + + private static bool CanEstablishExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) + { + if (!IsExpansionSystemEligible(world, factionId, project.SystemId)) + { + return false; + } + + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, project.SystemId); + if (controlState?.IsContested == true) + { + return false; + } + + var pressure = world.Geopolitics?.Territory.Pressures + .Where(entry => entry.SystemId == project.SystemId && entry.FactionId == factionId) + .OrderByDescending(entry => entry.CorridorRisk + entry.HostileInfluence) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return pressure is null || (pressure.CorridorRisk < 0.8f && pressure.HostileInfluence < 1.2f); + } + + 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( diff --git a/apps/backend/Industry/Planning/ProductionGraph.cs b/apps/backend/Industry/Planning/ProductionGraph.cs index adb352e..e9b6a39 100644 --- a/apps/backend/Industry/Planning/ProductionGraph.cs +++ b/apps/backend/Industry/Planning/ProductionGraph.cs @@ -2,52 +2,52 @@ namespace SpaceGame.Api.Industry.Planning; public sealed class ProductionGraph { - public required IReadOnlyDictionary Commodities { get; init; } - public required IReadOnlyDictionary Processes { get; init; } - public required IReadOnlyDictionary> ProcessesByOutputId { get; init; } - public required IReadOnlyDictionary> ProcessesByInputId { get; init; } - public required IReadOnlyDictionary> OutputsByModuleId { get; init; } + public required IReadOnlyDictionary Commodities { get; init; } + public required IReadOnlyDictionary Processes { get; init; } + public required IReadOnlyDictionary> ProcessesByOutputId { get; init; } + public required IReadOnlyDictionary> ProcessesByInputId { get; init; } + public required IReadOnlyDictionary> OutputsByModuleId { get; init; } - public IReadOnlyList GetProcessesForOutput(string itemId) => - ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : []; + public IReadOnlyList GetProcessesForOutput(string itemId) => + ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : []; - public IReadOnlyList GetProcessesForInput(string itemId) => - ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : []; + public IReadOnlyList GetProcessesForInput(string itemId) => + ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : []; - public string? GetPrimaryProducerModule(string itemId) => - GetProcessesForOutput(itemId) - .SelectMany(process => process.RequiredModuleIds) - .FirstOrDefault(); + 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 string? GetPrimaryOutputForModule(string moduleId) => + OutputsByModuleId.TryGetValue(moduleId, out var outputs) + ? outputs.FirstOrDefault() + : null; - public IReadOnlyList GetImmediateInputs(string itemId) => - GetProcessesForOutput(itemId) - .SelectMany(process => process.Inputs.Keys) - .Distinct(StringComparer.Ordinal) - .ToList(); + public IReadOnlyList 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 ProducerProcessIds { get; } = []; - public List ConsumerProcessIds { get; } = []; + 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 ProducerProcessIds { get; } = []; + public List 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 RequiredModuleIds { get; init; } - public required IReadOnlyDictionary Inputs { get; init; } - public required IReadOnlyDictionary Outputs { get; init; } - public required bool ProducesShip { get; init; } + public required string Id { get; init; } + public required string Label { get; init; } + public required string FacilityCategory { get; init; } + public required IReadOnlyList RequiredModuleIds { get; init; } + public required IReadOnlyDictionary Inputs { get; init; } + public required IReadOnlyDictionary Outputs { get; init; } + public required bool ProducesShip { get; init; } } diff --git a/apps/backend/Industry/Planning/ProductionGraphBuilder.cs b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs index 4aecbbc..b50d207 100644 --- a/apps/backend/Industry/Planning/ProductionGraphBuilder.cs +++ b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs @@ -2,104 +2,104 @@ namespace SpaceGame.Api.Industry.Planning; internal static class ProductionGraphBuilder { - internal static ProductionGraph Build( - IReadOnlyCollection items, - IReadOnlyCollection recipes, - IReadOnlyCollection 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(StringComparer.Ordinal); - var processesByOutputId = new Dictionary>(StringComparer.Ordinal); - var processesByInputId = new Dictionary>(StringComparer.Ordinal); - var outputsByModuleId = new Dictionary>(StringComparer.Ordinal); - - foreach (var recipe in recipes) + internal static ProductionGraph Build( + IReadOnlyCollection items, + IReadOnlyCollection recipes, + IReadOnlyCollection modules) { - 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, - }; + var commodities = items.ToDictionary( + item => item.Id, + item => new ProductionCommodityNode + { + ItemId = item.Id, + Name = item.Name, + Group = item.Group, + CargoKind = item.CargoKind, + }, + StringComparer.Ordinal); - processes[process.Id] = process; + var processes = new Dictionary(StringComparer.Ordinal); + var processesByOutputId = new Dictionary>(StringComparer.Ordinal); + var processesByInputId = new Dictionary>(StringComparer.Ordinal); + var outputsByModuleId = new Dictionary>(StringComparer.Ordinal); - foreach (var output in outputs.Keys) - { - if (!commodities.ContainsKey(output)) + foreach (var recipe in recipes) { - continue; + 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); + } } - commodities[output].ProducerProcessIds.Add(process.Id); - if (!processesByOutputId.TryGetValue(output, out var outputProcesses)) + foreach (var module in modules) { - outputProcesses = []; - processesByOutputId[output] = outputProcesses; + if (!outputsByModuleId.TryGetValue(module.Id, out var outputs)) + { + outputs = new HashSet(StringComparer.Ordinal); + outputsByModuleId[module.Id] = outputs; + } + + foreach (var product in module.Products) + { + outputs.Add(product); + } } - outputProcesses.Add(process); - } - - foreach (var input in inputs.Keys) - { - if (!commodities.ContainsKey(input)) + return new ProductionGraph { - continue; - } - - commodities[input].ConsumerProcessIds.Add(process.Id); - if (!processesByInputId.TryGetValue(input, out var inputProcesses)) - { - inputProcesses = []; - processesByInputId[input] = inputProcesses; - } - - inputProcesses.Add(process); - } + Commodities = commodities, + Processes = processes, + ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value, StringComparer.Ordinal), + ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value, StringComparer.Ordinal), + OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal), + }; } - - foreach (var module in modules) - { - if (!outputsByModuleId.TryGetValue(module.Id, out var outputs)) - { - outputs = new HashSet(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)entry.Value, StringComparer.Ordinal), - ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value, StringComparer.Ordinal), - OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal), - }; - } } diff --git a/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs index 5193053..043dc09 100644 --- a/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs +++ b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs @@ -4,29 +4,29 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class CreatePlayerOrganizationHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/organizations"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken) - { - try + public override void Configure() { - var snapshot = worldService.CreatePlayerOrganization(request); - if (snapshot is null) - { - await SendNotFoundAsync(cancellationToken); - return; - } + Post("/api/player-faction/organizations"); + AllowAnonymous(); + } - await SendOkAsync(snapshot, cancellationToken); - } - catch (InvalidOperationException ex) + public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken) { - AddError(ex.Message); - await SendErrorsAsync(cancellation: cancellationToken); + try + { + var snapshot = worldService.CreatePlayerOrganization(request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } } - } } diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs index 072f0b3..fe3626c 100644 --- a/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs +++ b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs @@ -4,26 +4,26 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class DeletePlayerDirectiveRequest { - public string DirectiveId { get; set; } = string.Empty; + public string DirectiveId { get; set; } = string.Empty; } public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Delete("/api/player-faction/directives/{directiveId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken) - { - var snapshot = worldService.DeletePlayerDirective(request.DirectiveId); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Delete("/api/player-faction/directives/{directiveId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.DeletePlayerDirective(request.DirectiveId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs index 9b8cba5..d581c20 100644 --- a/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs +++ b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs @@ -4,34 +4,34 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class DeletePlayerOrganizationRequest { - public string OrganizationId { get; set; } = string.Empty; + public string OrganizationId { get; set; } = string.Empty; } public sealed class DeletePlayerOrganizationHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Delete("/api/player-faction/organizations/{organizationId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken) - { - try + public override void Configure() { - var snapshot = worldService.DeletePlayerOrganization(request.OrganizationId); - if (snapshot is null) - { - await SendNotFoundAsync(cancellationToken); - return; - } + Delete("/api/player-faction/organizations/{organizationId}"); + AllowAnonymous(); + } - await SendOkAsync(snapshot, cancellationToken); - } - catch (InvalidOperationException ex) + public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken) { - AddError(ex.Message); - await SendErrorsAsync(cancellation: cancellationToken); + try + { + var snapshot = worldService.DeletePlayerOrganization(request.OrganizationId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } } - } } diff --git a/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs index 9f3474e..1bbc8be 100644 --- a/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs +++ b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs @@ -4,21 +4,21 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class GetPlayerFactionHandler(WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Get("/api/player-faction"); - AllowAnonymous(); - } - - public override async Task HandleAsync(CancellationToken cancellationToken) - { - var snapshot = worldService.GetPlayerFaction(); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Get("/api/player-faction"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var snapshot = worldService.GetPlayerFaction(); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs index 89ba4ef..29aa0b6 100644 --- a/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs @@ -4,37 +4,37 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Put("/api/player-faction/organizations/{organizationId}/membership"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken) - { - try + public override void Configure() { - var organizationId = Route("organizationId"); - if (string.IsNullOrWhiteSpace(organizationId)) - { - AddError("organizationId route parameter is required."); - await SendErrorsAsync(cancellation: cancellationToken); - return; - } - - var snapshot = worldService.UpdatePlayerOrganizationMembership(organizationId, request); - if (snapshot is null) - { - await SendNotFoundAsync(cancellationToken); - return; - } - - await SendOkAsync(snapshot, cancellationToken); + Put("/api/player-faction/organizations/{organizationId}/membership"); + AllowAnonymous(); } - catch (InvalidOperationException ex) + + public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken) { - AddError(ex.Message); - await SendErrorsAsync(cancellation: cancellationToken); + try + { + var organizationId = Route("organizationId"); + if (string.IsNullOrWhiteSpace(organizationId)) + { + AddError("organizationId route parameter is required."); + await SendErrorsAsync(cancellation: cancellationToken); + return; + } + + var snapshot = worldService.UpdatePlayerOrganizationMembership(organizationId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } } - } } diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs index 4f14538..05f509f 100644 --- a/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs @@ -4,21 +4,21 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Put("/api/player-faction/strategic-intent"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken) - { - var snapshot = worldService.UpdatePlayerStrategicIntent(request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Put("/api/player-faction/strategic-intent"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.UpdatePlayerStrategicIntent(request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs index a46132c..0896571 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs @@ -4,28 +4,28 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Put("/api/player-faction/assets/{assetId}/assignment"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken) - { - var assetId = Route("assetId"); - if (string.IsNullOrWhiteSpace(assetId)) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Put("/api/player-faction/assets/{assetId}/assignment"); + AllowAnonymous(); } - var snapshot = worldService.UpsertPlayerAssignment(assetId, request); - if (snapshot is null) + public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken) { - await SendNotFoundAsync(cancellationToken); - return; - } + var assetId = Route("assetId"); + if (string.IsNullOrWhiteSpace(assetId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } - await SendOkAsync(snapshot, cancellationToken); - } + var snapshot = worldService.UpsertPlayerAssignment(assetId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs index 53e3d47..b0759f4 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs @@ -4,23 +4,23 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/automation-policies"); - Put("/api/player-faction/automation-policies/{automationPolicyId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken) - { - var automationPolicyId = Route("automationPolicyId"); - var snapshot = worldService.UpsertPlayerAutomationPolicy(string.IsNullOrWhiteSpace(automationPolicyId) ? null : automationPolicyId, request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/player-faction/automation-policies"); + Put("/api/player-faction/automation-policies/{automationPolicyId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken) + { + var automationPolicyId = Route("automationPolicyId"); + var snapshot = worldService.UpsertPlayerAutomationPolicy(string.IsNullOrWhiteSpace(automationPolicyId) ? null : automationPolicyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs index 2d62106..d622c83 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs @@ -4,23 +4,23 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/directives"); - Put("/api/player-faction/directives/{directiveId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken) - { - var directiveId = Route("directiveId"); - var snapshot = worldService.UpsertPlayerDirective(string.IsNullOrWhiteSpace(directiveId) ? null : directiveId, request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/player-faction/directives"); + Put("/api/player-faction/directives/{directiveId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken) + { + var directiveId = Route("directiveId"); + var snapshot = worldService.UpsertPlayerDirective(string.IsNullOrWhiteSpace(directiveId) ? null : directiveId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs index 64eb361..f189aa7 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs @@ -4,23 +4,23 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/policies"); - Put("/api/player-faction/policies/{policyId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken) - { - var policyId = Route("policyId"); - var snapshot = worldService.UpsertPlayerPolicy(string.IsNullOrWhiteSpace(policyId) ? null : policyId, request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/player-faction/policies"); + Put("/api/player-faction/policies/{policyId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken) + { + var policyId = Route("policyId"); + var snapshot = worldService.UpsertPlayerPolicy(string.IsNullOrWhiteSpace(policyId) ? null : policyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs index 0972141..8d5fb36 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs @@ -4,23 +4,23 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerProductionProgramHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/production-programs"); - Put("/api/player-faction/production-programs/{productionProgramId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken) - { - var productionProgramId = Route("productionProgramId"); - var snapshot = worldService.UpsertPlayerProductionProgram(string.IsNullOrWhiteSpace(productionProgramId) ? null : productionProgramId, request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/player-faction/production-programs"); + Put("/api/player-faction/production-programs/{productionProgramId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken) + { + var productionProgramId = Route("productionProgramId"); + var snapshot = worldService.UpsertPlayerProductionProgram(string.IsNullOrWhiteSpace(productionProgramId) ? null : productionProgramId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs index 7c40afe..bc693fe 100644 --- a/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs @@ -4,23 +4,23 @@ namespace SpaceGame.Api.PlayerFaction.Api; public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/player-faction/reinforcement-policies"); - Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken) - { - var reinforcementPolicyId = Route("reinforcementPolicyId"); - var snapshot = worldService.UpsertPlayerReinforcementPolicy(string.IsNullOrWhiteSpace(reinforcementPolicyId) ? null : reinforcementPolicyId, request); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/player-faction/reinforcement-policies"); + Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken) + { + var reinforcementPolicyId = Route("reinforcementPolicyId"); + var snapshot = worldService.UpsertPlayerReinforcementPolicy(string.IsNullOrWhiteSpace(reinforcementPolicyId) ? null : reinforcementPolicyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs index b6954db..890e270 100644 --- a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs +++ b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs @@ -2,305 +2,305 @@ namespace SpaceGame.Api.PlayerFaction.Runtime; public sealed class PlayerFactionRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public required string SovereignFactionId { get; set; } - public string Status { get; set; } = "active"; - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public PlayerAssetRegistryRuntime AssetRegistry { get; set; } = new(); - public PlayerStrategicIntentRuntime StrategicIntent { get; set; } = new(); - public List Fleets { get; } = []; - public List TaskForces { get; } = []; - public List StationGroups { get; } = []; - public List EconomicRegions { get; } = []; - public List Fronts { get; } = []; - public List Reserves { get; } = []; - public List Policies { get; } = []; - public List AutomationPolicies { get; } = []; - public List ReinforcementPolicies { get; } = []; - public List ProductionPrograms { get; } = []; - public List Directives { get; } = []; - public List Assignments { get; } = []; - public List DecisionLog { get; } = []; - public List Alerts { get; } = []; - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string Label { get; set; } + public required string SovereignFactionId { get; set; } + public string Status { get; set; } = "active"; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public PlayerAssetRegistryRuntime AssetRegistry { get; set; } = new(); + public PlayerStrategicIntentRuntime StrategicIntent { get; set; } = new(); + public List Fleets { get; } = []; + public List TaskForces { get; } = []; + public List StationGroups { get; } = []; + public List EconomicRegions { get; } = []; + public List Fronts { get; } = []; + public List Reserves { get; } = []; + public List Policies { get; } = []; + public List AutomationPolicies { get; } = []; + public List ReinforcementPolicies { get; } = []; + public List ProductionPrograms { get; } = []; + public List Directives { get; } = []; + public List Assignments { get; } = []; + public List DecisionLog { get; } = []; + public List Alerts { get; } = []; + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class PlayerAssetRegistryRuntime { - public HashSet ShipIds { get; } = new(StringComparer.Ordinal); - public HashSet StationIds { get; } = new(StringComparer.Ordinal); - public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); - public HashSet ClaimIds { get; } = new(StringComparer.Ordinal); - public HashSet ConstructionSiteIds { get; } = new(StringComparer.Ordinal); - public HashSet PolicySetIds { get; } = new(StringComparer.Ordinal); - public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); - public HashSet FleetIds { get; } = new(StringComparer.Ordinal); - public HashSet TaskForceIds { get; } = new(StringComparer.Ordinal); - public HashSet StationGroupIds { get; } = new(StringComparer.Ordinal); - public HashSet EconomicRegionIds { get; } = new(StringComparer.Ordinal); - public HashSet FrontIds { get; } = new(StringComparer.Ordinal); - public HashSet ReserveIds { get; } = new(StringComparer.Ordinal); + public HashSet ShipIds { get; } = new(StringComparer.Ordinal); + public HashSet StationIds { get; } = new(StringComparer.Ordinal); + public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); + public HashSet ClaimIds { get; } = new(StringComparer.Ordinal); + public HashSet ConstructionSiteIds { get; } = new(StringComparer.Ordinal); + public HashSet PolicySetIds { get; } = new(StringComparer.Ordinal); + public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); + public HashSet FleetIds { get; } = new(StringComparer.Ordinal); + public HashSet TaskForceIds { get; } = new(StringComparer.Ordinal); + public HashSet StationGroupIds { get; } = new(StringComparer.Ordinal); + public HashSet EconomicRegionIds { get; } = new(StringComparer.Ordinal); + public HashSet FrontIds { get; } = new(StringComparer.Ordinal); + public HashSet ReserveIds { get; } = new(StringComparer.Ordinal); } public sealed class PlayerStrategicIntentRuntime { - public string StrategicPosture { get; set; } = "balanced"; - public string EconomicPosture { get; set; } = "delegated"; - public string MilitaryPosture { get; set; } = "layered-defense"; - public string LogisticsPosture { get; set; } = "stable"; - public float DesiredReserveRatio { get; set; } = 0.2f; - public bool AllowDelegatedCombatAutomation { get; set; } = true; - public bool AllowDelegatedEconomicAutomation { get; set; } = true; - public string? Notes { get; set; } + public string StrategicPosture { get; set; } = "balanced"; + public string EconomicPosture { get; set; } = "delegated"; + public string MilitaryPosture { get; set; } = "layered-defense"; + public string LogisticsPosture { get; set; } = "stable"; + public float DesiredReserveRatio { get; set; } = 0.2f; + public bool AllowDelegatedCombatAutomation { get; set; } = true; + public bool AllowDelegatedEconomicAutomation { get; set; } = true; + public string? Notes { get; set; } } public sealed class PlayerFleetRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Role { get; set; } = "general-purpose"; - public string? CommanderId { get; set; } - public string? FrontId { get; set; } - public string? HomeSystemId { get; set; } - public string? HomeStationId { get; set; } - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public string? ReinforcementPolicyId { get; set; } - public List AssetIds { get; } = []; - public List TaskForceIds { get; } = []; - public List DirectiveIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "general-purpose"; + public string? CommanderId { get; set; } + public string? FrontId { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string? ReinforcementPolicyId { get; set; } + public List AssetIds { get; } = []; + public List TaskForceIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerTaskForceRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Role { get; set; } = "task-force"; - public string? FleetId { get; set; } - public string? CommanderId { get; set; } - public string? FrontId { get; set; } - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public List AssetIds { get; } = []; - public List DirectiveIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "task-force"; + public string? FleetId { get; set; } + public string? CommanderId { get; set; } + public string? FrontId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List AssetIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerStationGroupRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Role { get; set; } = "industrial-group"; - public string? EconomicRegionId { get; set; } - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public List StationIds { get; } = []; - public List DirectiveIds { get; } = []; - public List FocusItemIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "industrial-group"; + public string? EconomicRegionId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List StationIds { get; } = []; + public List DirectiveIds { get; } = []; + public List FocusItemIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerEconomicRegionRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Role { get; set; } = "balanced-region"; - public string? SharedEconomicRegionId { get; set; } - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public List SystemIds { get; } = []; - public List StationGroupIds { get; } = []; - public List DirectiveIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "balanced-region"; + public string? SharedEconomicRegionId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List SystemIds { get; } = []; + public List StationGroupIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerFrontRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public float Priority { get; set; } = 50f; - public string Posture { get; set; } = "hold"; - public string? SharedFrontLineId { get; set; } - public string? TargetFactionId { get; set; } - public List SystemIds { get; } = []; - public List FleetIds { get; } = []; - public List ReserveIds { get; } = []; - public List DirectiveIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public float Priority { get; set; } = 50f; + public string Posture { get; set; } = "hold"; + public string? SharedFrontLineId { get; set; } + public string? TargetFactionId { get; set; } + public List SystemIds { get; } = []; + public List FleetIds { get; } = []; + public List ReserveIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerReserveGroupRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "ready"; - public string ReserveKind { get; set; } = "military"; - public string? HomeSystemId { get; set; } - public string? PolicyId { get; set; } - public List AssetIds { get; } = []; - public List FrontIds { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "ready"; + public string ReserveKind { get; set; } = "military"; + public string? HomeSystemId { get; set; } + public string? PolicyId { get; set; } + public List AssetIds { get; } = []; + public List FrontIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerFactionPolicyRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string ScopeKind { get; set; } = "player-faction"; - public string? ScopeId { get; set; } - public string? PolicySetId { get; set; } - public bool AllowDelegatedCombat { get; set; } = true; - public bool AllowDelegatedTrade { get; set; } = true; - public float ReserveCreditsRatio { get; set; } = 0.2f; - public float ReserveMilitaryRatio { get; set; } = 0.2f; - public string TradeAccessPolicy { get; set; } = "owner-and-allies"; - public string DockingAccessPolicy { get; set; } = "owner-and-allies"; - public string ConstructionAccessPolicy { get; set; } = "owner-only"; - public string OperationalRangePolicy { get; set; } = "unrestricted"; - public string CombatEngagementPolicy { get; set; } = "defensive"; - public bool AvoidHostileSystems { get; set; } = true; - public float FleeHullRatio { get; set; } = 0.35f; - public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); - public string? Notes { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public string? PolicySetId { get; set; } + public bool AllowDelegatedCombat { get; set; } = true; + public bool AllowDelegatedTrade { get; set; } = true; + public float ReserveCreditsRatio { get; set; } = 0.2f; + public float ReserveMilitaryRatio { get; set; } = 0.2f; + public string TradeAccessPolicy { get; set; } = "owner-and-allies"; + public string DockingAccessPolicy { get; set; } = "owner-and-allies"; + public string ConstructionAccessPolicy { get; set; } = "owner-only"; + public string OperationalRangePolicy { get; set; } = "unrestricted"; + public string CombatEngagementPolicy { get; set; } = "defensive"; + public bool AvoidHostileSystems { get; set; } = true; + public float FleeHullRatio { get; set; } = 0.35f; + public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerAutomationPolicyRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string ScopeKind { get; set; } = "player-faction"; - public string? ScopeId { get; set; } - public bool Enabled { get; set; } = true; - public string BehaviorKind { get; set; } = "idle"; - public bool UseOrders { get; set; } - public string? StagingOrderKind { get; set; } - public int MaxSystemRange { get; set; } - public bool KnownStationsOnly { get; set; } - public float Radius { get; set; } = 24f; - public float WaitSeconds { get; set; } = 3f; - public string? PreferredItemId { get; set; } - public string? Notes { get; set; } - public List RepeatOrders { get; } = []; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public bool Enabled { get; set; } = true; + public string BehaviorKind { get; set; } = "idle"; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public float Radius { get; set; } = 24f; + public float WaitSeconds { get; set; } = 3f; + public string? PreferredItemId { get; set; } + public string? Notes { get; set; } + public List RepeatOrders { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerReinforcementPolicyRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string ScopeKind { get; set; } = "player-faction"; - public string? ScopeId { get; set; } - public string ShipKind { get; set; } = "military"; - public int DesiredAssetCount { get; set; } - public int MinimumReserveCount { get; set; } - public bool AutoTransferReserves { get; set; } = true; - public bool AutoQueueProduction { get; set; } = true; - public string? SourceReserveId { get; set; } - public string? TargetFrontId { get; set; } - public string? Notes { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public string ShipKind { get; set; } = "military"; + public int DesiredAssetCount { get; set; } + public int MinimumReserveCount { get; set; } + public bool AutoTransferReserves { get; set; } = true; + public bool AutoQueueProduction { get; set; } = true; + public string? SourceReserveId { get; set; } + public string? TargetFrontId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerProductionProgramRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Kind { get; set; } = "ship-production"; - public string? TargetShipKind { get; set; } - public string? TargetModuleId { get; set; } - public string? TargetItemId { get; set; } - public int TargetCount { get; set; } - public int CurrentCount { get; set; } - public string? StationGroupId { get; set; } - public string? ReinforcementPolicyId { get; set; } - public string? Notes { get; set; } - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Kind { get; set; } = "ship-production"; + public string? TargetShipKind { get; set; } + public string? TargetModuleId { get; set; } + public string? TargetItemId { get; set; } + public int TargetCount { get; set; } + public int CurrentCount { get; set; } + public string? StationGroupId { get; set; } + public string? ReinforcementPolicyId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerDirectiveRuntime { - public required string Id { get; init; } - public required string Label { get; set; } - public string Status { get; set; } = "active"; - public string Kind { get; set; } = "hold"; - public string ScopeKind { get; set; } = "asset"; - public string ScopeId { get; set; } = string.Empty; - public string? TargetEntityId { get; set; } - public string? TargetSystemId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? HomeSystemId { get; set; } - public string? HomeStationId { get; set; } - public string? SourceStationId { get; set; } - public string? DestinationStationId { get; set; } - public string BehaviorKind { get; set; } = "idle"; - public bool UseOrders { get; set; } - public string? StagingOrderKind { get; set; } - public string? ItemId { get; set; } - public string? PreferredNodeId { get; set; } - public string? PreferredConstructionSiteId { get; set; } - public string? PreferredModuleId { get; set; } - public int Priority { get; set; } = 50; - public float Radius { get; set; } = 24f; - public float WaitSeconds { get; set; } = 3f; - public int MaxSystemRange { get; set; } - public bool KnownStationsOnly { get; set; } - public List PatrolPoints { get; } = []; - public List RepeatOrders { get; } = []; - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public string? Notes { get; set; } - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Kind { get; set; } = "hold"; + public string ScopeKind { get; set; } = "asset"; + public string ScopeId { get; set; } = string.Empty; + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string BehaviorKind { get; set; } = "idle"; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public string? ItemId { get; set; } + public string? PreferredNodeId { get; set; } + public string? PreferredConstructionSiteId { get; set; } + public string? PreferredModuleId { get; set; } + public int Priority { get; set; } = 50; + public float Radius { get; set; } = 24f; + public float WaitSeconds { get; set; } = 3f; + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public List PatrolPoints { get; } = []; + public List RepeatOrders { get; } = []; + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerAssignmentRuntime { - public required string Id { get; init; } - public required string AssetKind { get; set; } - public required string AssetId { get; set; } - public string? FleetId { get; set; } - public string? TaskForceId { get; set; } - public string? StationGroupId { get; set; } - public string? EconomicRegionId { get; set; } - public string? FrontId { get; set; } - public string? ReserveId { get; set; } - public string? DirectiveId { get; set; } - public string? PolicyId { get; set; } - public string? AutomationPolicyId { get; set; } - public string Role { get; set; } = "line"; - public string Status { get; set; } = "active"; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string AssetKind { get; set; } + public required string AssetId { get; set; } + public string? FleetId { get; set; } + public string? TaskForceId { get; set; } + public string? StationGroupId { get; set; } + public string? EconomicRegionId { get; set; } + public string? FrontId { get; set; } + public string? ReserveId { get; set; } + public string? DirectiveId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string Role { get; set; } = "line"; + public string Status { get; set; } = "active"; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerDecisionLogEntryRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string Summary { get; set; } - public string? RelatedEntityKind { get; set; } - public string? RelatedEntityId { get; set; } - public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedEntityKind { get; set; } + public string? RelatedEntityId { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class PlayerAlertRuntime { - public required string Id { get; init; } - public required string Kind { get; set; } - public required string Severity { get; set; } - public required string Summary { get; set; } - public string? AssetKind { get; set; } - public string? AssetId { get; set; } - public string? RelatedDirectiveId { get; set; } - public string Status { get; set; } = "open"; - public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Severity { get; set; } + public required string Summary { get; set; } + public string? AssetKind { get; set; } + public string? AssetId { get; set; } + public string? RelatedDirectiveId { get; set; } + public string Status { get; set; } = "open"; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs index a47336f..a6533fd 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -2,2440 +2,2440 @@ namespace SpaceGame.Api.PlayerFaction.Simulation; internal sealed class PlayerFactionService { - private const int MaxDecisionEntries = 64; - private const int MaxAlerts = 32; - private const string PlayerFactionDomainId = "player-faction"; + private const int MaxDecisionEntries = 64; + private const int MaxAlerts = 32; + private const string PlayerFactionDomainId = "player-faction"; - internal static bool IsPlayerFaction(SimulationWorld world, string factionId) => - world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal); + internal static bool IsPlayerFaction(SimulationWorld world, string factionId) => + world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal); - internal PlayerFactionRuntime EnsureDomain(SimulationWorld world) - { - if (world.PlayerFaction is not null) + internal PlayerFactionRuntime EnsureDomain(SimulationWorld world) { - return world.PlayerFaction; + if (world.PlayerFaction is not null) + { + return world.PlayerFaction; + } + + var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal)) + ?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); + + world.PlayerFaction = new PlayerFactionRuntime + { + Id = PlayerFactionDomainId, + Label = $"{sovereignFaction.Label} Command", + SovereignFactionId = sovereignFaction.Id, + CreatedAtUtc = world.GeneratedAtUtc, + UpdatedAtUtc = world.GeneratedAtUtc, + }; + + EnsureBaseStructures(world, world.PlayerFaction); + SyncRegistry(world, world.PlayerFaction); + return world.PlayerFaction; } - var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal)) - ?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); - - world.PlayerFaction = new PlayerFactionRuntime + internal void Update(SimulationWorld world, float _deltaSeconds, ICollection events) { - Id = PlayerFactionDomainId, - Label = $"{sovereignFaction.Label} Command", - SovereignFactionId = sovereignFaction.Id, - CreatedAtUtc = world.GeneratedAtUtc, - UpdatedAtUtc = world.GeneratedAtUtc, + var player = EnsureDomain(world); + EnsureBaseStructures(world, player); + SyncRegistry(world, player); + PrunePlayerState(world, player); + RefreshGeopoliticalOrganizationContext(world, player); + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + RefreshProductionPrograms(world, player); + ApplyStrategicIntegration(world, player); + ApplyPolicies(world, player); + ApplyAssignmentsAndDirectives(world, player, events); + RefreshAlerts(world, player); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request) + { + var player = EnsureDomain(world); + var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); + var nowUtc = DateTimeOffset.UtcNow; + + switch (NormalizeKind(request.Kind)) + { + case "fleet": + player.Fleets.Add(new PlayerFleetRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "general-purpose", + FrontId = request.FrontId, + HomeSystemId = request.HomeSystemId, + HomeStationId = request.HomeStationId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + ReinforcementPolicyId = request.ReinforcementPolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.FleetIds.Add(id); + break; + case "task-force": + player.TaskForces.Add(new PlayerTaskForceRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "task-force", + FleetId = request.ParentOrganizationId, + FrontId = request.FrontId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.TaskForceIds.Add(id); + break; + case "station-group": + var stationGroup = new PlayerStationGroupRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "industrial-group", + EconomicRegionId = request.ParentOrganizationId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }; + foreach (var itemId in request.FocusItemIds ?? []) + { + stationGroup.FocusItemIds.Add(itemId); + } + player.StationGroups.Add(stationGroup); + player.AssetRegistry.StationGroupIds.Add(id); + break; + case "economic-region": + var region = new PlayerEconomicRegionRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "balanced-region", + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }; + foreach (var systemId in request.SystemIds ?? []) + { + if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) + { + region.SystemIds.Add(systemId); + } + } + player.EconomicRegions.Add(region); + player.AssetRegistry.EconomicRegionIds.Add(id); + break; + case "front": + var front = new PlayerFrontRuntime + { + Id = id, + Label = request.Label, + Priority = request.Priority ?? 50f, + Posture = request.Role ?? "hold", + TargetFactionId = request.TargetFactionId, + UpdatedAtUtc = nowUtc, + }; + foreach (var systemId in request.SystemIds ?? []) + { + if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) + { + front.SystemIds.Add(systemId); + } + } + player.Fronts.Add(front); + player.AssetRegistry.FrontIds.Add(id); + break; + case "reserve": + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = id, + Label = request.Label, + ReserveKind = request.ReserveKind ?? "military", + HomeSystemId = request.HomeSystemId, + PolicyId = request.PolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.ReserveIds.Add(id); + break; + default: + throw new InvalidOperationException($"Unsupported organization kind '{request.Kind}'."); + } + + AddDecision(player, "organization-created", $"Created {request.Kind} {request.Label}.", request.Kind, id); + player.UpdatedAtUtc = nowUtc; + return player; + } + + internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId) + { + var player = EnsureDomain(world); + RemoveOrganization(player, organizationId); + player.Assignments.RemoveAll(assignment => + assignment.FleetId == organizationId || + assignment.TaskForceId == organizationId || + assignment.StationGroupId == organizationId || + assignment.EconomicRegionId == organizationId || + assignment.FrontId == organizationId || + assignment.ReserveId == organizationId); + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "organization-deleted", $"Removed organization {organizationId}.", "organization", organizationId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request) + { + var player = EnsureDomain(world); + var kind = ResolveOrganizationKind(player, organizationId); + switch (kind) + { + case "fleet": + var fleet = player.Fleets.First(entity => entity.Id == organizationId); + UpdateStringList(fleet.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + UpdateStringList(fleet.TaskForceIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.TaskForceIds); + fleet.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "task-force": + var taskForce = player.TaskForces.First(entity => entity.Id == organizationId); + UpdateStringList(taskForce.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + taskForce.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "station-group": + var stationGroup = player.StationGroups.First(entity => entity.Id == organizationId); + UpdateStringList(stationGroup.StationIds, request.AssetIds, request.Replace, player.AssetRegistry.StationIds); + stationGroup.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "economic-region": + var region = player.EconomicRegions.First(entity => entity.Id == organizationId); + UpdateStringList(region.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); + UpdateStringList(region.StationGroupIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.StationGroupIds); + region.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "front": + var front = player.Fronts.First(entity => entity.Id == organizationId); + UpdateStringList(front.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); + UpdateStringList(front.FleetIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.FleetIds); + front.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "reserve": + var reserve = player.Reserves.First(entity => entity.Id == organizationId); + UpdateStringList(reserve.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + UpdateStringList(reserve.FrontIds, request.FrontIds, request.Replace, player.AssetRegistry.FrontIds); + reserve.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + default: + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); + } + + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "membership-updated", $"Updated membership for {organizationId}.", "organization", organizationId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request) + { + var player = EnsureDomain(world); + var directive = directiveId is null + ? null + : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); + if (directive is null) + { + directive = new PlayerDirectiveRuntime + { + Id = directiveId ?? CreateDomainId("directive", request.Label, player.Directives.Select(candidate => candidate.Id)), + Label = request.Label, + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + player.Directives.Add(directive); + } + + directive.Label = request.Label; + directive.Kind = request.Kind; + directive.ScopeKind = request.ScopeKind; + directive.ScopeId = request.ScopeId; + directive.BehaviorKind = request.BehaviorKind; + directive.UseOrders = request.UseOrders; + directive.StagingOrderKind = request.StagingOrderKind; + directive.TargetEntityId = request.TargetEntityId; + directive.TargetSystemId = request.TargetSystemId; + directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + directive.HomeSystemId = request.HomeSystemId; + directive.HomeStationId = request.HomeStationId; + directive.SourceStationId = request.SourceStationId; + directive.DestinationStationId = request.DestinationStationId; + directive.ItemId = request.ItemId; + directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; + directive.PreferredModuleId = request.PreferredModuleId; + directive.Priority = request.Priority; + directive.Radius = MathF.Max(0f, request.Radius ?? directive.Radius); + directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? directive.WaitSeconds); + directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? directive.MaxSystemRange); + directive.KnownStationsOnly = request.KnownStationsOnly ?? directive.KnownStationsOnly; + directive.PatrolPoints.Clear(); + foreach (var point in request.PatrolPoints ?? []) + { + directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); + } + directive.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + directive.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + directive.PolicyId = request.PolicyId; + directive.AutomationPolicyId = request.AutomationPolicyId; + directive.Notes = request.Notes; + directive.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "directive-upserted", $"Updated directive {directive.Label}.", "directive", directive.Id); + player.UpdatedAtUtc = directive.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId) + { + var player = EnsureDomain(world); + player.Directives.RemoveAll(directive => directive.Id == directiveId); + foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId)) + { + assignment.DirectiveId = null; + } + ReconcileDirectiveScopes(player); + AddDecision(player, "directive-deleted", $"Removed directive {directiveId}.", "directive", directiveId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = policyId is null + ? null + : player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerFactionPolicyRuntime + { + Id = policyId ?? CreateDomainId("policy", request.Label, player.Policies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.Policies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.AllowDelegatedCombat = request.AllowDelegatedCombat; + policy.AllowDelegatedTrade = request.AllowDelegatedTrade; + policy.ReserveCreditsRatio = Math.Clamp(request.ReserveCreditsRatio, 0f, 1f); + policy.ReserveMilitaryRatio = Math.Clamp(request.ReserveMilitaryRatio, 0f, 1f); + if (request.TradeAccessPolicy is not null) + { + policy.TradeAccessPolicy = request.TradeAccessPolicy; + } + if (request.DockingAccessPolicy is not null) + { + policy.DockingAccessPolicy = request.DockingAccessPolicy; + } + if (request.ConstructionAccessPolicy is not null) + { + policy.ConstructionAccessPolicy = request.ConstructionAccessPolicy; + } + if (request.OperationalRangePolicy is not null) + { + policy.OperationalRangePolicy = request.OperationalRangePolicy; + } + if (request.CombatEngagementPolicy is not null) + { + policy.CombatEngagementPolicy = request.CombatEngagementPolicy; + } + if (request.AvoidHostileSystems.HasValue) + { + policy.AvoidHostileSystems = request.AvoidHostileSystems.Value; + } + if (request.FleeHullRatio.HasValue) + { + policy.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); + } + if (request.BlacklistedSystemIds is not null) + { + policy.BlacklistedSystemIds.Clear(); + foreach (var systemId in request.BlacklistedSystemIds) + { + policy.BlacklistedSystemIds.Add(systemId); + } + } + policy.Notes = request.Notes; + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var policySet = EnsurePolicySet(world, player, policy, request.PolicySetId); + ApplyPolicySetRequest(policySet, request); + policy.PolicySetId = policySet.Id; + + AddDecision(player, "policy-upserted", $"Updated policy {policy.Label}.", "policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = automationPolicyId is null + ? null + : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerAutomationPolicyRuntime + { + Id = automationPolicyId ?? CreateDomainId("automation", request.Label, player.AutomationPolicies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.AutomationPolicies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.Enabled = request.Enabled; + policy.BehaviorKind = request.BehaviorKind; + policy.UseOrders = request.UseOrders; + policy.StagingOrderKind = request.StagingOrderKind; + policy.MaxSystemRange = Math.Max(0, request.MaxSystemRange); + policy.KnownStationsOnly = request.KnownStationsOnly; + policy.Radius = MathF.Max(0f, request.Radius); + policy.WaitSeconds = MathF.Max(0f, request.WaitSeconds); + policy.PreferredItemId = request.PreferredItemId; + policy.Notes = request.Notes; + policy.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + policy.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "automation-upserted", $"Updated automation policy {policy.Label}.", "automation-policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = reinforcementPolicyId is null + ? null + : player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerReinforcementPolicyRuntime + { + Id = reinforcementPolicyId ?? CreateDomainId("reinforcement", request.Label, player.ReinforcementPolicies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.ReinforcementPolicies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.ShipKind = request.ShipKind; + policy.DesiredAssetCount = Math.Max(0, request.DesiredAssetCount); + policy.MinimumReserveCount = Math.Max(0, request.MinimumReserveCount); + policy.AutoTransferReserves = request.AutoTransferReserves; + policy.AutoQueueProduction = request.AutoQueueProduction; + policy.SourceReserveId = request.SourceReserveId; + policy.TargetFrontId = request.TargetFrontId; + policy.Notes = request.Notes; + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "reinforcement-upserted", $"Updated reinforcement policy {policy.Label}.", "reinforcement-policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request) + { + var player = EnsureDomain(world); + var program = productionProgramId is null + ? null + : player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); + if (program is null) + { + program = new PlayerProductionProgramRuntime + { + Id = productionProgramId ?? CreateDomainId("production", request.Label, player.ProductionPrograms.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.ProductionPrograms.Add(program); + } + + program.Label = request.Label; + program.Kind = request.Kind; + program.TargetShipKind = request.TargetShipKind; + program.TargetModuleId = request.TargetModuleId; + program.TargetItemId = request.TargetItemId; + program.TargetCount = Math.Max(0, request.TargetCount); + program.StationGroupId = request.StationGroupId; + program.ReinforcementPolicyId = request.ReinforcementPolicyId; + program.Notes = request.Notes; + program.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "production-upserted", $"Updated production program {program.Label}.", "production-program", program.Id); + player.UpdatedAtUtc = program.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request) + { + var player = EnsureDomain(world); + var assignment = player.Assignments.FirstOrDefault(candidate => + string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && + string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal)); + if (assignment is null) + { + assignment = new PlayerAssignmentRuntime + { + Id = $"assignment-{request.AssetKind}-{assetId}", + AssetKind = request.AssetKind, + AssetId = assetId, + }; + player.Assignments.Add(assignment); + } + + if (request.ClearConflicts) + { + RemoveAssetFromOrganizations(player, request.AssetKind, assetId); + } + + if (request.FleetId is not null) + { + AddAssetToFleet(player, request.FleetId, assetId); + } + if (request.TaskForceId is not null) + { + AddAssetToTaskForce(player, request.TaskForceId, assetId); + } + if (request.StationGroupId is not null) + { + AddAssetToStationGroup(player, request.StationGroupId, assetId); + } + if (request.ReserveId is not null) + { + AddAssetToReserve(player, request.ReserveId, assetId); + } + + assignment.FleetId = request.FleetId; + assignment.TaskForceId = request.TaskForceId; + assignment.StationGroupId = request.StationGroupId; + assignment.EconomicRegionId = request.EconomicRegionId ?? assignment.EconomicRegionId; + assignment.FrontId = request.FrontId ?? assignment.FrontId; + assignment.ReserveId = request.ReserveId; + assignment.DirectiveId = request.DirectiveId; + assignment.PolicyId = request.PolicyId; + assignment.AutomationPolicyId = request.AutomationPolicyId; + assignment.Role = request.Role; + assignment.Status = "active"; + assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; + + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "assignment-upserted", $"Assigned {request.AssetKind} {assetId}.", request.AssetKind, assetId); + player.UpdatedAtUtc = assignment.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request) + { + var player = EnsureDomain(world); + player.StrategicIntent.StrategicPosture = request.StrategicPosture; + player.StrategicIntent.EconomicPosture = request.EconomicPosture; + player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; + player.StrategicIntent.LogisticsPosture = request.LogisticsPosture; + player.StrategicIntent.DesiredReserveRatio = Math.Clamp(request.DesiredReserveRatio, 0f, 1f); + player.StrategicIntent.AllowDelegatedCombatAutomation = request.AllowDelegatedCombatAutomation; + player.StrategicIntent.AllowDelegatedEconomicAutomation = request.AllowDelegatedEconomicAutomation; + player.StrategicIntent.Notes = request.Notes; + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + AddDecision(player, "strategic-intent-updated", "Updated player strategic intent.", "player-faction", player.Id); + return player; + } + + internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + if (ship.OrderQueue.Count >= 8) + { + throw new InvalidOperationException("Order queue is full."); + } + + ship.OrderQueue.Add(new ShipOrderRuntime + { + Id = $"order-{ship.Id}-{Guid.NewGuid():N}", + Kind = request.Kind, + Priority = request.Priority, + InterruptCurrentPlan = request.InterruptCurrentPlan, + Label = request.Label, + TargetEntityId = request.TargetEntityId, + TargetSystemId = request.TargetSystemId, + TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z), + SourceStationId = request.SourceStationId, + DestinationStationId = request.DestinationStationId, + ItemId = request.ItemId, + NodeId = request.NodeId, + ConstructionSiteId = request.ConstructionSiteId, + ModuleId = request.ModuleId, + WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, request.Radius ?? 0f), + MaxSystemRange = request.MaxSystemRange, + KnownStationsOnly = request.KnownStationsOnly ?? false, + }); + + AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + ship.ControlSourceKind = "player-order"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = request.Label ?? request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-enqueued"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); + if (removed > 0) + { + AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + ? "player-order" + : "player-manual"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Label ?? order.Kind) + .FirstOrDefault() + ?? "manual-player-control"; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-removed"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var directiveId = $"player-directive-ship-{shipId}"; + var directive = player.Directives.FirstOrDefault(candidate => candidate.Id == directiveId); + if (directive is null) + { + directive = new PlayerDirectiveRuntime + { + Id = directiveId, + Label = $"Direct control {ship.Definition.Label}", + ScopeKind = "ship", + ScopeId = shipId, + Kind = "direct-control", + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + player.Directives.Add(directive); + } + + directive.Label = $"Direct control {ship.Definition.Label}"; + directive.Kind = "direct-control"; + directive.ScopeKind = "ship"; + directive.ScopeId = shipId; + directive.BehaviorKind = request.Kind; + directive.UseOrders = false; + directive.StagingOrderKind = null; + directive.TargetEntityId = request.TargetEntityId; + directive.TargetSystemId = request.AreaSystemId; + directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + directive.HomeSystemId = request.HomeSystemId ?? ship.SystemId; + directive.HomeStationId = request.HomeStationId; + directive.SourceStationId = request.HomeStationId; + directive.DestinationStationId = null; + directive.ItemId = request.PreferredItemId; + directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; + directive.PreferredModuleId = request.PreferredModuleId; + directive.Priority = 100; + directive.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius); + directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds); + directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange); + directive.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly; + directive.PatrolPoints.Clear(); + foreach (var point in request.PatrolPoints ?? []) + { + directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); + } + directive.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + directive.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + directive.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var assignment = GetOrCreateAssignment(player, "ship", shipId); + assignment.DirectiveId = directive.Id; + assignment.Status = "active"; + assignment.UpdatedAtUtc = directive.UpdatedAtUtc; + + ApplyBehavior(ship.DefaultBehavior, BuildDirectiveBehavior(ship, directive, null)); + ship.ControlSourceKind = "player-directive"; + ship.ControlSourceId = directive.Id; + ship.ControlReason = directive.Label; + AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = directive.UpdatedAtUtc; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-behavior-configured"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private static void EnsureBaseStructures(SimulationWorld world, PlayerFactionRuntime player) + { + if (player.Policies.Count == 0) + { + var sovereign = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, player.SovereignFactionId, StringComparison.Ordinal)); + player.Policies.Add(new PlayerFactionPolicyRuntime + { + Id = "player-core-policy", + Label = "Core Empire Policy", + PolicySetId = sovereign?.DefaultPolicySetId, + }); + + if (sovereign?.DefaultPolicySetId is { } defaultPolicySetId + && world.Policies.FirstOrDefault(policy => policy.Id == defaultPolicySetId) is { } defaultPolicySet) + { + CopyPolicySetToPlayerPolicy(defaultPolicySet, player.Policies[0]); + } + } + + if (player.AutomationPolicies.Count == 0) + { + player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime + { + Id = "player-core-automation", + Label = "Core Automation", + BehaviorKind = "idle", + }); + } + + if (player.Reserves.Count == 0) + { + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = "player-core-reserve", + Label = "Strategic Reserve", + ReserveKind = "military", + }); + player.AssetRegistry.ReserveIds.Add("player-core-reserve"); + } + } + + private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player) + { + SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id)); + SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id)); + SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id)); + SyncSet(player.AssetRegistry.ClaimIds, world.Claims.Where(claim => claim.FactionId == player.SovereignFactionId).Select(claim => claim.Id)); + SyncSet(player.AssetRegistry.ConstructionSiteIds, world.ConstructionSites.Where(site => site.FactionId == player.SovereignFactionId).Select(site => site.Id)); + SyncSet(player.AssetRegistry.PolicySetIds, world.Policies.Where(policy => policy.OwnerId == player.SovereignFactionId || player.Policies.Any(entry => entry.PolicySetId == policy.Id)).Select(policy => policy.Id)); + SyncSet(player.AssetRegistry.MarketOrderIds, world.MarketOrders.Where(order => order.FactionId == player.SovereignFactionId).Select(order => order.Id)); + SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id)); + SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id)); + SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id)); + SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id)); + SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id)); + SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id)); + } + + private static void PrunePlayerState(SimulationWorld world, PlayerFactionRuntime player) + { + var shipIds = player.AssetRegistry.ShipIds; + var stationIds = player.AssetRegistry.StationIds; + var frontIds = player.AssetRegistry.FrontIds; + var fleetIds = player.AssetRegistry.FleetIds; + var reserveIds = player.AssetRegistry.ReserveIds; + var taskForceIds = player.AssetRegistry.TaskForceIds; + var stationGroupIds = player.AssetRegistry.StationGroupIds; + var regionIds = player.AssetRegistry.EconomicRegionIds; + var directiveIds = player.Directives.Select(directive => directive.Id).ToHashSet(StringComparer.Ordinal); + var policyIds = player.Policies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); + var automationIds = player.AutomationPolicies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); + + foreach (var fleet in player.Fleets) + { + fleet.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + fleet.TaskForceIds.RemoveAll(taskForceId => !taskForceIds.Contains(taskForceId)); + fleet.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var taskForce in player.TaskForces) + { + taskForce.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + taskForce.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + if (taskForce.FleetId is not null && !fleetIds.Contains(taskForce.FleetId)) + { + taskForce.FleetId = null; + } + } + + foreach (var group in player.StationGroups) + { + group.StationIds.RemoveAll(stationId => !stationIds.Contains(stationId)); + group.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + if (group.EconomicRegionId is not null && !regionIds.Contains(group.EconomicRegionId)) + { + group.EconomicRegionId = null; + } + } + + foreach (var region in player.EconomicRegions) + { + region.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); + region.StationGroupIds.RemoveAll(groupId => !stationGroupIds.Contains(groupId)); + region.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var front in player.Fronts) + { + front.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); + front.FleetIds.RemoveAll(fleetId => !fleetIds.Contains(fleetId)); + front.ReserveIds.RemoveAll(reserveId => !reserveIds.Contains(reserveId)); + front.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var reserve in player.Reserves) + { + reserve.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + reserve.FrontIds.RemoveAll(frontId => !frontIds.Contains(frontId)); + } + + player.Assignments.RemoveAll(assignment => + (assignment.AssetKind == "ship" && !shipIds.Contains(assignment.AssetId)) || + (assignment.AssetKind == "station" && !stationIds.Contains(assignment.AssetId))); + + foreach (var assignment in player.Assignments) + { + if (assignment.FleetId is not null && !fleetIds.Contains(assignment.FleetId)) + { + assignment.FleetId = null; + } + if (assignment.TaskForceId is not null && !taskForceIds.Contains(assignment.TaskForceId)) + { + assignment.TaskForceId = null; + } + if (assignment.StationGroupId is not null && !stationGroupIds.Contains(assignment.StationGroupId)) + { + assignment.StationGroupId = null; + } + if (assignment.EconomicRegionId is not null && !regionIds.Contains(assignment.EconomicRegionId)) + { + assignment.EconomicRegionId = null; + } + if (assignment.FrontId is not null && !frontIds.Contains(assignment.FrontId)) + { + assignment.FrontId = null; + } + if (assignment.ReserveId is not null && !reserveIds.Contains(assignment.ReserveId)) + { + assignment.ReserveId = null; + } + if (assignment.DirectiveId is not null && !directiveIds.Contains(assignment.DirectiveId)) + { + assignment.DirectiveId = null; + } + if (assignment.PolicyId is not null && !policyIds.Contains(assignment.PolicyId)) + { + assignment.PolicyId = null; + } + if (assignment.AutomationPolicyId is not null && !automationIds.Contains(assignment.AutomationPolicyId)) + { + assignment.AutomationPolicyId = null; + } + } + } + + private static void ApplyPolicies(SimulationWorld world, PlayerFactionRuntime player) + { + foreach (var policy in player.Policies) + { + if (policy.PolicySetId is null) + { + continue; + } + + if (world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } policySet) + { + policySet.TradeAccessPolicy = policy.TradeAccessPolicy; + policySet.DockingAccessPolicy = policy.DockingAccessPolicy; + policySet.ConstructionAccessPolicy = policy.ConstructionAccessPolicy; + policySet.OperationalRangePolicy = policy.OperationalRangePolicy; + policySet.CombatEngagementPolicy = policy.CombatEngagementPolicy; + policySet.FleeHullRatio = Math.Clamp(policy.FleeHullRatio, 0.05f, 0.95f); + policySet.AvoidHostileSystems = policy.AvoidHostileSystems; + + policySet.BlacklistedSystemIds.Clear(); + foreach (var systemId in policy.BlacklistedSystemIds) + { + policySet.BlacklistedSystemIds.Add(systemId); + } + } + } + } + + private static void ApplyAssignmentsAndDirectives(SimulationWorld world, PlayerFactionRuntime player, ICollection events) + { + var factionCommander = world.Commanders.FirstOrDefault(commander => + commander.Kind == CommanderKind.Faction && + string.Equals(commander.FactionId, player.SovereignFactionId, StringComparison.Ordinal)); + if (factionCommander is null) + { + return; + } + + var fleetCommanders = EnsureFleetCommanders(world, player, factionCommander); + var taskForceCommanders = EnsureTaskForceCommanders(world, player, factionCommander, fleetCommanders); + var assignmentsByAsset = player.Assignments + .Where(assignment => assignment.Status == "active") + .GroupBy(assignment => $"{assignment.AssetKind}:{assignment.AssetId}", StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.OrderByDescending(item => item.UpdatedAtUtc).First(), StringComparer.Ordinal); + + foreach (var ship in world.Ships.Where(candidate => candidate.FactionId == player.SovereignFactionId)) + { + if (ship.CommanderId is null) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); + if (commander is null) + { + continue; + } + + var assignment = ResolveAssignment(assignmentsByAsset, "ship", ship.Id); + var directive = ResolveDirective(player, assignment, "ship", ship.Id); + var automation = ResolveAutomation(player, assignment, directive, "ship", ship.Id); + var policy = ResolvePolicy(player, assignment, directive, "ship", ship.Id); + + commander.ParentCommanderId = ResolveParentCommanderId(factionCommander, assignment, fleetCommanders, taskForceCommanders); + commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; + ship.PolicySetId = commander.PolicySetId; + var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment); + if (changed && directive is not null) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id)); + } + } + + foreach (var station in world.Stations.Where(candidate => candidate.FactionId == player.SovereignFactionId)) + { + if (station.CommanderId is null) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); + if (commander is null) + { + continue; + } + + var assignment = ResolveAssignment(assignmentsByAsset, "station", station.Id); + var directive = ResolveDirective(player, assignment, "station", station.Id); + var policy = ResolvePolicy(player, assignment, directive, "station", station.Id); + commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; + station.PolicySetId = commander.PolicySetId; + commander.Assignment = directive is null && assignment is null + ? null + : new CommanderAssignmentRuntime + { + ObjectiveId = directive?.Id ?? assignment?.StationGroupId ?? $"player-station-{station.Id}", + Kind = directive?.Kind ?? "player-station-control", + BehaviorKind = directive?.BehaviorKind ?? assignment?.Role ?? "station-control", + Status = directive?.Status ?? assignment?.Status ?? "active", + Priority = directive?.Priority ?? 40f, + HomeSystemId = directive?.HomeSystemId ?? station.SystemId, + HomeStationId = directive?.HomeStationId ?? station.Id, + TargetSystemId = directive?.TargetSystemId, + TargetEntityId = directive?.TargetEntityId, + TargetPosition = directive?.TargetPosition, + ItemId = directive?.ItemId, + Notes = directive?.Notes ?? assignment?.Role, + UpdatedAtUtc = directive?.UpdatedAtUtc ?? assignment?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, + }; + } + } + + private static Dictionary EnsureFleetCommanders(SimulationWorld world, PlayerFactionRuntime player, CommanderRuntime factionCommander) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var fleet in player.Fleets) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Fleet && + candidate.FactionId == player.SovereignFactionId && + string.Equals(candidate.ControlledEntityId, fleet.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-player-fleet-{fleet.Id}", + Kind = CommanderKind.Fleet, + FactionId = player.SovereignFactionId, + ControlledEntityId = fleet.Id, + Doctrine = "player-fleet-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 4 }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = factionCommander.Id; + commander.PolicySetId = ResolvePolicySetId(world, player, fleet.PolicyId) ?? factionCommander.PolicySetId; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = fleet.Id, + Kind = "player-fleet", + BehaviorKind = fleet.Role, + Status = fleet.Status, + Priority = 80f, + HomeSystemId = fleet.HomeSystemId, + HomeStationId = fleet.HomeStationId, + Notes = fleet.Label, + UpdatedAtUtc = fleet.UpdatedAtUtc, + }; + fleet.CommanderId = commander.Id; + map[fleet.Id] = commander; + } + return map; + } + + private static Dictionary EnsureTaskForceCommanders( + SimulationWorld world, + PlayerFactionRuntime player, + CommanderRuntime factionCommander, + IReadOnlyDictionary fleetCommanders) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var taskForce in player.TaskForces) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.TaskGroup && + candidate.FactionId == player.SovereignFactionId && + string.Equals(candidate.ControlledEntityId, taskForce.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-player-task-force-{taskForce.Id}", + Kind = CommanderKind.TaskGroup, + FactionId = player.SovereignFactionId, + ControlledEntityId = taskForce.Id, + Doctrine = "player-task-force-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 4, Coordination = 4, Strategy = 4 }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = taskForce.FleetId is not null && fleetCommanders.TryGetValue(taskForce.FleetId, out var fleetCommander) + ? fleetCommander.Id + : factionCommander.Id; + commander.PolicySetId = ResolvePolicySetId(world, player, taskForce.PolicyId) ?? factionCommander.PolicySetId; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = taskForce.Id, + Kind = "player-task-force", + BehaviorKind = taskForce.Role, + Status = taskForce.Status, + Priority = 75f, + Notes = taskForce.Label, + UpdatedAtUtc = taskForce.UpdatedAtUtc, + }; + taskForce.CommanderId = commander.Id; + map[taskForce.Id] = commander; + } + return map; + } + + private static string ResolveParentCommanderId( + CommanderRuntime factionCommander, + PlayerAssignmentRuntime? assignment, + IReadOnlyDictionary fleetCommanders, + IReadOnlyDictionary taskForceCommanders) + { + if (assignment?.TaskForceId is not null && taskForceCommanders.TryGetValue(assignment.TaskForceId, out var taskForceCommander)) + { + return taskForceCommander.Id; + } + + if (assignment?.FleetId is not null && fleetCommanders.TryGetValue(assignment.FleetId, out var fleetCommander)) + { + return fleetCommander.Id; + } + + return factionCommander.Id; + } + + private static PlayerAssignmentRuntime? ResolveAssignment( + IReadOnlyDictionary assignmentsByAsset, + string assetKind, + string assetId) => + assignmentsByAsset.GetValueOrDefault($"{assetKind}:{assetId}"); + + private static PlayerDirectiveRuntime? ResolveDirective(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, string assetKind, string assetId) + { + if (assignment?.DirectiveId is not null) + { + return player.Directives.FirstOrDefault(directive => directive.Id == assignment.DirectiveId); + } + + return SelectScopedDirective( + player.Directives.Where(directive => directive.Status == "active"), + player, + assignment, + assetKind, + assetId); + } + + private static PlayerAutomationPolicyRuntime? ResolveAutomation(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) + { + var automationId = assignment?.AutomationPolicyId ?? directive?.AutomationPolicyId; + if (automationId is not null) + { + return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId); + } + + return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId) + ?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation"); + } + + private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) + { + var policyId = assignment?.PolicyId ?? directive?.PolicyId; + if (policyId is not null) + { + return player.Policies.FirstOrDefault(policy => policy.Id == policyId); + } + + return SelectScopedFactionPolicy(player, assignment, assetKind, assetId) + ?? player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); + } + + private static bool ApplyDirectiveToShip( + CommanderRuntime commander, + ShipRuntime ship, + PlayerDirectiveRuntime? directive, + PlayerAutomationPolicyRuntime? automation, + PlayerAssignmentRuntime? assignment) + { + var desiredAssignment = BuildDirectiveAssignment(ship, directive, automation, assignment); + var desiredBehavior = BuildDirectiveBehavior(ship, directive, automation); + var hasBehaviorSource = directive is not null || automation is not null; + var desiredControlSourceKind = directive is not null + ? "player-directive" + : automation is not null + ? "player-automation" + : ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + ? "player-order" + : "player-manual"; + var desiredControlSourceId = directive?.Id + ?? automation?.Id + ?? ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + var desiredControlReason = directive?.Label + ?? automation?.Label + ?? ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Label ?? order.Kind) + .FirstOrDefault() + ?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control"); + + var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment); + var behaviorChanged = hasBehaviorSource && !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior!); + var ordersChanged = ReconcileDirectiveOrders(ship, directive, automation); + var controlChanged = + !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) + || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) + || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); + + if (assignmentChanged) + { + commander.Assignment = desiredAssignment; + } + + if (behaviorChanged && desiredBehavior is not null) + { + ApplyBehavior(ship.DefaultBehavior, desiredBehavior); + } + + if (directive is null && automation is null) + { + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + var surfaceChanged = assignmentChanged || ordersChanged || controlChanged; + if (surfaceChanged) + { + ship.LastDeltaSignature = string.Empty; + } + + if (assignmentChanged || ordersChanged) + { + ship.NeedsReplan = true; + ship.LastReplanReason = assignmentChanged + ? "player-assignment-updated" + : ordersChanged + ? "player-order-updated" + : "player-control-updated"; + } + + return surfaceChanged; + } + + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + var changed = assignmentChanged || behaviorChanged || ordersChanged || controlChanged; + if (changed) + { + ship.LastDeltaSignature = string.Empty; + } + + if (assignmentChanged || behaviorChanged || ordersChanged) + { + ship.NeedsReplan = true; + ship.LastReplanReason = assignmentChanged + ? "player-assignment-updated" + : behaviorChanged + ? "player-behavior-updated" + : ordersChanged + ? "player-order-updated" + : "player-control-updated"; + } + + return changed; + } + + private static DefaultBehaviorRuntime BuildDirectiveBehavior(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) + { + return new DefaultBehaviorRuntime + { + Kind = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind, + HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId ?? ship.SystemId, + HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, + AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, + TargetEntityId = directive?.TargetEntityId, + PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId, + PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, + PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, + PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, + TargetPosition = directive?.TargetPosition, + WaitSeconds = directive?.WaitSeconds ?? automation?.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds, + Radius = directive?.Radius ?? automation?.Radius ?? ship.DefaultBehavior.Radius, + MaxSystemRange = directive?.MaxSystemRange ?? automation?.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = directive?.KnownStationsOnly ?? automation?.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly, + PatrolPoints = directive?.PatrolPoints.Select(point => point).ToList() ?? ship.DefaultBehavior.PatrolPoints.Select(point => point).ToList(), + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + RepeatOrders = directive?.RepeatOrders.Select(CloneTemplate).ToList() + ?? automation?.RepeatOrders.Select(CloneTemplate).ToList() + ?? ship.DefaultBehavior.RepeatOrders.Select(CloneTemplate).ToList(), + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; + } + + private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) + { + var aiOrderId = directive is null ? null : $"player-order-{directive.Id}"; + var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0; + + var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false; + if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind)) + { + return changed; + } + + var desiredOrder = new ShipOrderRuntime + { + Id = aiOrderId!, + Kind = directive.StagingOrderKind!, + Priority = Math.Max(0, directive.Priority), + InterruptCurrentPlan = true, + Label = directive.Label, + TargetEntityId = directive.TargetEntityId, + TargetSystemId = directive.TargetSystemId, + TargetPosition = directive.TargetPosition, + SourceStationId = directive.SourceStationId ?? directive.HomeStationId, + DestinationStationId = directive.DestinationStationId, + ItemId = directive.ItemId, + NodeId = directive.PreferredNodeId, + ConstructionSiteId = directive.PreferredConstructionSiteId, + ModuleId = directive.PreferredModuleId, + WaitSeconds = directive.WaitSeconds, + Radius = directive.Radius, + MaxSystemRange = directive.MaxSystemRange, + KnownStationsOnly = directive.KnownStationsOnly, + }; + + var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId); + if (existing is null) + { + ship.OrderQueue.Add(desiredOrder); + return true; + } + + if (!ShipOrdersEqual(existing, desiredOrder)) + { + ship.OrderQueue.Remove(existing); + ship.OrderQueue.Add(desiredOrder); + return true; + } + + return changed; + } + + private static CommanderAssignmentRuntime? BuildDirectiveAssignment( + ShipRuntime ship, + PlayerDirectiveRuntime? directive, + PlayerAutomationPolicyRuntime? automation, + PlayerAssignmentRuntime? assignment) + { + if (directive is null && automation is null) + { + return null; + } + + var behavior = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind; + return new CommanderAssignmentRuntime + { + ObjectiveId = directive?.Id ?? assignment?.DirectiveId ?? $"automation-{ship.Id}", + Kind = directive?.Kind ?? "player-automation", + BehaviorKind = behavior, + Status = directive?.Status ?? "active", + Priority = directive?.Priority ?? 50f, + HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId, + HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, + TargetSystemId = directive?.TargetSystemId, + TargetEntityId = directive?.TargetEntityId, + TargetPosition = directive?.TargetPosition, + ItemId = directive?.ItemId, + Notes = directive?.Notes ?? automation?.Notes, + UpdatedAtUtc = directive?.UpdatedAtUtc ?? automation?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, + }; + } + + private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) + { + target.Kind = source.Kind; + target.HomeSystemId = source.HomeSystemId; + target.HomeStationId = source.HomeStationId; + target.AreaSystemId = source.AreaSystemId; + target.TargetEntityId = source.TargetEntityId; + target.PreferredItemId = source.PreferredItemId; + target.PreferredNodeId = source.PreferredNodeId; + target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; + target.PreferredModuleId = source.PreferredModuleId; + target.TargetPosition = source.TargetPosition; + target.WaitSeconds = source.WaitSeconds; + target.Radius = source.Radius; + target.MaxSystemRange = source.MaxSystemRange; + target.KnownStationsOnly = source.KnownStationsOnly; + target.PatrolPoints = source.PatrolPoints.Select(point => point).ToList(); + target.PatrolIndex = source.PatrolIndex; + target.RepeatOrders = source.RepeatOrders.Select(CloneTemplate).ToList(); + target.RepeatIndex = source.RepeatIndex; + } + + private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly + && left.PatrolPoints.SequenceEqual(right.PatrolPoints) + && left.RepeatOrders.Count == right.RepeatOrders.Count + && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); + + private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.Priority == right.Priority + && left.InterruptCurrentPlan == right.InterruptCurrentPlan + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) + && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) + && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) + && string.Equals(left.Status, right.Status, StringComparison.Ordinal) + && left.Priority.Equals(right.Priority) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal); + } + + private static ShipOrderTemplateRuntime CloneTemplate(ShipOrderTemplateRuntime template) => new() + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition, + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds, + Radius = template.Radius, + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly, }; - EnsureBaseStructures(world, world.PlayerFaction); - SyncRegistry(world, world.PlayerFaction); - return world.PlayerFaction; - } - - internal void Update(SimulationWorld world, float _deltaSeconds, ICollection events) - { - var player = EnsureDomain(world); - EnsureBaseStructures(world, player); - SyncRegistry(world, player); - PrunePlayerState(world, player); - RefreshGeopoliticalOrganizationContext(world, player); - ReconcileOrganizationAssignments(world, player); - ReconcileDirectiveScopes(player); - RefreshProductionPrograms(world, player); - ApplyStrategicIntegration(world, player); - ApplyPolicies(world, player); - ApplyAssignmentsAndDirectives(world, player, events); - RefreshAlerts(world, player); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - } - - internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request) - { - var player = EnsureDomain(world); - var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); - var nowUtc = DateTimeOffset.UtcNow; - - switch (NormalizeKind(request.Kind)) + private static void ReconcileOrganizationAssignments(SimulationWorld world, PlayerFactionRuntime player) { - case "fleet": - player.Fleets.Add(new PlayerFleetRuntime + var fleetMemberships = new Dictionary>(StringComparer.Ordinal); + var taskForceMemberships = new Dictionary>(StringComparer.Ordinal); + var stationGroupMemberships = new Dictionary>(StringComparer.Ordinal); + var reserveMemberships = new Dictionary>(StringComparer.Ordinal); + + foreach (var fleet in player.Fleets) { - Id = id, - Label = request.Label, - Role = request.Role ?? "general-purpose", - FrontId = request.FrontId, - HomeSystemId = request.HomeSystemId, - HomeStationId = request.HomeStationId, - PolicyId = request.PolicyId, - AutomationPolicyId = request.AutomationPolicyId, - ReinforcementPolicyId = request.ReinforcementPolicyId, - UpdatedAtUtc = nowUtc, - }); - player.AssetRegistry.FleetIds.Add(id); - break; - case "task-force": - player.TaskForces.Add(new PlayerTaskForceRuntime - { - Id = id, - Label = request.Label, - Role = request.Role ?? "task-force", - FleetId = request.ParentOrganizationId, - FrontId = request.FrontId, - PolicyId = request.PolicyId, - AutomationPolicyId = request.AutomationPolicyId, - UpdatedAtUtc = nowUtc, - }); - player.AssetRegistry.TaskForceIds.Add(id); - break; - case "station-group": - var stationGroup = new PlayerStationGroupRuntime - { - Id = id, - Label = request.Label, - Role = request.Role ?? "industrial-group", - EconomicRegionId = request.ParentOrganizationId, - PolicyId = request.PolicyId, - AutomationPolicyId = request.AutomationPolicyId, - UpdatedAtUtc = nowUtc, - }; - foreach (var itemId in request.FocusItemIds ?? []) - { - stationGroup.FocusItemIds.Add(itemId); + foreach (var assetId in fleet.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(fleetMemberships, assetId, fleet.Id); + GetOrCreateAssignment(player, "ship", assetId); + } } - player.StationGroups.Add(stationGroup); - player.AssetRegistry.StationGroupIds.Add(id); - break; - case "economic-region": - var region = new PlayerEconomicRegionRuntime + + foreach (var taskForce in player.TaskForces) { - Id = id, - Label = request.Label, - Role = request.Role ?? "balanced-region", - PolicyId = request.PolicyId, - AutomationPolicyId = request.AutomationPolicyId, - UpdatedAtUtc = nowUtc, - }; - foreach (var systemId in request.SystemIds ?? []) - { - if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) - { - region.SystemIds.Add(systemId); - } + foreach (var assetId in taskForce.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(taskForceMemberships, assetId, taskForce.Id); + GetOrCreateAssignment(player, "ship", assetId); + } } - player.EconomicRegions.Add(region); - player.AssetRegistry.EconomicRegionIds.Add(id); - break; - case "front": - var front = new PlayerFrontRuntime + + foreach (var group in player.StationGroups) { - Id = id, - Label = request.Label, - Priority = request.Priority ?? 50f, - Posture = request.Role ?? "hold", - TargetFactionId = request.TargetFactionId, - UpdatedAtUtc = nowUtc, - }; - foreach (var systemId in request.SystemIds ?? []) - { - if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) - { - front.SystemIds.Add(systemId); - } + foreach (var stationId in group.StationIds.Where(player.AssetRegistry.StationIds.Contains)) + { + AddMembership(stationGroupMemberships, stationId, group.Id); + GetOrCreateAssignment(player, "station", stationId); + } } - player.Fronts.Add(front); - player.AssetRegistry.FrontIds.Add(id); - break; - case "reserve": - player.Reserves.Add(new PlayerReserveGroupRuntime + + foreach (var reserve in player.Reserves) { - Id = id, - Label = request.Label, - ReserveKind = request.ReserveKind ?? "military", - HomeSystemId = request.HomeSystemId, - PolicyId = request.PolicyId, - UpdatedAtUtc = nowUtc, - }); - player.AssetRegistry.ReserveIds.Add(id); - break; - default: - throw new InvalidOperationException($"Unsupported organization kind '{request.Kind}'."); + foreach (var assetId in reserve.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(reserveMemberships, assetId, reserve.Id); + GetOrCreateAssignment(player, "ship", assetId); + } + } + + foreach (var assignment in player.Assignments) + { + if (assignment.AssetKind == "ship") + { + assignment.FleetId = SelectSingleMembership(fleetMemberships, assignment.AssetId); + assignment.TaskForceId = SelectSingleMembership(taskForceMemberships, assignment.AssetId); + assignment.ReserveId = SelectSingleMembership(reserveMemberships, assignment.AssetId); + + if (assignment.TaskForceId is not null + && player.TaskForces.FirstOrDefault(taskForce => taskForce.Id == assignment.TaskForceId) is { FleetId: not null } taskForce) + { + assignment.FleetId ??= taskForce.FleetId; + } + + if (assignment.FleetId is not null) + { + assignment.FrontId = player.Fronts + .Where(front => front.FleetIds.Contains(assignment.FleetId, StringComparer.Ordinal)) + .OrderByDescending(front => front.Priority) + .ThenBy(front => front.Id, StringComparer.Ordinal) + .Select(front => front.Id) + .FirstOrDefault() + ?? assignment.FrontId; + } + else if (assignment.ReserveId is not null) + { + assignment.FrontId = player.Fronts + .Where(front => front.ReserveIds.Contains(assignment.ReserveId, StringComparer.Ordinal)) + .OrderByDescending(front => front.Priority) + .ThenBy(front => front.Id, StringComparer.Ordinal) + .Select(front => front.Id) + .FirstOrDefault() + ?? player.Reserves.FirstOrDefault(reserve => reserve.Id == assignment.ReserveId)?.FrontIds + .OrderBy(id => id, StringComparer.Ordinal) + .FirstOrDefault() + ?? assignment.FrontId; + } + } + else if (assignment.AssetKind == "station") + { + assignment.StationGroupId = SelectSingleMembership(stationGroupMemberships, assignment.AssetId); + if (assignment.StationGroupId is not null + && player.StationGroups.FirstOrDefault(group => group.Id == assignment.StationGroupId) is { EconomicRegionId: not null } stationGroup) + { + assignment.EconomicRegionId = stationGroup.EconomicRegionId; + } + } + } + + foreach (var assignment in player.Assignments) + { + assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; + } } - AddDecision(player, "organization-created", $"Created {request.Kind} {request.Label}.", request.Kind, id); - player.UpdatedAtUtc = nowUtc; - return player; - } - - internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId) - { - var player = EnsureDomain(world); - RemoveOrganization(player, organizationId); - player.Assignments.RemoveAll(assignment => - assignment.FleetId == organizationId || - assignment.TaskForceId == organizationId || - assignment.StationGroupId == organizationId || - assignment.EconomicRegionId == organizationId || - assignment.FrontId == organizationId || - assignment.ReserveId == organizationId); - ReconcileOrganizationAssignments(world, player); - ReconcileDirectiveScopes(player); - AddDecision(player, "organization-deleted", $"Removed organization {organizationId}.", "organization", organizationId); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - return player; - } - - internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request) - { - var player = EnsureDomain(world); - var kind = ResolveOrganizationKind(player, organizationId); - switch (kind) + private static void ReconcileDirectiveScopes(PlayerFactionRuntime player) { - case "fleet": - var fleet = player.Fleets.First(entity => entity.Id == organizationId); - UpdateStringList(fleet.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); - UpdateStringList(fleet.TaskForceIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.TaskForceIds); - fleet.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - case "task-force": - var taskForce = player.TaskForces.First(entity => entity.Id == organizationId); - UpdateStringList(taskForce.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); - taskForce.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - case "station-group": - var stationGroup = player.StationGroups.First(entity => entity.Id == organizationId); - UpdateStringList(stationGroup.StationIds, request.AssetIds, request.Replace, player.AssetRegistry.StationIds); - stationGroup.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - case "economic-region": - var region = player.EconomicRegions.First(entity => entity.Id == organizationId); - UpdateStringList(region.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); - UpdateStringList(region.StationGroupIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.StationGroupIds); - region.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - case "front": - var front = player.Fronts.First(entity => entity.Id == organizationId); - UpdateStringList(front.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); - UpdateStringList(front.FleetIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.FleetIds); - front.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - case "reserve": - var reserve = player.Reserves.First(entity => entity.Id == organizationId); - UpdateStringList(reserve.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); - UpdateStringList(reserve.FrontIds, request.FrontIds, request.Replace, player.AssetRegistry.FrontIds); - reserve.UpdatedAtUtc = DateTimeOffset.UtcNow; - break; - default: + foreach (var fleet in player.Fleets) + { + fleet.DirectiveIds.Clear(); + } + foreach (var taskForce in player.TaskForces) + { + taskForce.DirectiveIds.Clear(); + } + foreach (var group in player.StationGroups) + { + group.DirectiveIds.Clear(); + } + foreach (var region in player.EconomicRegions) + { + region.DirectiveIds.Clear(); + } + foreach (var front in player.Fronts) + { + front.DirectiveIds.Clear(); + } + + foreach (var directive in player.Directives.Where(directive => directive.Status == "active")) + { + switch (directive.ScopeKind) + { + case "fleet": + player.Fleets.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "task-force": + player.TaskForces.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "station-group": + player.StationGroups.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "economic-region": + player.EconomicRegions.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "front": + player.Fronts.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + } + } + } + + private static void RefreshProductionPrograms(SimulationWorld world, PlayerFactionRuntime player) + { + foreach (var program in player.ProductionPrograms) + { + if (!string.IsNullOrWhiteSpace(program.TargetShipKind)) + { + program.CurrentCount = world.Ships.Count(ship => + ship.FactionId == player.SovereignFactionId && + string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal)); + } + else + { + program.CurrentCount = 0; + } + } + } + + private static void ApplyStrategicIntegration(SimulationWorld world, PlayerFactionRuntime player) + { + var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == player.SovereignFactionId); + if (faction is null) + { + return; + } + + var corePolicy = player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); + faction.Doctrine.StrategicPosture = player.StrategicIntent.StrategicPosture; + faction.Doctrine.EconomicPosture = player.StrategicIntent.EconomicPosture; + faction.Doctrine.MilitaryPosture = player.StrategicIntent.MilitaryPosture; + faction.Doctrine.ReserveCreditsRatio = corePolicy?.ReserveCreditsRatio ?? faction.Doctrine.ReserveCreditsRatio; + faction.Doctrine.ReserveMilitaryRatio = corePolicy?.ReserveMilitaryRatio ?? faction.Doctrine.ReserveMilitaryRatio; + } + + private static void RefreshGeopoliticalOrganizationContext(SimulationWorld world, PlayerFactionRuntime player) + { + var regions = world.Geopolitics?.EconomyRegions.Regions + .Where(region => string.Equals(region.FactionId, player.SovereignFactionId, StringComparison.Ordinal)) + .ToList() ?? []; + var fronts = world.Geopolitics?.Territory.FrontLines + .Where(front => front.FactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal)) + .ToList() ?? []; + + foreach (var region in player.EconomicRegions) + { + if (region.SystemIds.Count == 0) + { + region.SystemIds.AddRange( + region.StationGroupIds + .SelectMany(groupId => player.StationGroups.FirstOrDefault(group => group.Id == groupId)?.StationIds ?? []) + .Select(stationId => world.Stations.FirstOrDefault(station => station.Id == stationId)?.SystemId) + .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + var matchedRegion = regions + .Select(candidate => new + { + Region = candidate, + Overlap = candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Count(), + }) + .OrderByDescending(entry => entry.Overlap) + .ThenBy(entry => entry.Region.Id, StringComparer.Ordinal) + .Select(entry => entry.Region) + .FirstOrDefault(); + region.SharedEconomicRegionId = matchedRegion?.Id; + if (matchedRegion is null) + { + continue; + } + + if (region.SystemIds.Count == 0) + { + region.SystemIds.AddRange(matchedRegion.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + if (string.Equals(region.Role, "balanced-region", StringComparison.Ordinal)) + { + region.Role = matchedRegion.Kind; + } + } + + foreach (var front in player.Fronts) + { + if (front.SystemIds.Count == 0) + { + var fleetSystems = front.FleetIds + .SelectMany(fleetId => player.Fleets.FirstOrDefault(fleet => fleet.Id == fleetId)?.AssetIds ?? []) + .Select(assetId => world.Ships.FirstOrDefault(ship => ship.Id == assetId)?.SystemId) + .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .ToList(); + front.SystemIds.AddRange(fleetSystems); + } + + var matchedFront = fronts + .Select(candidate => new + { + Front = candidate, + Overlap = candidate.SystemIds.Intersect(front.SystemIds, StringComparer.Ordinal).Count(), + TargetBias = front.TargetFactionId is not null && candidate.FactionIds.Contains(front.TargetFactionId, StringComparer.Ordinal) ? 1 : 0, + }) + .OrderByDescending(entry => entry.Overlap + entry.TargetBias) + .ThenBy(entry => entry.Front.Id, StringComparer.Ordinal) + .Select(entry => entry.Front) + .FirstOrDefault(); + front.SharedFrontLineId = matchedFront?.Id; + if (matchedFront is null) + { + continue; + } + + if (front.SystemIds.Count == 0) + { + front.SystemIds.AddRange(matchedFront.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + front.TargetFactionId ??= matchedFront.FactionIds.FirstOrDefault(id => !string.Equals(id, player.SovereignFactionId, StringComparison.Ordinal)); + } + } + + private static PlayerDirectiveRuntime? SelectScopedDirective( + IEnumerable directives, + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + directives + .Where(directive => ScopeMatches(player, directive.ScopeKind, directive.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(directive => ScopePriority(directive.ScopeKind)) + .ThenByDescending(directive => directive.Priority) + .ThenByDescending(directive => directive.UpdatedAtUtc) + .ThenBy(directive => directive.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static PlayerAutomationPolicyRuntime? SelectScopedAutomationPolicy( + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + player.AutomationPolicies + .Where(policy => policy.Enabled && ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) + .ThenByDescending(policy => policy.UpdatedAtUtc) + .ThenBy(policy => policy.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static PlayerFactionPolicyRuntime? SelectScopedFactionPolicy( + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + player.Policies + .Where(policy => ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) + .ThenByDescending(policy => policy.UpdatedAtUtc) + .ThenBy(policy => policy.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static bool ScopeMatches( + PlayerFactionRuntime player, + string scopeKind, + string? scopeId, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) + { + return scopeKind switch + { + "player-faction" => string.IsNullOrWhiteSpace(scopeId) + || string.Equals(scopeId, player.Id, StringComparison.Ordinal) + || string.Equals(scopeId, player.SovereignFactionId, StringComparison.Ordinal), + "asset" => string.Equals(scopeId, assetId, StringComparison.Ordinal), + "ship" => assetKind == "ship" && string.Equals(scopeId, assetId, StringComparison.Ordinal), + "station" => assetKind == "station" && string.Equals(scopeId, assetId, StringComparison.Ordinal), + "fleet" => string.Equals(scopeId, assignment?.FleetId, StringComparison.Ordinal), + "task-force" => string.Equals(scopeId, assignment?.TaskForceId, StringComparison.Ordinal), + "station-group" => string.Equals(scopeId, assignment?.StationGroupId, StringComparison.Ordinal), + "economic-region" => string.Equals(scopeId, assignment?.EconomicRegionId, StringComparison.Ordinal), + "front" => string.Equals(scopeId, assignment?.FrontId, StringComparison.Ordinal), + "reserve" => string.Equals(scopeId, assignment?.ReserveId, StringComparison.Ordinal), + _ => false, + }; + } + + private static int ScopePriority(string scopeKind) => scopeKind switch + { + "ship" or "station" or "asset" => 100, + "task-force" => 90, + "fleet" or "station-group" or "reserve" => 80, + "economic-region" or "front" => 70, + "player-faction" => 10, + _ => 0, + }; + + private static PlayerAssignmentRuntime GetOrCreateAssignment(PlayerFactionRuntime player, string assetKind, string assetId) + { + var assignment = player.Assignments.FirstOrDefault(candidate => + string.Equals(candidate.AssetKind, assetKind, StringComparison.Ordinal) && + string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal)); + if (assignment is not null) + { + return assignment; + } + + assignment = new PlayerAssignmentRuntime + { + Id = $"assignment-{assetKind}-{assetId}", + AssetKind = assetKind, + AssetId = assetId, + }; + player.Assignments.Add(assignment); + return assignment; + } + + private static void AddMembership(Dictionary> memberships, string assetId, string organizationId) + { + if (!memberships.TryGetValue(assetId, out var values)) + { + values = []; + memberships[assetId] = values; + } + + if (!values.Contains(organizationId, StringComparer.Ordinal)) + { + values.Add(organizationId); + } + } + + private static string? SelectSingleMembership(Dictionary> memberships, string assetId) => + memberships.TryGetValue(assetId, out var values) + ? values.OrderBy(value => value, StringComparer.Ordinal).FirstOrDefault() + : null; + + private static void RemoveAssetFromOrganizations(PlayerFactionRuntime player, string assetKind, string assetId) + { + foreach (var fleet in player.Fleets) + { + fleet.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + foreach (var taskForce in player.TaskForces) + { + taskForce.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + foreach (var reserve in player.Reserves) + { + reserve.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + if (assetKind == "station") + { + foreach (var group in player.StationGroups) + { + group.StationIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + } + } + + private static void AddAssetToFleet(PlayerFactionRuntime player, string fleetId, string assetId) + { + var fleet = player.Fleets.FirstOrDefault(entity => entity.Id == fleetId) + ?? throw new InvalidOperationException($"Unknown fleet '{fleetId}'."); + if (!fleet.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + fleet.AssetIds.Add(assetId); + } + } + + private static void AddAssetToTaskForce(PlayerFactionRuntime player, string taskForceId, string assetId) + { + var taskForce = player.TaskForces.FirstOrDefault(entity => entity.Id == taskForceId) + ?? throw new InvalidOperationException($"Unknown task force '{taskForceId}'."); + if (!taskForce.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + taskForce.AssetIds.Add(assetId); + } + } + + private static void AddAssetToStationGroup(PlayerFactionRuntime player, string groupId, string assetId) + { + var group = player.StationGroups.FirstOrDefault(entity => entity.Id == groupId) + ?? throw new InvalidOperationException($"Unknown station group '{groupId}'."); + if (!group.StationIds.Contains(assetId, StringComparer.Ordinal)) + { + group.StationIds.Add(assetId); + } + } + + private static void AddAssetToReserve(PlayerFactionRuntime player, string reserveId, string assetId) + { + var reserve = player.Reserves.FirstOrDefault(entity => entity.Id == reserveId) + ?? throw new InvalidOperationException($"Unknown reserve '{reserveId}'."); + if (!reserve.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + reserve.AssetIds.Add(assetId); + } + } + + private static void RefreshAlerts(SimulationWorld world, PlayerFactionRuntime player) + { + player.Alerts.Clear(); + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => player.Fleets.Count(fleet => fleet.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-fleet-{shipId}", + Kind = "conflicting-fleet-membership", + Severity = "warning", + Summary = $"Ship {shipId} belongs to multiple fleets.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => player.TaskForces.Count(taskForce => taskForce.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-task-force-{shipId}", + Kind = "conflicting-task-force-membership", + Severity = "warning", + Summary = $"Ship {shipId} belongs to multiple task forces.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var stationId in player.AssetRegistry.StationIds + .Where(stationId => player.StationGroups.Count(group => group.StationIds.Contains(stationId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-station-group-{stationId}", + Kind = "conflicting-station-group-membership", + Severity = "warning", + Summary = $"Station {stationId} belongs to multiple station groups.", + AssetKind = "station", + AssetId = stationId, + }); + } + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => !player.Assignments.Any(assignment => assignment.AssetKind == "ship" && assignment.AssetId == shipId)) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(10)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-unassigned-ship-{shipId}", + Kind = "unassigned-ship", + Severity = "warning", + Summary = $"Ship {shipId} has no player assignment.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var stationId in player.AssetRegistry.StationIds + .Where(stationId => !player.Assignments.Any(assignment => assignment.AssetKind == "station" && assignment.AssetId == stationId)) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-unassigned-station-{stationId}", + Kind = "unassigned-station", + Severity = "info", + Summary = $"Station {stationId} is not part of a player station group.", + AssetKind = "station", + AssetId = stationId, + }); + } + + foreach (var directive in player.Directives.Where(directive => + directive.Status == "active" && + !player.Assignments.Any(assignment => assignment.DirectiveId == directive.Id)).Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-orphan-directive-{directive.Id}", + Kind = "orphan-directive", + Severity = "warning", + Summary = $"Directive {directive.Label} is not assigned to any asset or group.", + RelatedDirectiveId = directive.Id, + }); + } + + foreach (var policy in player.ReinforcementPolicies + .Where(policy => policy.DesiredAssetCount > 0) + .OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Take(6)) + { + var available = world.Ships.Count(ship => + ship.FactionId == player.SovereignFactionId && + string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal)); + if (available < policy.DesiredAssetCount) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-reinforcement-{policy.Id}", + Kind = "reinforcement-deficit", + Severity = "warning", + Summary = $"Reinforcement policy {policy.Label} is short {policy.DesiredAssetCount - available} {policy.ShipKind} assets.", + }); + } + } + + foreach (var program in player.ProductionPrograms + .Where(program => program.TargetCount > 0 && program.CurrentCount < program.TargetCount) + .OrderBy(program => program.Id, StringComparer.Ordinal) + .Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-production-{program.Id}", + Kind = "production-program-deficit", + Severity = "info", + Summary = $"Production program {program.Label} is at {program.CurrentCount}/{program.TargetCount}.", + }); + } + + foreach (var systemId in world.Geopolitics?.Territory.ControlStates + .Where(state => state.IsContested + && (string.Equals(state.ControllerFactionId, player.SovereignFactionId, StringComparison.Ordinal) + || string.Equals(state.PrimaryClaimantFactionId, player.SovereignFactionId, StringComparison.Ordinal) + || state.ClaimantFactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal))) + .Select(state => state.SystemId) + .Where(systemId => player.Fronts.All(front => !front.SystemIds.Contains(systemId, StringComparer.Ordinal))) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .Take(4) ?? []) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-contested-system-{systemId}", + Kind = "uncovered-contested-system", + Severity = "warning", + Summary = $"Contested player system {systemId} is not covered by a player front.", + }); + } + + foreach (var region in player.EconomicRegions.Take(6)) + { + var sharedRegion = world.Geopolitics?.EconomyRegions.Regions.FirstOrDefault(candidate => + string.Equals(candidate.FactionId, player.SovereignFactionId, StringComparison.Ordinal) + && candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Any()); + if (sharedRegion is null) + { + continue; + } + + var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Severity) + .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) + .FirstOrDefault(); + var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)); + if (bottleneck is not null && bottleneck.Severity >= 2.5f) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-region-bottleneck-{region.Id}-{bottleneck.ItemId}", + Kind = "economic-region-bottleneck", + Severity = "warning", + Summary = $"Region {region.Label} is bottlenecked on {bottleneck.ItemId}.", + }); + } + if ((security?.SupplyRisk ?? 0f) >= 0.55f) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-region-risk-{region.Id}", + Kind = "economic-region-risk", + Severity = "warning", + Summary = $"Region {region.Label} has elevated logistics risk.", + }); + } + } + + foreach (var front in player.Fronts + .Where(front => !string.IsNullOrWhiteSpace(front.TargetFactionId)) + .Take(6)) + { + var relation = GeopoliticalSimulationService.FindRelation(world, player.SovereignFactionId, front.TargetFactionId!); + if (relation is not null + && relation.Posture is not "hostile" and not "war" + && front.Priority >= 60f + && !string.Equals(front.Posture, "hold", StringComparison.Ordinal)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-front-posture-{front.Id}", + Kind = "front-diplomatic-misalignment", + Severity = "info", + Summary = $"Front {front.Label} targets {front.TargetFactionId} while diplomatic posture is {relation.Posture}.", + }); + } + } + + while (player.Alerts.Count > MaxAlerts) + { + player.Alerts.RemoveAt(player.Alerts.Count - 1); + } + } + + private static void AddDecision(PlayerFactionRuntime player, string kind, string summary, string? relatedKind, string? relatedId) + { + player.DecisionLog.Insert(0, new PlayerDecisionLogEntryRuntime + { + Id = $"player-decision-{Guid.NewGuid():N}", + Kind = kind, + Summary = summary, + RelatedEntityKind = relatedKind, + RelatedEntityId = relatedId, + OccurredAtUtc = DateTimeOffset.UtcNow, + }); + + while (player.DecisionLog.Count > MaxDecisionEntries) + { + player.DecisionLog.RemoveAt(player.DecisionLog.Count - 1); + } + } + + private static PolicySetRuntime EnsurePolicySet(SimulationWorld world, PlayerFactionRuntime player, PlayerFactionPolicyRuntime policy, string? requestedPolicySetId) + { + if (requestedPolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == requestedPolicySetId) is { } existing) + { + return existing; + } + + if (policy.PolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } current) + { + return current; + } + + var created = new PolicySetRuntime + { + Id = $"policy-player-{policy.Id}", + OwnerKind = "player-faction-policy", + OwnerId = policy.Id, + }; + world.Policies.Add(created); + player.AssetRegistry.PolicySetIds.Add(created.Id); + return created; + } + + private static void CopyPolicySetToPlayerPolicy(PolicySetRuntime policySet, PlayerFactionPolicyRuntime policy) + { + policy.TradeAccessPolicy = policySet.TradeAccessPolicy; + policy.DockingAccessPolicy = policySet.DockingAccessPolicy; + policy.ConstructionAccessPolicy = policySet.ConstructionAccessPolicy; + policy.OperationalRangePolicy = policySet.OperationalRangePolicy; + policy.CombatEngagementPolicy = policySet.CombatEngagementPolicy; + policy.AvoidHostileSystems = policySet.AvoidHostileSystems; + policy.FleeHullRatio = policySet.FleeHullRatio; + policy.BlacklistedSystemIds.Clear(); + foreach (var systemId in policySet.BlacklistedSystemIds) + { + policy.BlacklistedSystemIds.Add(systemId); + } + } + + private static void ApplyPolicySetRequest(PolicySetRuntime policySet, PlayerPolicyCommandRequest request) + { + if (request.TradeAccessPolicy is not null) + { + policySet.TradeAccessPolicy = request.TradeAccessPolicy; + } + if (request.DockingAccessPolicy is not null) + { + policySet.DockingAccessPolicy = request.DockingAccessPolicy; + } + if (request.ConstructionAccessPolicy is not null) + { + policySet.ConstructionAccessPolicy = request.ConstructionAccessPolicy; + } + if (request.OperationalRangePolicy is not null) + { + policySet.OperationalRangePolicy = request.OperationalRangePolicy; + } + if (request.CombatEngagementPolicy is not null) + { + policySet.CombatEngagementPolicy = request.CombatEngagementPolicy; + } + if (request.AvoidHostileSystems.HasValue) + { + policySet.AvoidHostileSystems = request.AvoidHostileSystems.Value; + } + if (request.FleeHullRatio.HasValue) + { + policySet.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); + } + policySet.BlacklistedSystemIds.Clear(); + foreach (var systemId in request.BlacklistedSystemIds ?? []) + { + policySet.BlacklistedSystemIds.Add(systemId); + } + } + + private static string? ResolvePolicySetId(SimulationWorld world, PlayerFactionRuntime player, string? policyId) + { + if (policyId is null) + { + return player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy")?.PolicySetId; + } + + return player.Policies.FirstOrDefault(policy => policy.Id == policyId)?.PolicySetId + ?? world.Policies.FirstOrDefault(policy => policy.Id == policyId)?.Id; + } + + private static void RemoveOrganization(PlayerFactionRuntime player, string organizationId) + { + if (player.Fleets.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.FleetIds.Remove(organizationId); + return; + } + if (player.TaskForces.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.TaskForceIds.Remove(organizationId); + return; + } + if (player.StationGroups.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.StationGroupIds.Remove(organizationId); + return; + } + if (player.EconomicRegions.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.EconomicRegionIds.Remove(organizationId); + return; + } + if (player.Fronts.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.FrontIds.Remove(organizationId); + return; + } + if (player.Reserves.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.ReserveIds.Remove(organizationId); + return; + } + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); } - ReconcileOrganizationAssignments(world, player); - ReconcileDirectiveScopes(player); - AddDecision(player, "membership-updated", $"Updated membership for {organizationId}.", "organization", organizationId); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - return player; - } - - internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request) - { - var player = EnsureDomain(world); - var directive = directiveId is null - ? null - : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); - if (directive is null) + private static string ResolveOrganizationKind(PlayerFactionRuntime player, string organizationId) { - directive = new PlayerDirectiveRuntime - { - Id = directiveId ?? CreateDomainId("directive", request.Label, player.Directives.Select(candidate => candidate.Id)), - Label = request.Label, - CreatedAtUtc = DateTimeOffset.UtcNow, - }; - player.Directives.Add(directive); + if (player.Fleets.Any(entity => entity.Id == organizationId)) return "fleet"; + if (player.TaskForces.Any(entity => entity.Id == organizationId)) return "task-force"; + if (player.StationGroups.Any(entity => entity.Id == organizationId)) return "station-group"; + if (player.EconomicRegions.Any(entity => entity.Id == organizationId)) return "economic-region"; + if (player.Fronts.Any(entity => entity.Id == organizationId)) return "front"; + if (player.Reserves.Any(entity => entity.Id == organizationId)) return "reserve"; + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); } - directive.Label = request.Label; - directive.Kind = request.Kind; - directive.ScopeKind = request.ScopeKind; - directive.ScopeId = request.ScopeId; - directive.BehaviorKind = request.BehaviorKind; - directive.UseOrders = request.UseOrders; - directive.StagingOrderKind = request.StagingOrderKind; - directive.TargetEntityId = request.TargetEntityId; - directive.TargetSystemId = request.TargetSystemId; - directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); - directive.HomeSystemId = request.HomeSystemId; - directive.HomeStationId = request.HomeStationId; - directive.SourceStationId = request.SourceStationId; - directive.DestinationStationId = request.DestinationStationId; - directive.ItemId = request.ItemId; - directive.PreferredNodeId = request.PreferredNodeId; - directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; - directive.PreferredModuleId = request.PreferredModuleId; - directive.Priority = request.Priority; - directive.Radius = MathF.Max(0f, request.Radius ?? directive.Radius); - directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? directive.WaitSeconds); - directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? directive.MaxSystemRange); - directive.KnownStationsOnly = request.KnownStationsOnly ?? directive.KnownStationsOnly; - directive.PatrolPoints.Clear(); - foreach (var point in request.PatrolPoints ?? []) + private static IEnumerable ExistingOrganizationIds(PlayerFactionRuntime player) => + player.Fleets.Select(entity => entity.Id) + .Concat(player.TaskForces.Select(entity => entity.Id)) + .Concat(player.StationGroups.Select(entity => entity.Id)) + .Concat(player.EconomicRegions.Select(entity => entity.Id)) + .Concat(player.Fronts.Select(entity => entity.Id)) + .Concat(player.Reserves.Select(entity => entity.Id)); + + private static string NormalizeKind(string value) => + value.Trim().ToLowerInvariant(); + + private static string CreateDomainId(string prefix, string label, IEnumerable existingIds) { - directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); - } - directive.RepeatOrders.Clear(); - foreach (var template in request.RepeatOrders ?? []) - { - directive.RepeatOrders.Add(new ShipOrderTemplateRuntime - { - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), - Radius = MathF.Max(0f, template.Radius ?? 0f), - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly ?? false, - }); - } - directive.PolicyId = request.PolicyId; - directive.AutomationPolicyId = request.AutomationPolicyId; - directive.Notes = request.Notes; - directive.UpdatedAtUtc = DateTimeOffset.UtcNow; - - AddDecision(player, "directive-upserted", $"Updated directive {directive.Label}.", "directive", directive.Id); - player.UpdatedAtUtc = directive.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId) - { - var player = EnsureDomain(world); - player.Directives.RemoveAll(directive => directive.Id == directiveId); - foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId)) - { - assignment.DirectiveId = null; - } - ReconcileDirectiveScopes(player); - AddDecision(player, "directive-deleted", $"Removed directive {directiveId}.", "directive", directiveId); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - return player; - } - - internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request) - { - var player = EnsureDomain(world); - var policy = policyId is null - ? null - : player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); - if (policy is null) - { - policy = new PlayerFactionPolicyRuntime - { - Id = policyId ?? CreateDomainId("policy", request.Label, player.Policies.Select(candidate => candidate.Id)), - Label = request.Label, - }; - player.Policies.Add(policy); - } - - policy.Label = request.Label; - policy.ScopeKind = request.ScopeKind; - policy.ScopeId = request.ScopeId; - policy.AllowDelegatedCombat = request.AllowDelegatedCombat; - policy.AllowDelegatedTrade = request.AllowDelegatedTrade; - policy.ReserveCreditsRatio = Math.Clamp(request.ReserveCreditsRatio, 0f, 1f); - policy.ReserveMilitaryRatio = Math.Clamp(request.ReserveMilitaryRatio, 0f, 1f); - if (request.TradeAccessPolicy is not null) - { - policy.TradeAccessPolicy = request.TradeAccessPolicy; - } - if (request.DockingAccessPolicy is not null) - { - policy.DockingAccessPolicy = request.DockingAccessPolicy; - } - if (request.ConstructionAccessPolicy is not null) - { - policy.ConstructionAccessPolicy = request.ConstructionAccessPolicy; - } - if (request.OperationalRangePolicy is not null) - { - policy.OperationalRangePolicy = request.OperationalRangePolicy; - } - if (request.CombatEngagementPolicy is not null) - { - policy.CombatEngagementPolicy = request.CombatEngagementPolicy; - } - if (request.AvoidHostileSystems.HasValue) - { - policy.AvoidHostileSystems = request.AvoidHostileSystems.Value; - } - if (request.FleeHullRatio.HasValue) - { - policy.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); - } - if (request.BlacklistedSystemIds is not null) - { - policy.BlacklistedSystemIds.Clear(); - foreach (var systemId in request.BlacklistedSystemIds) - { - policy.BlacklistedSystemIds.Add(systemId); - } - } - policy.Notes = request.Notes; - policy.UpdatedAtUtc = DateTimeOffset.UtcNow; - - var policySet = EnsurePolicySet(world, player, policy, request.PolicySetId); - ApplyPolicySetRequest(policySet, request); - policy.PolicySetId = policySet.Id; - - AddDecision(player, "policy-upserted", $"Updated policy {policy.Label}.", "policy", policy.Id); - player.UpdatedAtUtc = policy.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) - { - var player = EnsureDomain(world); - var policy = automationPolicyId is null - ? null - : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); - if (policy is null) - { - policy = new PlayerAutomationPolicyRuntime - { - Id = automationPolicyId ?? CreateDomainId("automation", request.Label, player.AutomationPolicies.Select(candidate => candidate.Id)), - Label = request.Label, - }; - player.AutomationPolicies.Add(policy); - } - - policy.Label = request.Label; - policy.ScopeKind = request.ScopeKind; - policy.ScopeId = request.ScopeId; - policy.Enabled = request.Enabled; - policy.BehaviorKind = request.BehaviorKind; - policy.UseOrders = request.UseOrders; - policy.StagingOrderKind = request.StagingOrderKind; - policy.MaxSystemRange = Math.Max(0, request.MaxSystemRange); - policy.KnownStationsOnly = request.KnownStationsOnly; - policy.Radius = MathF.Max(0f, request.Radius); - policy.WaitSeconds = MathF.Max(0f, request.WaitSeconds); - policy.PreferredItemId = request.PreferredItemId; - policy.Notes = request.Notes; - policy.RepeatOrders.Clear(); - foreach (var template in request.RepeatOrders ?? []) - { - policy.RepeatOrders.Add(new ShipOrderTemplateRuntime - { - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), - Radius = MathF.Max(0f, template.Radius ?? 0f), - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly ?? false, - }); - } - policy.UpdatedAtUtc = DateTimeOffset.UtcNow; - - AddDecision(player, "automation-upserted", $"Updated automation policy {policy.Label}.", "automation-policy", policy.Id); - player.UpdatedAtUtc = policy.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) - { - var player = EnsureDomain(world); - var policy = reinforcementPolicyId is null - ? null - : player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); - if (policy is null) - { - policy = new PlayerReinforcementPolicyRuntime - { - Id = reinforcementPolicyId ?? CreateDomainId("reinforcement", request.Label, player.ReinforcementPolicies.Select(candidate => candidate.Id)), - Label = request.Label, - }; - player.ReinforcementPolicies.Add(policy); - } - - policy.Label = request.Label; - policy.ScopeKind = request.ScopeKind; - policy.ScopeId = request.ScopeId; - policy.ShipKind = request.ShipKind; - policy.DesiredAssetCount = Math.Max(0, request.DesiredAssetCount); - policy.MinimumReserveCount = Math.Max(0, request.MinimumReserveCount); - policy.AutoTransferReserves = request.AutoTransferReserves; - policy.AutoQueueProduction = request.AutoQueueProduction; - policy.SourceReserveId = request.SourceReserveId; - policy.TargetFrontId = request.TargetFrontId; - policy.Notes = request.Notes; - policy.UpdatedAtUtc = DateTimeOffset.UtcNow; - - AddDecision(player, "reinforcement-upserted", $"Updated reinforcement policy {policy.Label}.", "reinforcement-policy", policy.Id); - player.UpdatedAtUtc = policy.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request) - { - var player = EnsureDomain(world); - var program = productionProgramId is null - ? null - : player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); - if (program is null) - { - program = new PlayerProductionProgramRuntime - { - Id = productionProgramId ?? CreateDomainId("production", request.Label, player.ProductionPrograms.Select(candidate => candidate.Id)), - Label = request.Label, - }; - player.ProductionPrograms.Add(program); - } - - program.Label = request.Label; - program.Kind = request.Kind; - program.TargetShipKind = request.TargetShipKind; - program.TargetModuleId = request.TargetModuleId; - program.TargetItemId = request.TargetItemId; - program.TargetCount = Math.Max(0, request.TargetCount); - program.StationGroupId = request.StationGroupId; - program.ReinforcementPolicyId = request.ReinforcementPolicyId; - program.Notes = request.Notes; - program.UpdatedAtUtc = DateTimeOffset.UtcNow; - - AddDecision(player, "production-upserted", $"Updated production program {program.Label}.", "production-program", program.Id); - player.UpdatedAtUtc = program.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request) - { - var player = EnsureDomain(world); - var assignment = player.Assignments.FirstOrDefault(candidate => - string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && - string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal)); - if (assignment is null) - { - assignment = new PlayerAssignmentRuntime - { - Id = $"assignment-{request.AssetKind}-{assetId}", - AssetKind = request.AssetKind, - AssetId = assetId, - }; - player.Assignments.Add(assignment); - } - - if (request.ClearConflicts) - { - RemoveAssetFromOrganizations(player, request.AssetKind, assetId); - } - - if (request.FleetId is not null) - { - AddAssetToFleet(player, request.FleetId, assetId); - } - if (request.TaskForceId is not null) - { - AddAssetToTaskForce(player, request.TaskForceId, assetId); - } - if (request.StationGroupId is not null) - { - AddAssetToStationGroup(player, request.StationGroupId, assetId); - } - if (request.ReserveId is not null) - { - AddAssetToReserve(player, request.ReserveId, assetId); - } - - assignment.FleetId = request.FleetId; - assignment.TaskForceId = request.TaskForceId; - assignment.StationGroupId = request.StationGroupId; - assignment.EconomicRegionId = request.EconomicRegionId ?? assignment.EconomicRegionId; - assignment.FrontId = request.FrontId ?? assignment.FrontId; - assignment.ReserveId = request.ReserveId; - assignment.DirectiveId = request.DirectiveId; - assignment.PolicyId = request.PolicyId; - assignment.AutomationPolicyId = request.AutomationPolicyId; - assignment.Role = request.Role; - assignment.Status = "active"; - assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; - - ReconcileOrganizationAssignments(world, player); - ReconcileDirectiveScopes(player); - AddDecision(player, "assignment-upserted", $"Assigned {request.AssetKind} {assetId}.", request.AssetKind, assetId); - player.UpdatedAtUtc = assignment.UpdatedAtUtc; - return player; - } - - internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request) - { - var player = EnsureDomain(world); - player.StrategicIntent.StrategicPosture = request.StrategicPosture; - player.StrategicIntent.EconomicPosture = request.EconomicPosture; - player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; - player.StrategicIntent.LogisticsPosture = request.LogisticsPosture; - player.StrategicIntent.DesiredReserveRatio = Math.Clamp(request.DesiredReserveRatio, 0f, 1f); - player.StrategicIntent.AllowDelegatedCombatAutomation = request.AllowDelegatedCombatAutomation; - player.StrategicIntent.AllowDelegatedEconomicAutomation = request.AllowDelegatedEconomicAutomation; - player.StrategicIntent.Notes = request.Notes; - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - AddDecision(player, "strategic-intent-updated", "Updated player strategic intent.", "player-faction", player.Id); - return player; - } - - internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request) - { - var player = EnsureDomain(world); - if (!player.AssetRegistry.ShipIds.Contains(shipId)) - { - return null; - } - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); - if (ship is null) - { - return null; - } - - if (ship.OrderQueue.Count >= 8) - { - throw new InvalidOperationException("Order queue is full."); - } - - ship.OrderQueue.Add(new ShipOrderRuntime - { - Id = $"order-{ship.Id}-{Guid.NewGuid():N}", - Kind = request.Kind, - Priority = request.Priority, - InterruptCurrentPlan = request.InterruptCurrentPlan, - Label = request.Label, - TargetEntityId = request.TargetEntityId, - TargetSystemId = request.TargetSystemId, - TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z), - SourceStationId = request.SourceStationId, - DestinationStationId = request.DestinationStationId, - ItemId = request.ItemId, - NodeId = request.NodeId, - ConstructionSiteId = request.ConstructionSiteId, - ModuleId = request.ModuleId, - WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), - Radius = MathF.Max(0f, request.Radius ?? 0f), - MaxSystemRange = request.MaxSystemRange, - KnownStationsOnly = request.KnownStationsOnly ?? false, - }); - - AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - ship.ControlSourceKind = "player-order"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); - ship.ControlReason = request.Label ?? request.Kind; - ship.NeedsReplan = true; - ship.LastReplanReason = "player-order-enqueued"; - ship.LastDeltaSignature = string.Empty; - return ship; - } - - internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId) - { - var player = EnsureDomain(world); - if (!player.AssetRegistry.ShipIds.Contains(shipId)) - { - return null; - } - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); - if (ship is null) - { - return null; - } - - var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); - if (removed > 0) - { - AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId); - player.UpdatedAtUtc = DateTimeOffset.UtcNow; - } - - ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - ? "player-order" - : "player-manual"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); - ship.ControlReason = ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Label ?? order.Kind) - .FirstOrDefault() - ?? "manual-player-control"; - ship.NeedsReplan = true; - ship.LastReplanReason = "player-order-removed"; - ship.LastDeltaSignature = string.Empty; - return ship; - } - - internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request) - { - var player = EnsureDomain(world); - if (!player.AssetRegistry.ShipIds.Contains(shipId)) - { - return null; - } - - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); - if (ship is null) - { - return null; - } - - var directiveId = $"player-directive-ship-{shipId}"; - var directive = player.Directives.FirstOrDefault(candidate => candidate.Id == directiveId); - if (directive is null) - { - directive = new PlayerDirectiveRuntime - { - Id = directiveId, - Label = $"Direct control {ship.Definition.Label}", - ScopeKind = "ship", - ScopeId = shipId, - Kind = "direct-control", - CreatedAtUtc = DateTimeOffset.UtcNow, - }; - player.Directives.Add(directive); - } - - directive.Label = $"Direct control {ship.Definition.Label}"; - directive.Kind = "direct-control"; - directive.ScopeKind = "ship"; - directive.ScopeId = shipId; - directive.BehaviorKind = request.Kind; - directive.UseOrders = false; - directive.StagingOrderKind = null; - directive.TargetEntityId = request.TargetEntityId; - directive.TargetSystemId = request.AreaSystemId; - directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); - directive.HomeSystemId = request.HomeSystemId ?? ship.SystemId; - directive.HomeStationId = request.HomeStationId; - directive.SourceStationId = request.HomeStationId; - directive.DestinationStationId = null; - directive.ItemId = request.PreferredItemId; - directive.PreferredNodeId = request.PreferredNodeId; - directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; - directive.PreferredModuleId = request.PreferredModuleId; - directive.Priority = 100; - directive.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius); - directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds); - directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange); - directive.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly; - directive.PatrolPoints.Clear(); - foreach (var point in request.PatrolPoints ?? []) - { - directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); - } - directive.RepeatOrders.Clear(); - foreach (var template in request.RepeatOrders ?? []) - { - directive.RepeatOrders.Add(new ShipOrderTemplateRuntime - { - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), - Radius = MathF.Max(0f, template.Radius ?? 0f), - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly ?? false, - }); - } - directive.UpdatedAtUtc = DateTimeOffset.UtcNow; - - var assignment = GetOrCreateAssignment(player, "ship", shipId); - assignment.DirectiveId = directive.Id; - assignment.Status = "active"; - assignment.UpdatedAtUtc = directive.UpdatedAtUtc; - - ApplyBehavior(ship.DefaultBehavior, BuildDirectiveBehavior(ship, directive, null)); - ship.ControlSourceKind = "player-directive"; - ship.ControlSourceId = directive.Id; - ship.ControlReason = directive.Label; - AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId); - player.UpdatedAtUtc = directive.UpdatedAtUtc; - ship.NeedsReplan = true; - ship.LastReplanReason = "player-behavior-configured"; - ship.LastDeltaSignature = string.Empty; - return ship; - } - - private static void EnsureBaseStructures(SimulationWorld world, PlayerFactionRuntime player) - { - if (player.Policies.Count == 0) - { - var sovereign = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, player.SovereignFactionId, StringComparison.Ordinal)); - player.Policies.Add(new PlayerFactionPolicyRuntime - { - Id = "player-core-policy", - Label = "Core Empire Policy", - PolicySetId = sovereign?.DefaultPolicySetId, - }); - - if (sovereign?.DefaultPolicySetId is { } defaultPolicySetId - && world.Policies.FirstOrDefault(policy => policy.Id == defaultPolicySetId) is { } defaultPolicySet) - { - CopyPolicySetToPlayerPolicy(defaultPolicySet, player.Policies[0]); - } - } - - if (player.AutomationPolicies.Count == 0) - { - player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime - { - Id = "player-core-automation", - Label = "Core Automation", - BehaviorKind = "idle", - }); - } - - if (player.Reserves.Count == 0) - { - player.Reserves.Add(new PlayerReserveGroupRuntime - { - Id = "player-core-reserve", - Label = "Strategic Reserve", - ReserveKind = "military", - }); - player.AssetRegistry.ReserveIds.Add("player-core-reserve"); - } - } - - private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player) - { - SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id)); - SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id)); - SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id)); - SyncSet(player.AssetRegistry.ClaimIds, world.Claims.Where(claim => claim.FactionId == player.SovereignFactionId).Select(claim => claim.Id)); - SyncSet(player.AssetRegistry.ConstructionSiteIds, world.ConstructionSites.Where(site => site.FactionId == player.SovereignFactionId).Select(site => site.Id)); - SyncSet(player.AssetRegistry.PolicySetIds, world.Policies.Where(policy => policy.OwnerId == player.SovereignFactionId || player.Policies.Any(entry => entry.PolicySetId == policy.Id)).Select(policy => policy.Id)); - SyncSet(player.AssetRegistry.MarketOrderIds, world.MarketOrders.Where(order => order.FactionId == player.SovereignFactionId).Select(order => order.Id)); - SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id)); - SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id)); - SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id)); - SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id)); - SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id)); - SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id)); - } - - private static void PrunePlayerState(SimulationWorld world, PlayerFactionRuntime player) - { - var shipIds = player.AssetRegistry.ShipIds; - var stationIds = player.AssetRegistry.StationIds; - var frontIds = player.AssetRegistry.FrontIds; - var fleetIds = player.AssetRegistry.FleetIds; - var reserveIds = player.AssetRegistry.ReserveIds; - var taskForceIds = player.AssetRegistry.TaskForceIds; - var stationGroupIds = player.AssetRegistry.StationGroupIds; - var regionIds = player.AssetRegistry.EconomicRegionIds; - var directiveIds = player.Directives.Select(directive => directive.Id).ToHashSet(StringComparer.Ordinal); - var policyIds = player.Policies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); - var automationIds = player.AutomationPolicies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); - - foreach (var fleet in player.Fleets) - { - fleet.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); - fleet.TaskForceIds.RemoveAll(taskForceId => !taskForceIds.Contains(taskForceId)); - fleet.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); - } - - foreach (var taskForce in player.TaskForces) - { - taskForce.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); - taskForce.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); - if (taskForce.FleetId is not null && !fleetIds.Contains(taskForce.FleetId)) - { - taskForce.FleetId = null; - } - } - - foreach (var group in player.StationGroups) - { - group.StationIds.RemoveAll(stationId => !stationIds.Contains(stationId)); - group.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); - if (group.EconomicRegionId is not null && !regionIds.Contains(group.EconomicRegionId)) - { - group.EconomicRegionId = null; - } - } - - foreach (var region in player.EconomicRegions) - { - region.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); - region.StationGroupIds.RemoveAll(groupId => !stationGroupIds.Contains(groupId)); - region.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); - } - - foreach (var front in player.Fronts) - { - front.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); - front.FleetIds.RemoveAll(fleetId => !fleetIds.Contains(fleetId)); - front.ReserveIds.RemoveAll(reserveId => !reserveIds.Contains(reserveId)); - front.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); - } - - foreach (var reserve in player.Reserves) - { - reserve.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); - reserve.FrontIds.RemoveAll(frontId => !frontIds.Contains(frontId)); - } - - player.Assignments.RemoveAll(assignment => - (assignment.AssetKind == "ship" && !shipIds.Contains(assignment.AssetId)) || - (assignment.AssetKind == "station" && !stationIds.Contains(assignment.AssetId))); - - foreach (var assignment in player.Assignments) - { - if (assignment.FleetId is not null && !fleetIds.Contains(assignment.FleetId)) - { - assignment.FleetId = null; - } - if (assignment.TaskForceId is not null && !taskForceIds.Contains(assignment.TaskForceId)) - { - assignment.TaskForceId = null; - } - if (assignment.StationGroupId is not null && !stationGroupIds.Contains(assignment.StationGroupId)) - { - assignment.StationGroupId = null; - } - if (assignment.EconomicRegionId is not null && !regionIds.Contains(assignment.EconomicRegionId)) - { - assignment.EconomicRegionId = null; - } - if (assignment.FrontId is not null && !frontIds.Contains(assignment.FrontId)) - { - assignment.FrontId = null; - } - if (assignment.ReserveId is not null && !reserveIds.Contains(assignment.ReserveId)) - { - assignment.ReserveId = null; - } - if (assignment.DirectiveId is not null && !directiveIds.Contains(assignment.DirectiveId)) - { - assignment.DirectiveId = null; - } - if (assignment.PolicyId is not null && !policyIds.Contains(assignment.PolicyId)) - { - assignment.PolicyId = null; - } - if (assignment.AutomationPolicyId is not null && !automationIds.Contains(assignment.AutomationPolicyId)) - { - assignment.AutomationPolicyId = null; - } - } - } - - private static void ApplyPolicies(SimulationWorld world, PlayerFactionRuntime player) - { - foreach (var policy in player.Policies) - { - if (policy.PolicySetId is null) - { - continue; - } - - if (world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } policySet) - { - policySet.TradeAccessPolicy = policy.TradeAccessPolicy; - policySet.DockingAccessPolicy = policy.DockingAccessPolicy; - policySet.ConstructionAccessPolicy = policy.ConstructionAccessPolicy; - policySet.OperationalRangePolicy = policy.OperationalRangePolicy; - policySet.CombatEngagementPolicy = policy.CombatEngagementPolicy; - policySet.FleeHullRatio = Math.Clamp(policy.FleeHullRatio, 0.05f, 0.95f); - policySet.AvoidHostileSystems = policy.AvoidHostileSystems; - - policySet.BlacklistedSystemIds.Clear(); - foreach (var systemId in policy.BlacklistedSystemIds) + var slug = new string(label + .Trim() + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-') + .ToArray()) + .Trim('-'); + if (string.IsNullOrWhiteSpace(slug)) { - policySet.BlacklistedSystemIds.Add(systemId); - } - } - } - } - - private static void ApplyAssignmentsAndDirectives(SimulationWorld world, PlayerFactionRuntime player, ICollection events) - { - var factionCommander = world.Commanders.FirstOrDefault(commander => - commander.Kind == CommanderKind.Faction && - string.Equals(commander.FactionId, player.SovereignFactionId, StringComparison.Ordinal)); - if (factionCommander is null) - { - return; - } - - var fleetCommanders = EnsureFleetCommanders(world, player, factionCommander); - var taskForceCommanders = EnsureTaskForceCommanders(world, player, factionCommander, fleetCommanders); - var assignmentsByAsset = player.Assignments - .Where(assignment => assignment.Status == "active") - .GroupBy(assignment => $"{assignment.AssetKind}:{assignment.AssetId}", StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.OrderByDescending(item => item.UpdatedAtUtc).First(), StringComparer.Ordinal); - - foreach (var ship in world.Ships.Where(candidate => candidate.FactionId == player.SovereignFactionId)) - { - if (ship.CommanderId is null) - { - continue; - } - - var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); - if (commander is null) - { - continue; - } - - var assignment = ResolveAssignment(assignmentsByAsset, "ship", ship.Id); - var directive = ResolveDirective(player, assignment, "ship", ship.Id); - var automation = ResolveAutomation(player, assignment, directive, "ship", ship.Id); - var policy = ResolvePolicy(player, assignment, directive, "ship", ship.Id); - - commander.ParentCommanderId = ResolveParentCommanderId(factionCommander, assignment, fleetCommanders, taskForceCommanders); - commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; - ship.PolicySetId = commander.PolicySetId; - var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment); - if (changed && directive is not null) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id)); - } - } - - foreach (var station in world.Stations.Where(candidate => candidate.FactionId == player.SovereignFactionId)) - { - if (station.CommanderId is null) - { - continue; - } - - var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); - if (commander is null) - { - continue; - } - - var assignment = ResolveAssignment(assignmentsByAsset, "station", station.Id); - var directive = ResolveDirective(player, assignment, "station", station.Id); - var policy = ResolvePolicy(player, assignment, directive, "station", station.Id); - commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; - station.PolicySetId = commander.PolicySetId; - commander.Assignment = directive is null && assignment is null - ? null - : new CommanderAssignmentRuntime - { - ObjectiveId = directive?.Id ?? assignment?.StationGroupId ?? $"player-station-{station.Id}", - Kind = directive?.Kind ?? "player-station-control", - BehaviorKind = directive?.BehaviorKind ?? assignment?.Role ?? "station-control", - Status = directive?.Status ?? assignment?.Status ?? "active", - Priority = directive?.Priority ?? 40f, - HomeSystemId = directive?.HomeSystemId ?? station.SystemId, - HomeStationId = directive?.HomeStationId ?? station.Id, - TargetSystemId = directive?.TargetSystemId, - TargetEntityId = directive?.TargetEntityId, - TargetPosition = directive?.TargetPosition, - ItemId = directive?.ItemId, - Notes = directive?.Notes ?? assignment?.Role, - UpdatedAtUtc = directive?.UpdatedAtUtc ?? assignment?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, - }; - } - } - - private static Dictionary EnsureFleetCommanders(SimulationWorld world, PlayerFactionRuntime player, CommanderRuntime factionCommander) - { - var map = new Dictionary(StringComparer.Ordinal); - foreach (var fleet in player.Fleets) - { - var commander = world.Commanders.FirstOrDefault(candidate => - candidate.Kind == CommanderKind.Fleet && - candidate.FactionId == player.SovereignFactionId && - string.Equals(candidate.ControlledEntityId, fleet.Id, StringComparison.Ordinal)); - if (commander is null) - { - commander = new CommanderRuntime - { - Id = $"commander-player-fleet-{fleet.Id}", - Kind = CommanderKind.Fleet, - FactionId = player.SovereignFactionId, - ControlledEntityId = fleet.Id, - Doctrine = "player-fleet-control", - Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 4 }, - }; - world.Commanders.Add(commander); - } - - commander.ParentCommanderId = factionCommander.Id; - commander.PolicySetId = ResolvePolicySetId(world, player, fleet.PolicyId) ?? factionCommander.PolicySetId; - commander.Assignment = new CommanderAssignmentRuntime - { - ObjectiveId = fleet.Id, - Kind = "player-fleet", - BehaviorKind = fleet.Role, - Status = fleet.Status, - Priority = 80f, - HomeSystemId = fleet.HomeSystemId, - HomeStationId = fleet.HomeStationId, - Notes = fleet.Label, - UpdatedAtUtc = fleet.UpdatedAtUtc, - }; - fleet.CommanderId = commander.Id; - map[fleet.Id] = commander; - } - return map; - } - - private static Dictionary EnsureTaskForceCommanders( - SimulationWorld world, - PlayerFactionRuntime player, - CommanderRuntime factionCommander, - IReadOnlyDictionary fleetCommanders) - { - var map = new Dictionary(StringComparer.Ordinal); - foreach (var taskForce in player.TaskForces) - { - var commander = world.Commanders.FirstOrDefault(candidate => - candidate.Kind == CommanderKind.TaskGroup && - candidate.FactionId == player.SovereignFactionId && - string.Equals(candidate.ControlledEntityId, taskForce.Id, StringComparison.Ordinal)); - if (commander is null) - { - commander = new CommanderRuntime - { - Id = $"commander-player-task-force-{taskForce.Id}", - Kind = CommanderKind.TaskGroup, - FactionId = player.SovereignFactionId, - ControlledEntityId = taskForce.Id, - Doctrine = "player-task-force-control", - Skills = new CommanderSkillProfileRuntime { Leadership = 4, Coordination = 4, Strategy = 4 }, - }; - world.Commanders.Add(commander); - } - - commander.ParentCommanderId = taskForce.FleetId is not null && fleetCommanders.TryGetValue(taskForce.FleetId, out var fleetCommander) - ? fleetCommander.Id - : factionCommander.Id; - commander.PolicySetId = ResolvePolicySetId(world, player, taskForce.PolicyId) ?? factionCommander.PolicySetId; - commander.Assignment = new CommanderAssignmentRuntime - { - ObjectiveId = taskForce.Id, - Kind = "player-task-force", - BehaviorKind = taskForce.Role, - Status = taskForce.Status, - Priority = 75f, - Notes = taskForce.Label, - UpdatedAtUtc = taskForce.UpdatedAtUtc, - }; - taskForce.CommanderId = commander.Id; - map[taskForce.Id] = commander; - } - return map; - } - - private static string ResolveParentCommanderId( - CommanderRuntime factionCommander, - PlayerAssignmentRuntime? assignment, - IReadOnlyDictionary fleetCommanders, - IReadOnlyDictionary taskForceCommanders) - { - if (assignment?.TaskForceId is not null && taskForceCommanders.TryGetValue(assignment.TaskForceId, out var taskForceCommander)) - { - return taskForceCommander.Id; - } - - if (assignment?.FleetId is not null && fleetCommanders.TryGetValue(assignment.FleetId, out var fleetCommander)) - { - return fleetCommander.Id; - } - - return factionCommander.Id; - } - - private static PlayerAssignmentRuntime? ResolveAssignment( - IReadOnlyDictionary assignmentsByAsset, - string assetKind, - string assetId) => - assignmentsByAsset.GetValueOrDefault($"{assetKind}:{assetId}"); - - private static PlayerDirectiveRuntime? ResolveDirective(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, string assetKind, string assetId) - { - if (assignment?.DirectiveId is not null) - { - return player.Directives.FirstOrDefault(directive => directive.Id == assignment.DirectiveId); - } - - return SelectScopedDirective( - player.Directives.Where(directive => directive.Status == "active"), - player, - assignment, - assetKind, - assetId); - } - - private static PlayerAutomationPolicyRuntime? ResolveAutomation(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) - { - var automationId = assignment?.AutomationPolicyId ?? directive?.AutomationPolicyId; - if (automationId is not null) - { - return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId); - } - - return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId) - ?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation"); - } - - private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) - { - var policyId = assignment?.PolicyId ?? directive?.PolicyId; - if (policyId is not null) - { - return player.Policies.FirstOrDefault(policy => policy.Id == policyId); - } - - return SelectScopedFactionPolicy(player, assignment, assetKind, assetId) - ?? player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); - } - - private static bool ApplyDirectiveToShip( - CommanderRuntime commander, - ShipRuntime ship, - PlayerDirectiveRuntime? directive, - PlayerAutomationPolicyRuntime? automation, - PlayerAssignmentRuntime? assignment) - { - var desiredAssignment = BuildDirectiveAssignment(ship, directive, automation, assignment); - var desiredBehavior = BuildDirectiveBehavior(ship, directive, automation); - var hasBehaviorSource = directive is not null || automation is not null; - var desiredControlSourceKind = directive is not null - ? "player-directive" - : automation is not null - ? "player-automation" - : ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - ? "player-order" - : "player-manual"; - var desiredControlSourceId = directive?.Id - ?? automation?.Id - ?? ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); - var desiredControlReason = directive?.Label - ?? automation?.Label - ?? ship.OrderQueue - .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Label ?? order.Kind) - .FirstOrDefault() - ?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control"); - - var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment); - var behaviorChanged = hasBehaviorSource && !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior!); - var ordersChanged = ReconcileDirectiveOrders(ship, directive, automation); - var controlChanged = - !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) - || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) - || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); - - if (assignmentChanged) - { - commander.Assignment = desiredAssignment; - } - - if (behaviorChanged && desiredBehavior is not null) - { - ApplyBehavior(ship.DefaultBehavior, desiredBehavior); - } - - if (directive is null && automation is null) - { - ship.ControlSourceKind = desiredControlSourceKind; - ship.ControlSourceId = desiredControlSourceId; - ship.ControlReason = desiredControlReason; - var surfaceChanged = assignmentChanged || ordersChanged || controlChanged; - if (surfaceChanged) - { - ship.LastDeltaSignature = string.Empty; - } - - if (assignmentChanged || ordersChanged) - { - ship.NeedsReplan = true; - ship.LastReplanReason = assignmentChanged - ? "player-assignment-updated" - : ordersChanged - ? "player-order-updated" - : "player-control-updated"; - } - - return surfaceChanged; - } - - ship.ControlSourceKind = desiredControlSourceKind; - ship.ControlSourceId = desiredControlSourceId; - ship.ControlReason = desiredControlReason; - var changed = assignmentChanged || behaviorChanged || ordersChanged || controlChanged; - if (changed) - { - ship.LastDeltaSignature = string.Empty; - } - - if (assignmentChanged || behaviorChanged || ordersChanged) - { - ship.NeedsReplan = true; - ship.LastReplanReason = assignmentChanged - ? "player-assignment-updated" - : behaviorChanged - ? "player-behavior-updated" - : ordersChanged - ? "player-order-updated" - : "player-control-updated"; - } - - return changed; - } - - private static DefaultBehaviorRuntime BuildDirectiveBehavior(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) - { - return new DefaultBehaviorRuntime - { - Kind = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind, - HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId ?? ship.SystemId, - HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, - AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, - TargetEntityId = directive?.TargetEntityId, - PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId, - PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, - PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, - PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, - TargetPosition = directive?.TargetPosition, - WaitSeconds = directive?.WaitSeconds ?? automation?.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds, - Radius = directive?.Radius ?? automation?.Radius ?? ship.DefaultBehavior.Radius, - MaxSystemRange = directive?.MaxSystemRange ?? automation?.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange, - KnownStationsOnly = directive?.KnownStationsOnly ?? automation?.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly, - PatrolPoints = directive?.PatrolPoints.Select(point => point).ToList() ?? ship.DefaultBehavior.PatrolPoints.Select(point => point).ToList(), - PatrolIndex = ship.DefaultBehavior.PatrolIndex, - RepeatOrders = directive?.RepeatOrders.Select(CloneTemplate).ToList() - ?? automation?.RepeatOrders.Select(CloneTemplate).ToList() - ?? ship.DefaultBehavior.RepeatOrders.Select(CloneTemplate).ToList(), - RepeatIndex = ship.DefaultBehavior.RepeatIndex, - }; - } - - private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) - { - var aiOrderId = directive is null ? null : $"player-order-{directive.Id}"; - var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0; - - var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false; - if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind)) - { - return changed; - } - - var desiredOrder = new ShipOrderRuntime - { - Id = aiOrderId!, - Kind = directive.StagingOrderKind!, - Priority = Math.Max(0, directive.Priority), - InterruptCurrentPlan = true, - Label = directive.Label, - TargetEntityId = directive.TargetEntityId, - TargetSystemId = directive.TargetSystemId, - TargetPosition = directive.TargetPosition, - SourceStationId = directive.SourceStationId ?? directive.HomeStationId, - DestinationStationId = directive.DestinationStationId, - ItemId = directive.ItemId, - NodeId = directive.PreferredNodeId, - ConstructionSiteId = directive.PreferredConstructionSiteId, - ModuleId = directive.PreferredModuleId, - WaitSeconds = directive.WaitSeconds, - Radius = directive.Radius, - MaxSystemRange = directive.MaxSystemRange, - KnownStationsOnly = directive.KnownStationsOnly, - }; - - var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId); - if (existing is null) - { - ship.OrderQueue.Add(desiredOrder); - return true; - } - - if (!ShipOrdersEqual(existing, desiredOrder)) - { - ship.OrderQueue.Remove(existing); - ship.OrderQueue.Add(desiredOrder); - return true; - } - - return changed; - } - - private static CommanderAssignmentRuntime? BuildDirectiveAssignment( - ShipRuntime ship, - PlayerDirectiveRuntime? directive, - PlayerAutomationPolicyRuntime? automation, - PlayerAssignmentRuntime? assignment) - { - if (directive is null && automation is null) - { - return null; - } - - var behavior = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind; - return new CommanderAssignmentRuntime - { - ObjectiveId = directive?.Id ?? assignment?.DirectiveId ?? $"automation-{ship.Id}", - Kind = directive?.Kind ?? "player-automation", - BehaviorKind = behavior, - Status = directive?.Status ?? "active", - Priority = directive?.Priority ?? 50f, - HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId, - HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, - TargetSystemId = directive?.TargetSystemId, - TargetEntityId = directive?.TargetEntityId, - TargetPosition = directive?.TargetPosition, - ItemId = directive?.ItemId, - Notes = directive?.Notes ?? automation?.Notes, - UpdatedAtUtc = directive?.UpdatedAtUtc ?? automation?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, - }; - } - - private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) - { - target.Kind = source.Kind; - target.HomeSystemId = source.HomeSystemId; - target.HomeStationId = source.HomeStationId; - target.AreaSystemId = source.AreaSystemId; - target.TargetEntityId = source.TargetEntityId; - target.PreferredItemId = source.PreferredItemId; - target.PreferredNodeId = source.PreferredNodeId; - target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; - target.PreferredModuleId = source.PreferredModuleId; - target.TargetPosition = source.TargetPosition; - target.WaitSeconds = source.WaitSeconds; - target.Radius = source.Radius; - target.MaxSystemRange = source.MaxSystemRange; - target.KnownStationsOnly = source.KnownStationsOnly; - target.PatrolPoints = source.PatrolPoints.Select(point => point).ToList(); - target.PatrolIndex = source.PatrolIndex; - target.RepeatOrders = source.RepeatOrders.Select(CloneTemplate).ToList(); - target.RepeatIndex = source.RepeatIndex; - } - - private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => - string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) - && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) - && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) - && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) - && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly - && left.PatrolPoints.SequenceEqual(right.PatrolPoints) - && left.RepeatOrders.Count == right.RepeatOrders.Count - && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); - - private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => - string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.Label, right.Label, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) - && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) - && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly; - - private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => - string.Equals(left.Id, right.Id, StringComparison.Ordinal) - && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && left.Priority == right.Priority - && left.InterruptCurrentPlan == right.InterruptCurrentPlan - && string.Equals(left.Label, right.Label, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) - && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) - && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) - && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) - && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) - && left.WaitSeconds.Equals(right.WaitSeconds) - && left.Radius.Equals(right.Radius) - && left.MaxSystemRange == right.MaxSystemRange - && left.KnownStationsOnly == right.KnownStationsOnly; - - private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) - { - if (ReferenceEquals(left, right)) - { - return true; - } - - if (left is null || right is null) - { - return false; - } - - return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) - && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) - && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) - && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) - && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) - && string.Equals(left.Status, right.Status, StringComparison.Ordinal) - && left.Priority.Equals(right.Priority) - && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) - && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) - && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) - && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) - && Nullable.Equals(left.TargetPosition, right.TargetPosition) - && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal); - } - - private static ShipOrderTemplateRuntime CloneTemplate(ShipOrderTemplateRuntime template) => new() - { - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition, - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = template.WaitSeconds, - Radius = template.Radius, - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly, - }; - - private static void ReconcileOrganizationAssignments(SimulationWorld world, PlayerFactionRuntime player) - { - var fleetMemberships = new Dictionary>(StringComparer.Ordinal); - var taskForceMemberships = new Dictionary>(StringComparer.Ordinal); - var stationGroupMemberships = new Dictionary>(StringComparer.Ordinal); - var reserveMemberships = new Dictionary>(StringComparer.Ordinal); - - foreach (var fleet in player.Fleets) - { - foreach (var assetId in fleet.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) - { - AddMembership(fleetMemberships, assetId, fleet.Id); - GetOrCreateAssignment(player, "ship", assetId); - } - } - - foreach (var taskForce in player.TaskForces) - { - foreach (var assetId in taskForce.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) - { - AddMembership(taskForceMemberships, assetId, taskForce.Id); - GetOrCreateAssignment(player, "ship", assetId); - } - } - - foreach (var group in player.StationGroups) - { - foreach (var stationId in group.StationIds.Where(player.AssetRegistry.StationIds.Contains)) - { - AddMembership(stationGroupMemberships, stationId, group.Id); - GetOrCreateAssignment(player, "station", stationId); - } - } - - foreach (var reserve in player.Reserves) - { - foreach (var assetId in reserve.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) - { - AddMembership(reserveMemberships, assetId, reserve.Id); - GetOrCreateAssignment(player, "ship", assetId); - } - } - - foreach (var assignment in player.Assignments) - { - if (assignment.AssetKind == "ship") - { - assignment.FleetId = SelectSingleMembership(fleetMemberships, assignment.AssetId); - assignment.TaskForceId = SelectSingleMembership(taskForceMemberships, assignment.AssetId); - assignment.ReserveId = SelectSingleMembership(reserveMemberships, assignment.AssetId); - - if (assignment.TaskForceId is not null - && player.TaskForces.FirstOrDefault(taskForce => taskForce.Id == assignment.TaskForceId) is { FleetId: not null } taskForce) - { - assignment.FleetId ??= taskForce.FleetId; + slug = prefix; } - if (assignment.FleetId is not null) + var candidate = $"{prefix}-{slug}"; + var known = existingIds.ToHashSet(StringComparer.Ordinal); + if (!known.Contains(candidate)) { - assignment.FrontId = player.Fronts - .Where(front => front.FleetIds.Contains(assignment.FleetId, StringComparer.Ordinal)) - .OrderByDescending(front => front.Priority) - .ThenBy(front => front.Id, StringComparer.Ordinal) - .Select(front => front.Id) - .FirstOrDefault() - ?? assignment.FrontId; + return candidate; } - else if (assignment.ReserveId is not null) + + var suffix = 2; + while (known.Contains($"{candidate}-{suffix}")) { - assignment.FrontId = player.Fronts - .Where(front => front.ReserveIds.Contains(assignment.ReserveId, StringComparer.Ordinal)) - .OrderByDescending(front => front.Priority) - .ThenBy(front => front.Id, StringComparer.Ordinal) - .Select(front => front.Id) - .FirstOrDefault() - ?? player.Reserves.FirstOrDefault(reserve => reserve.Id == assignment.ReserveId)?.FrontIds - .OrderBy(id => id, StringComparer.Ordinal) - .FirstOrDefault() - ?? assignment.FrontId; + suffix += 1; } - } - else if (assignment.AssetKind == "station") - { - assignment.StationGroupId = SelectSingleMembership(stationGroupMemberships, assignment.AssetId); - if (assignment.StationGroupId is not null - && player.StationGroups.FirstOrDefault(group => group.Id == assignment.StationGroupId) is { EconomicRegionId: not null } stationGroup) + return $"{candidate}-{suffix}"; + } + + private static void UpdateStringList(List target, IEnumerable? requested, bool replace, IEnumerable allowedValues) + { + if (replace) { - assignment.EconomicRegionId = stationGroup.EconomicRegionId; + target.Clear(); } - } - } - - foreach (var assignment in player.Assignments) - { - assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; - } - } - - private static void ReconcileDirectiveScopes(PlayerFactionRuntime player) - { - foreach (var fleet in player.Fleets) - { - fleet.DirectiveIds.Clear(); - } - foreach (var taskForce in player.TaskForces) - { - taskForce.DirectiveIds.Clear(); - } - foreach (var group in player.StationGroups) - { - group.DirectiveIds.Clear(); - } - foreach (var region in player.EconomicRegions) - { - region.DirectiveIds.Clear(); - } - foreach (var front in player.Fronts) - { - front.DirectiveIds.Clear(); - } - - foreach (var directive in player.Directives.Where(directive => directive.Status == "active")) - { - switch (directive.ScopeKind) - { - case "fleet": - player.Fleets.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); - break; - case "task-force": - player.TaskForces.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); - break; - case "station-group": - player.StationGroups.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); - break; - case "economic-region": - player.EconomicRegions.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); - break; - case "front": - player.Fronts.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); - break; - } - } - } - - private static void RefreshProductionPrograms(SimulationWorld world, PlayerFactionRuntime player) - { - foreach (var program in player.ProductionPrograms) - { - if (!string.IsNullOrWhiteSpace(program.TargetShipKind)) - { - program.CurrentCount = world.Ships.Count(ship => - ship.FactionId == player.SovereignFactionId && - string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal)); - } - else - { - program.CurrentCount = 0; - } - } - } - - private static void ApplyStrategicIntegration(SimulationWorld world, PlayerFactionRuntime player) - { - var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == player.SovereignFactionId); - if (faction is null) - { - return; - } - - var corePolicy = player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); - faction.Doctrine.StrategicPosture = player.StrategicIntent.StrategicPosture; - faction.Doctrine.EconomicPosture = player.StrategicIntent.EconomicPosture; - faction.Doctrine.MilitaryPosture = player.StrategicIntent.MilitaryPosture; - faction.Doctrine.ReserveCreditsRatio = corePolicy?.ReserveCreditsRatio ?? faction.Doctrine.ReserveCreditsRatio; - faction.Doctrine.ReserveMilitaryRatio = corePolicy?.ReserveMilitaryRatio ?? faction.Doctrine.ReserveMilitaryRatio; - } - - private static void RefreshGeopoliticalOrganizationContext(SimulationWorld world, PlayerFactionRuntime player) - { - var regions = world.Geopolitics?.EconomyRegions.Regions - .Where(region => string.Equals(region.FactionId, player.SovereignFactionId, StringComparison.Ordinal)) - .ToList() ?? []; - var fronts = world.Geopolitics?.Territory.FrontLines - .Where(front => front.FactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal)) - .ToList() ?? []; - - foreach (var region in player.EconomicRegions) - { - if (region.SystemIds.Count == 0) - { - region.SystemIds.AddRange( - region.StationGroupIds - .SelectMany(groupId => player.StationGroups.FirstOrDefault(group => group.Id == groupId)?.StationIds ?? []) - .Select(stationId => world.Stations.FirstOrDefault(station => station.Id == stationId)?.SystemId) - .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) - .Cast() - .Distinct(StringComparer.Ordinal) - .OrderBy(systemId => systemId, StringComparer.Ordinal)); - } - - var matchedRegion = regions - .Select(candidate => new + if (requested is null) { - Region = candidate, - Overlap = candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Count(), - }) - .OrderByDescending(entry => entry.Overlap) - .ThenBy(entry => entry.Region.Id, StringComparer.Ordinal) - .Select(entry => entry.Region) - .FirstOrDefault(); - region.SharedEconomicRegionId = matchedRegion?.Id; - if (matchedRegion is null) - { - continue; - } + return; + } - if (region.SystemIds.Count == 0) - { - region.SystemIds.AddRange(matchedRegion.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); - } - - if (string.Equals(region.Role, "balanced-region", StringComparison.Ordinal)) - { - region.Role = matchedRegion.Kind; - } - } - - foreach (var front in player.Fronts) - { - if (front.SystemIds.Count == 0) - { - var fleetSystems = front.FleetIds - .SelectMany(fleetId => player.Fleets.FirstOrDefault(fleet => fleet.Id == fleetId)?.AssetIds ?? []) - .Select(assetId => world.Ships.FirstOrDefault(ship => ship.Id == assetId)?.SystemId) - .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) - .Cast() - .Distinct(StringComparer.Ordinal) - .OrderBy(systemId => systemId, StringComparer.Ordinal) - .ToList(); - front.SystemIds.AddRange(fleetSystems); - } - - var matchedFront = fronts - .Select(candidate => new + var allowed = allowedValues.ToHashSet(StringComparer.Ordinal); + foreach (var value in requested.Where(value => !string.IsNullOrWhiteSpace(value))) { - Front = candidate, - Overlap = candidate.SystemIds.Intersect(front.SystemIds, StringComparer.Ordinal).Count(), - TargetBias = front.TargetFactionId is not null && candidate.FactionIds.Contains(front.TargetFactionId, StringComparer.Ordinal) ? 1 : 0, - }) - .OrderByDescending(entry => entry.Overlap + entry.TargetBias) - .ThenBy(entry => entry.Front.Id, StringComparer.Ordinal) - .Select(entry => entry.Front) - .FirstOrDefault(); - front.SharedFrontLineId = matchedFront?.Id; - if (matchedFront is null) - { - continue; - } - - if (front.SystemIds.Count == 0) - { - front.SystemIds.AddRange(matchedFront.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); - } - - front.TargetFactionId ??= matchedFront.FactionIds.FirstOrDefault(id => !string.Equals(id, player.SovereignFactionId, StringComparison.Ordinal)); - } - } - - private static PlayerDirectiveRuntime? SelectScopedDirective( - IEnumerable directives, - PlayerFactionRuntime player, - PlayerAssignmentRuntime? assignment, - string assetKind, - string assetId) => - directives - .Where(directive => ScopeMatches(player, directive.ScopeKind, directive.ScopeId, assignment, assetKind, assetId)) - .OrderByDescending(directive => ScopePriority(directive.ScopeKind)) - .ThenByDescending(directive => directive.Priority) - .ThenByDescending(directive => directive.UpdatedAtUtc) - .ThenBy(directive => directive.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - private static PlayerAutomationPolicyRuntime? SelectScopedAutomationPolicy( - PlayerFactionRuntime player, - PlayerAssignmentRuntime? assignment, - string assetKind, - string assetId) => - player.AutomationPolicies - .Where(policy => policy.Enabled && ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) - .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) - .ThenByDescending(policy => policy.UpdatedAtUtc) - .ThenBy(policy => policy.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - private static PlayerFactionPolicyRuntime? SelectScopedFactionPolicy( - PlayerFactionRuntime player, - PlayerAssignmentRuntime? assignment, - string assetKind, - string assetId) => - player.Policies - .Where(policy => ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) - .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) - .ThenByDescending(policy => policy.UpdatedAtUtc) - .ThenBy(policy => policy.Id, StringComparer.Ordinal) - .FirstOrDefault(); - - private static bool ScopeMatches( - PlayerFactionRuntime player, - string scopeKind, - string? scopeId, - PlayerAssignmentRuntime? assignment, - string assetKind, - string assetId) - { - return scopeKind switch - { - "player-faction" => string.IsNullOrWhiteSpace(scopeId) - || string.Equals(scopeId, player.Id, StringComparison.Ordinal) - || string.Equals(scopeId, player.SovereignFactionId, StringComparison.Ordinal), - "asset" => string.Equals(scopeId, assetId, StringComparison.Ordinal), - "ship" => assetKind == "ship" && string.Equals(scopeId, assetId, StringComparison.Ordinal), - "station" => assetKind == "station" && string.Equals(scopeId, assetId, StringComparison.Ordinal), - "fleet" => string.Equals(scopeId, assignment?.FleetId, StringComparison.Ordinal), - "task-force" => string.Equals(scopeId, assignment?.TaskForceId, StringComparison.Ordinal), - "station-group" => string.Equals(scopeId, assignment?.StationGroupId, StringComparison.Ordinal), - "economic-region" => string.Equals(scopeId, assignment?.EconomicRegionId, StringComparison.Ordinal), - "front" => string.Equals(scopeId, assignment?.FrontId, StringComparison.Ordinal), - "reserve" => string.Equals(scopeId, assignment?.ReserveId, StringComparison.Ordinal), - _ => false, - }; - } - - private static int ScopePriority(string scopeKind) => scopeKind switch - { - "ship" or "station" or "asset" => 100, - "task-force" => 90, - "fleet" or "station-group" or "reserve" => 80, - "economic-region" or "front" => 70, - "player-faction" => 10, - _ => 0, - }; - - private static PlayerAssignmentRuntime GetOrCreateAssignment(PlayerFactionRuntime player, string assetKind, string assetId) - { - var assignment = player.Assignments.FirstOrDefault(candidate => - string.Equals(candidate.AssetKind, assetKind, StringComparison.Ordinal) && - string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal)); - if (assignment is not null) - { - return assignment; + if (allowed.Contains(value) && !target.Contains(value, StringComparer.Ordinal)) + { + target.Add(value); + } + } } - assignment = new PlayerAssignmentRuntime + private static void SyncSet(HashSet target, IEnumerable source) { - Id = $"assignment-{assetKind}-{assetId}", - AssetKind = assetKind, - AssetId = assetId, - }; - player.Assignments.Add(assignment); - return assignment; - } - - private static void AddMembership(Dictionary> memberships, string assetId, string organizationId) - { - if (!memberships.TryGetValue(assetId, out var values)) - { - values = []; - memberships[assetId] = values; - } - - if (!values.Contains(organizationId, StringComparer.Ordinal)) - { - values.Add(organizationId); - } - } - - private static string? SelectSingleMembership(Dictionary> memberships, string assetId) => - memberships.TryGetValue(assetId, out var values) - ? values.OrderBy(value => value, StringComparer.Ordinal).FirstOrDefault() - : null; - - private static void RemoveAssetFromOrganizations(PlayerFactionRuntime player, string assetKind, string assetId) - { - foreach (var fleet in player.Fleets) - { - fleet.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); - } - foreach (var taskForce in player.TaskForces) - { - taskForce.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); - } - foreach (var reserve in player.Reserves) - { - reserve.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); - } - if (assetKind == "station") - { - foreach (var group in player.StationGroups) - { - group.StationIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); - } - } - } - - private static void AddAssetToFleet(PlayerFactionRuntime player, string fleetId, string assetId) - { - var fleet = player.Fleets.FirstOrDefault(entity => entity.Id == fleetId) - ?? throw new InvalidOperationException($"Unknown fleet '{fleetId}'."); - if (!fleet.AssetIds.Contains(assetId, StringComparer.Ordinal)) - { - fleet.AssetIds.Add(assetId); - } - } - - private static void AddAssetToTaskForce(PlayerFactionRuntime player, string taskForceId, string assetId) - { - var taskForce = player.TaskForces.FirstOrDefault(entity => entity.Id == taskForceId) - ?? throw new InvalidOperationException($"Unknown task force '{taskForceId}'."); - if (!taskForce.AssetIds.Contains(assetId, StringComparer.Ordinal)) - { - taskForce.AssetIds.Add(assetId); - } - } - - private static void AddAssetToStationGroup(PlayerFactionRuntime player, string groupId, string assetId) - { - var group = player.StationGroups.FirstOrDefault(entity => entity.Id == groupId) - ?? throw new InvalidOperationException($"Unknown station group '{groupId}'."); - if (!group.StationIds.Contains(assetId, StringComparer.Ordinal)) - { - group.StationIds.Add(assetId); - } - } - - private static void AddAssetToReserve(PlayerFactionRuntime player, string reserveId, string assetId) - { - var reserve = player.Reserves.FirstOrDefault(entity => entity.Id == reserveId) - ?? throw new InvalidOperationException($"Unknown reserve '{reserveId}'."); - if (!reserve.AssetIds.Contains(assetId, StringComparer.Ordinal)) - { - reserve.AssetIds.Add(assetId); - } - } - - private static void RefreshAlerts(SimulationWorld world, PlayerFactionRuntime player) - { - player.Alerts.Clear(); - - foreach (var shipId in player.AssetRegistry.ShipIds - .Where(shipId => player.Fleets.Count(fleet => fleet.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(4)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-conflict-fleet-{shipId}", - Kind = "conflicting-fleet-membership", - Severity = "warning", - Summary = $"Ship {shipId} belongs to multiple fleets.", - AssetKind = "ship", - AssetId = shipId, - }); - } - - foreach (var shipId in player.AssetRegistry.ShipIds - .Where(shipId => player.TaskForces.Count(taskForce => taskForce.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(4)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-conflict-task-force-{shipId}", - Kind = "conflicting-task-force-membership", - Severity = "warning", - Summary = $"Ship {shipId} belongs to multiple task forces.", - AssetKind = "ship", - AssetId = shipId, - }); - } - - foreach (var stationId in player.AssetRegistry.StationIds - .Where(stationId => player.StationGroups.Count(group => group.StationIds.Contains(stationId, StringComparer.Ordinal)) > 1) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(4)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-conflict-station-group-{stationId}", - Kind = "conflicting-station-group-membership", - Severity = "warning", - Summary = $"Station {stationId} belongs to multiple station groups.", - AssetKind = "station", - AssetId = stationId, - }); - } - - foreach (var shipId in player.AssetRegistry.ShipIds - .Where(shipId => !player.Assignments.Any(assignment => assignment.AssetKind == "ship" && assignment.AssetId == shipId)) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(10)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-unassigned-ship-{shipId}", - Kind = "unassigned-ship", - Severity = "warning", - Summary = $"Ship {shipId} has no player assignment.", - AssetKind = "ship", - AssetId = shipId, - }); - } - - foreach (var stationId in player.AssetRegistry.StationIds - .Where(stationId => !player.Assignments.Any(assignment => assignment.AssetKind == "station" && assignment.AssetId == stationId)) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(6)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-unassigned-station-{stationId}", - Kind = "unassigned-station", - Severity = "info", - Summary = $"Station {stationId} is not part of a player station group.", - AssetKind = "station", - AssetId = stationId, - }); - } - - foreach (var directive in player.Directives.Where(directive => - directive.Status == "active" && - !player.Assignments.Any(assignment => assignment.DirectiveId == directive.Id)).Take(6)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-orphan-directive-{directive.Id}", - Kind = "orphan-directive", - Severity = "warning", - Summary = $"Directive {directive.Label} is not assigned to any asset or group.", - RelatedDirectiveId = directive.Id, - }); - } - - foreach (var policy in player.ReinforcementPolicies - .Where(policy => policy.DesiredAssetCount > 0) - .OrderBy(policy => policy.Id, StringComparer.Ordinal) - .Take(6)) - { - var available = world.Ships.Count(ship => - ship.FactionId == player.SovereignFactionId && - string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal)); - if (available < policy.DesiredAssetCount) - { - player.Alerts.Add(new PlayerAlertRuntime + target.Clear(); + foreach (var value in source.Where(value => !string.IsNullOrWhiteSpace(value))) { - Id = $"alert-reinforcement-{policy.Id}", - Kind = "reinforcement-deficit", - Severity = "warning", - Summary = $"Reinforcement policy {policy.Label} is short {policy.DesiredAssetCount - available} {policy.ShipKind} assets.", - }); - } + target.Add(value); + } } - - foreach (var program in player.ProductionPrograms - .Where(program => program.TargetCount > 0 && program.CurrentCount < program.TargetCount) - .OrderBy(program => program.Id, StringComparer.Ordinal) - .Take(6)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-production-{program.Id}", - Kind = "production-program-deficit", - Severity = "info", - Summary = $"Production program {program.Label} is at {program.CurrentCount}/{program.TargetCount}.", - }); - } - - foreach (var systemId in world.Geopolitics?.Territory.ControlStates - .Where(state => state.IsContested - && (string.Equals(state.ControllerFactionId, player.SovereignFactionId, StringComparison.Ordinal) - || string.Equals(state.PrimaryClaimantFactionId, player.SovereignFactionId, StringComparison.Ordinal) - || state.ClaimantFactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal))) - .Select(state => state.SystemId) - .Where(systemId => player.Fronts.All(front => !front.SystemIds.Contains(systemId, StringComparer.Ordinal))) - .OrderBy(systemId => systemId, StringComparer.Ordinal) - .Take(4) ?? []) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-contested-system-{systemId}", - Kind = "uncovered-contested-system", - Severity = "warning", - Summary = $"Contested player system {systemId} is not covered by a player front.", - }); - } - - foreach (var region in player.EconomicRegions.Take(6)) - { - var sharedRegion = world.Geopolitics?.EconomyRegions.Regions.FirstOrDefault(candidate => - string.Equals(candidate.FactionId, player.SovereignFactionId, StringComparison.Ordinal) - && candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Any()); - if (sharedRegion is null) - { - continue; - } - - var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)) - .OrderByDescending(candidate => candidate.Severity) - .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) - .FirstOrDefault(); - var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)); - if (bottleneck is not null && bottleneck.Severity >= 2.5f) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-region-bottleneck-{region.Id}-{bottleneck.ItemId}", - Kind = "economic-region-bottleneck", - Severity = "warning", - Summary = $"Region {region.Label} is bottlenecked on {bottleneck.ItemId}.", - }); - } - if ((security?.SupplyRisk ?? 0f) >= 0.55f) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-region-risk-{region.Id}", - Kind = "economic-region-risk", - Severity = "warning", - Summary = $"Region {region.Label} has elevated logistics risk.", - }); - } - } - - foreach (var front in player.Fronts - .Where(front => !string.IsNullOrWhiteSpace(front.TargetFactionId)) - .Take(6)) - { - var relation = GeopoliticalSimulationService.FindRelation(world, player.SovereignFactionId, front.TargetFactionId!); - if (relation is not null - && relation.Posture is not "hostile" and not "war" - && front.Priority >= 60f - && !string.Equals(front.Posture, "hold", StringComparison.Ordinal)) - { - player.Alerts.Add(new PlayerAlertRuntime - { - Id = $"alert-front-posture-{front.Id}", - Kind = "front-diplomatic-misalignment", - Severity = "info", - Summary = $"Front {front.Label} targets {front.TargetFactionId} while diplomatic posture is {relation.Posture}.", - }); - } - } - - while (player.Alerts.Count > MaxAlerts) - { - player.Alerts.RemoveAt(player.Alerts.Count - 1); - } - } - - private static void AddDecision(PlayerFactionRuntime player, string kind, string summary, string? relatedKind, string? relatedId) - { - player.DecisionLog.Insert(0, new PlayerDecisionLogEntryRuntime - { - Id = $"player-decision-{Guid.NewGuid():N}", - Kind = kind, - Summary = summary, - RelatedEntityKind = relatedKind, - RelatedEntityId = relatedId, - OccurredAtUtc = DateTimeOffset.UtcNow, - }); - - while (player.DecisionLog.Count > MaxDecisionEntries) - { - player.DecisionLog.RemoveAt(player.DecisionLog.Count - 1); - } - } - - private static PolicySetRuntime EnsurePolicySet(SimulationWorld world, PlayerFactionRuntime player, PlayerFactionPolicyRuntime policy, string? requestedPolicySetId) - { - if (requestedPolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == requestedPolicySetId) is { } existing) - { - return existing; - } - - if (policy.PolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } current) - { - return current; - } - - var created = new PolicySetRuntime - { - Id = $"policy-player-{policy.Id}", - OwnerKind = "player-faction-policy", - OwnerId = policy.Id, - }; - world.Policies.Add(created); - player.AssetRegistry.PolicySetIds.Add(created.Id); - return created; - } - - private static void CopyPolicySetToPlayerPolicy(PolicySetRuntime policySet, PlayerFactionPolicyRuntime policy) - { - policy.TradeAccessPolicy = policySet.TradeAccessPolicy; - policy.DockingAccessPolicy = policySet.DockingAccessPolicy; - policy.ConstructionAccessPolicy = policySet.ConstructionAccessPolicy; - policy.OperationalRangePolicy = policySet.OperationalRangePolicy; - policy.CombatEngagementPolicy = policySet.CombatEngagementPolicy; - policy.AvoidHostileSystems = policySet.AvoidHostileSystems; - policy.FleeHullRatio = policySet.FleeHullRatio; - policy.BlacklistedSystemIds.Clear(); - foreach (var systemId in policySet.BlacklistedSystemIds) - { - policy.BlacklistedSystemIds.Add(systemId); - } - } - - private static void ApplyPolicySetRequest(PolicySetRuntime policySet, PlayerPolicyCommandRequest request) - { - if (request.TradeAccessPolicy is not null) - { - policySet.TradeAccessPolicy = request.TradeAccessPolicy; - } - if (request.DockingAccessPolicy is not null) - { - policySet.DockingAccessPolicy = request.DockingAccessPolicy; - } - if (request.ConstructionAccessPolicy is not null) - { - policySet.ConstructionAccessPolicy = request.ConstructionAccessPolicy; - } - if (request.OperationalRangePolicy is not null) - { - policySet.OperationalRangePolicy = request.OperationalRangePolicy; - } - if (request.CombatEngagementPolicy is not null) - { - policySet.CombatEngagementPolicy = request.CombatEngagementPolicy; - } - if (request.AvoidHostileSystems.HasValue) - { - policySet.AvoidHostileSystems = request.AvoidHostileSystems.Value; - } - if (request.FleeHullRatio.HasValue) - { - policySet.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); - } - policySet.BlacklistedSystemIds.Clear(); - foreach (var systemId in request.BlacklistedSystemIds ?? []) - { - policySet.BlacklistedSystemIds.Add(systemId); - } - } - - private static string? ResolvePolicySetId(SimulationWorld world, PlayerFactionRuntime player, string? policyId) - { - if (policyId is null) - { - return player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy")?.PolicySetId; - } - - return player.Policies.FirstOrDefault(policy => policy.Id == policyId)?.PolicySetId - ?? world.Policies.FirstOrDefault(policy => policy.Id == policyId)?.Id; - } - - private static void RemoveOrganization(PlayerFactionRuntime player, string organizationId) - { - if (player.Fleets.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.FleetIds.Remove(organizationId); - return; - } - if (player.TaskForces.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.TaskForceIds.Remove(organizationId); - return; - } - if (player.StationGroups.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.StationGroupIds.Remove(organizationId); - return; - } - if (player.EconomicRegions.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.EconomicRegionIds.Remove(organizationId); - return; - } - if (player.Fronts.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.FrontIds.Remove(organizationId); - return; - } - if (player.Reserves.RemoveAll(entity => entity.Id == organizationId) > 0) - { - player.AssetRegistry.ReserveIds.Remove(organizationId); - return; - } - - throw new InvalidOperationException($"Unknown organization '{organizationId}'."); - } - - private static string ResolveOrganizationKind(PlayerFactionRuntime player, string organizationId) - { - if (player.Fleets.Any(entity => entity.Id == organizationId)) return "fleet"; - if (player.TaskForces.Any(entity => entity.Id == organizationId)) return "task-force"; - if (player.StationGroups.Any(entity => entity.Id == organizationId)) return "station-group"; - if (player.EconomicRegions.Any(entity => entity.Id == organizationId)) return "economic-region"; - if (player.Fronts.Any(entity => entity.Id == organizationId)) return "front"; - if (player.Reserves.Any(entity => entity.Id == organizationId)) return "reserve"; - throw new InvalidOperationException($"Unknown organization '{organizationId}'."); - } - - private static IEnumerable ExistingOrganizationIds(PlayerFactionRuntime player) => - player.Fleets.Select(entity => entity.Id) - .Concat(player.TaskForces.Select(entity => entity.Id)) - .Concat(player.StationGroups.Select(entity => entity.Id)) - .Concat(player.EconomicRegions.Select(entity => entity.Id)) - .Concat(player.Fronts.Select(entity => entity.Id)) - .Concat(player.Reserves.Select(entity => entity.Id)); - - private static string NormalizeKind(string value) => - value.Trim().ToLowerInvariant(); - - private static string CreateDomainId(string prefix, string label, IEnumerable existingIds) - { - var slug = new string(label - .Trim() - .ToLowerInvariant() - .Select(character => char.IsLetterOrDigit(character) ? character : '-') - .ToArray()) - .Trim('-'); - if (string.IsNullOrWhiteSpace(slug)) - { - slug = prefix; - } - - var candidate = $"{prefix}-{slug}"; - var known = existingIds.ToHashSet(StringComparer.Ordinal); - if (!known.Contains(candidate)) - { - return candidate; - } - - var suffix = 2; - while (known.Contains($"{candidate}-{suffix}")) - { - suffix += 1; - } - return $"{candidate}-{suffix}"; - } - - private static void UpdateStringList(List target, IEnumerable? requested, bool replace, IEnumerable allowedValues) - { - if (replace) - { - target.Clear(); - } - if (requested is null) - { - return; - } - - var allowed = allowedValues.ToHashSet(StringComparer.Ordinal); - foreach (var value in requested.Where(value => !string.IsNullOrWhiteSpace(value))) - { - if (allowed.Contains(value) && !target.Contains(value, StringComparer.Ordinal)) - { - target.Add(value); - } - } - } - - private static void SyncSet(HashSet target, IEnumerable source) - { - target.Clear(); - foreach (var value in source.Where(value => !string.IsNullOrWhiteSpace(value))) - { - target.Add(value); - } - } } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index da4ded2..8b17b68 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -6,13 +6,13 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddCors((options) => { - options.AddDefaultPolicy((policy) => - { - policy - .AllowAnyHeader() - .AllowAnyMethod() - .AllowAnyOrigin(); - }); + options.AddDefaultPolicy((policy) => + { + policy + .AllowAnyHeader() + .AllowAnyMethod() + .AllowAnyOrigin(); + }); }); builder.Services.Configure(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index 2ec343e..394073c 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -2,276 +2,276 @@ namespace SpaceGame.Api.Shared.Runtime; public enum SpatialNodeKind { - Star, - Planet, - Moon, - LagrangePoint, + Star, + Planet, + Moon, + LagrangePoint, } public enum WorkStatus { - Pending, - Active, - Blocked, - Completed, - Failed, - Interrupted, + Pending, + Active, + Blocked, + Completed, + Failed, + Interrupted, } public enum OrderStatus { - Queued, - Active, - Completed, - Cancelled, - Failed, - Interrupted, + Queued, + Active, + Completed, + Cancelled, + Failed, + Interrupted, } public enum AiPlanStatus { - Planned, - Running, - Blocked, - Completed, - Failed, - Interrupted, + Planned, + Running, + Blocked, + Completed, + Failed, + Interrupted, } public enum AiPlanStepStatus { - Planned, - Running, - Blocked, - Completed, - Failed, - Interrupted, + Planned, + Running, + Blocked, + Completed, + Failed, + Interrupted, } public enum AiPlanSourceKind { - Rule, - Order, - DefaultBehavior, + Rule, + Order, + DefaultBehavior, } public enum ShipState { - Idle, - Arriving, - LocalFlight, - SpoolingWarp, - Warping, - SpoolingFtl, - Ftl, - CargoFull, - MiningApproach, - Mining, - NodeDepleted, - AwaitingDock, - DockingApproach, - Docking, - Docked, - Transferring, - Loading, - Unloading, - WaitingMaterials, - ConstructionBlocked, - Constructing, - DeliveringConstruction, - Blocked, - Undocking, - EngagingTarget, - HoldingPosition, - Fleeing, + Idle, + Arriving, + LocalFlight, + SpoolingWarp, + Warping, + SpoolingFtl, + Ftl, + CargoFull, + MiningApproach, + Mining, + NodeDepleted, + AwaitingDock, + DockingApproach, + Docking, + Docked, + Transferring, + Loading, + Unloading, + WaitingMaterials, + ConstructionBlocked, + Constructing, + DeliveringConstruction, + Blocked, + Undocking, + EngagingTarget, + HoldingPosition, + Fleeing, } public static class SpaceLayerKinds { - public const string UniverseSpace = "universe-space"; - public const string GalaxySpace = "galaxy-space"; - public const string SystemSpace = "system-space"; - public const string LocalSpace = "local-space"; + public const string UniverseSpace = "universe-space"; + public const string GalaxySpace = "galaxy-space"; + public const string SystemSpace = "system-space"; + public const string LocalSpace = "local-space"; } public static class MovementRegimeKinds { - public const string LocalFlight = "local-flight"; - public const string Warp = "warp"; - public const string StargateTransit = "stargate-transit"; - public const string FtlTransit = "ftl-transit"; + public const string LocalFlight = "local-flight"; + public const string Warp = "warp"; + public const string StargateTransit = "stargate-transit"; + public const string FtlTransit = "ftl-transit"; } public static class CommanderKind { - public const string Faction = "faction"; - public const string Station = "station"; - public const string Ship = "ship"; - public const string Fleet = "fleet"; - public const string Sector = "sector"; - public const string TaskGroup = "task-group"; + public const string Faction = "faction"; + public const string Station = "station"; + public const string Ship = "ship"; + public const string Fleet = "fleet"; + public const string Sector = "sector"; + public const string TaskGroup = "task-group"; } public static class ShipTaskKinds { - public const string HoldPosition = "hold-position"; - public const string Travel = "travel"; - public const string FollowTarget = "follow-target"; - public const string MineNode = "mine-node"; - public const string Dock = "dock"; - public const string Undock = "undock"; - public const string LoadCargo = "load-cargo"; - public const string UnloadCargo = "unload-cargo"; - public const string TransferCargoToShip = "transfer-cargo-to-ship"; - public const string SalvageWreck = "salvage-wreck"; - public const string DeliverConstruction = "deliver-construction"; - public const string ConstructModule = "construct-module"; - public const string BuildConstructionSite = "build-construction-site"; - public const string AttackTarget = "attack-target"; - public const string Flee = "flee"; - public const string Wait = "wait"; + public const string HoldPosition = "hold-position"; + public const string Travel = "travel"; + public const string FollowTarget = "follow-target"; + public const string MineNode = "mine-node"; + public const string Dock = "dock"; + public const string Undock = "undock"; + public const string LoadCargo = "load-cargo"; + public const string UnloadCargo = "unload-cargo"; + public const string TransferCargoToShip = "transfer-cargo-to-ship"; + public const string SalvageWreck = "salvage-wreck"; + public const string DeliverConstruction = "deliver-construction"; + public const string ConstructModule = "construct-module"; + public const string BuildConstructionSite = "build-construction-site"; + public const string AttackTarget = "attack-target"; + public const string Flee = "flee"; + public const string Wait = "wait"; } public static class ShipOrderKinds { - public const string Move = "move"; - public const string DockAtStation = "dock-at-station"; - public const string DockAndWait = "dock-and-wait"; - public const string FlyAndWait = "fly-and-wait"; - public const string FlyToObject = "fly-to-object"; - public const string FollowShip = "follow-ship"; - public const string TradeRoute = "trade-route"; - public const string MineAndDeliver = "mine-and-deliver"; - public const string BuildAtSite = "build-at-site"; - public const string AttackTarget = "attack-target"; - public const string HoldPosition = "hold-position"; - public const string RepeatOrders = "repeat-orders"; - public const string Flee = "flee"; + public const string Move = "move"; + public const string DockAtStation = "dock-at-station"; + public const string DockAndWait = "dock-and-wait"; + public const string FlyAndWait = "fly-and-wait"; + public const string FlyToObject = "fly-to-object"; + public const string FollowShip = "follow-ship"; + public const string TradeRoute = "trade-route"; + public const string MineAndDeliver = "mine-and-deliver"; + public const string BuildAtSite = "build-at-site"; + public const string AttackTarget = "attack-target"; + public const string HoldPosition = "hold-position"; + public const string RepeatOrders = "repeat-orders"; + public const string Flee = "flee"; } public static class ClaimStateKinds { - public const string Placed = "placed"; - public const string Activating = "activating"; - public const string Active = "active"; - public const string Destroyed = "destroyed"; + public const string Placed = "placed"; + public const string Activating = "activating"; + public const string Active = "active"; + public const string Destroyed = "destroyed"; } public static class ConstructionSiteStateKinds { - public const string Planned = "planned"; - public const string Active = "active"; - public const string Paused = "paused"; - public const string Completed = "completed"; - public const string Destroyed = "destroyed"; + public const string Planned = "planned"; + public const string Active = "active"; + public const string Paused = "paused"; + public const string Completed = "completed"; + public const string Destroyed = "destroyed"; } public static class MarketOrderKinds { - public const string Buy = "buy"; - public const string Sell = "sell"; + public const string Buy = "buy"; + public const string Sell = "sell"; } public static class MarketOrderStateKinds { - public const string Open = "open"; - public const string PartiallyFilled = "partially-filled"; - public const string Filled = "filled"; - public const string Cancelled = "cancelled"; + public const string Open = "open"; + public const string PartiallyFilled = "partially-filled"; + public const string Filled = "filled"; + public const string Cancelled = "cancelled"; } public static class SimulationEnumMappings { - public static string ToContractValue(this SpatialNodeKind kind) => kind switch - { - SpatialNodeKind.Star => "star", - SpatialNodeKind.Planet => "planet", - SpatialNodeKind.Moon => "moon", - SpatialNodeKind.LagrangePoint => "lagrange-point", - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), - }; + public static string ToContractValue(this SpatialNodeKind kind) => kind switch + { + SpatialNodeKind.Star => "star", + SpatialNodeKind.Planet => "planet", + SpatialNodeKind.Moon => "moon", + SpatialNodeKind.LagrangePoint => "lagrange-point", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; - public static string ToContractValue(this WorkStatus status) => status switch - { - WorkStatus.Pending => "pending", - WorkStatus.Active => "active", - WorkStatus.Blocked => "blocked", - WorkStatus.Completed => "completed", - WorkStatus.Failed => "failed", - WorkStatus.Interrupted => "interrupted", - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; + public static string ToContractValue(this WorkStatus status) => status switch + { + WorkStatus.Pending => "pending", + WorkStatus.Active => "active", + WorkStatus.Blocked => "blocked", + WorkStatus.Completed => "completed", + WorkStatus.Failed => "failed", + WorkStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; - public static string ToContractValue(this OrderStatus status) => status switch - { - OrderStatus.Queued => "queued", - OrderStatus.Active => "active", - OrderStatus.Completed => "completed", - OrderStatus.Cancelled => "cancelled", - OrderStatus.Failed => "failed", - OrderStatus.Interrupted => "interrupted", - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; + public static string ToContractValue(this OrderStatus status) => status switch + { + OrderStatus.Queued => "queued", + OrderStatus.Active => "active", + OrderStatus.Completed => "completed", + OrderStatus.Cancelled => "cancelled", + OrderStatus.Failed => "failed", + OrderStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; - public static string ToContractValue(this AiPlanStatus status) => status switch - { - AiPlanStatus.Planned => "planned", - AiPlanStatus.Running => "running", - AiPlanStatus.Blocked => "blocked", - AiPlanStatus.Completed => "completed", - AiPlanStatus.Failed => "failed", - AiPlanStatus.Interrupted => "interrupted", - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; + public static string ToContractValue(this AiPlanStatus status) => status switch + { + AiPlanStatus.Planned => "planned", + AiPlanStatus.Running => "running", + AiPlanStatus.Blocked => "blocked", + AiPlanStatus.Completed => "completed", + AiPlanStatus.Failed => "failed", + AiPlanStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; - public static string ToContractValue(this AiPlanStepStatus status) => status switch - { - AiPlanStepStatus.Planned => "planned", - AiPlanStepStatus.Running => "running", - AiPlanStepStatus.Blocked => "blocked", - AiPlanStepStatus.Completed => "completed", - AiPlanStepStatus.Failed => "failed", - AiPlanStepStatus.Interrupted => "interrupted", - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; + public static string ToContractValue(this AiPlanStepStatus status) => status switch + { + AiPlanStepStatus.Planned => "planned", + AiPlanStepStatus.Running => "running", + AiPlanStepStatus.Blocked => "blocked", + AiPlanStepStatus.Completed => "completed", + AiPlanStepStatus.Failed => "failed", + AiPlanStepStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; - public static string ToContractValue(this AiPlanSourceKind kind) => kind switch - { - AiPlanSourceKind.Rule => "rule", - AiPlanSourceKind.Order => "order", - AiPlanSourceKind.DefaultBehavior => "default-behavior", - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), - }; + public static string ToContractValue(this AiPlanSourceKind kind) => kind switch + { + AiPlanSourceKind.Rule => "rule", + AiPlanSourceKind.Order => "order", + AiPlanSourceKind.DefaultBehavior => "default-behavior", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; - public static string ToContractValue(this ShipState state) => state switch - { - ShipState.Idle => "idle", - ShipState.Arriving => "arriving", - ShipState.LocalFlight => "local-flight", - ShipState.SpoolingWarp => "spooling-warp", - ShipState.Warping => "warping", - ShipState.SpoolingFtl => "spooling-ftl", - ShipState.Ftl => "ftl", - ShipState.CargoFull => "cargo-full", - ShipState.MiningApproach => "mining-approach", - ShipState.Mining => "mining", - ShipState.NodeDepleted => "node-depleted", - ShipState.AwaitingDock => "awaiting-dock", - ShipState.DockingApproach => "docking-approach", - ShipState.Docking => "docking", - ShipState.Docked => "docked", - ShipState.Transferring => "transferring", - ShipState.Loading => "loading", - ShipState.Unloading => "unloading", - ShipState.WaitingMaterials => "waiting-materials", - ShipState.ConstructionBlocked => "construction-blocked", - ShipState.Constructing => "constructing", - ShipState.DeliveringConstruction => "delivering-construction", - ShipState.Blocked => "blocked", - ShipState.Undocking => "undocking", - ShipState.EngagingTarget => "engaging-target", - ShipState.HoldingPosition => "holding-position", - ShipState.Fleeing => "fleeing", - _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), - }; + public static string ToContractValue(this ShipState state) => state switch + { + ShipState.Idle => "idle", + ShipState.Arriving => "arriving", + ShipState.LocalFlight => "local-flight", + ShipState.SpoolingWarp => "spooling-warp", + ShipState.Warping => "warping", + ShipState.SpoolingFtl => "spooling-ftl", + ShipState.Ftl => "ftl", + ShipState.CargoFull => "cargo-full", + ShipState.MiningApproach => "mining-approach", + ShipState.Mining => "mining", + ShipState.NodeDepleted => "node-depleted", + ShipState.AwaitingDock => "awaiting-dock", + ShipState.DockingApproach => "docking-approach", + ShipState.Docking => "docking", + ShipState.Docked => "docked", + ShipState.Transferring => "transferring", + ShipState.Loading => "loading", + ShipState.Unloading => "unloading", + ShipState.WaitingMaterials => "waiting-materials", + ShipState.ConstructionBlocked => "construction-blocked", + ShipState.Constructing => "constructing", + ShipState.DeliveringConstruction => "delivering-construction", + ShipState.Blocked => "blocked", + ShipState.Undocking => "undocking", + ShipState.EngagingTarget => "engaging-target", + ShipState.HoldingPosition => "holding-position", + ShipState.Fleeing => "fleeing", + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), + }; } diff --git a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs index 7dec5f5..eb3c45e 100644 --- a/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs +++ b/apps/backend/Shared/Runtime/SimulationRuntimeSupport.cs @@ -3,179 +3,179 @@ namespace SpaceGame.Api.Shared.Runtime; internal static class SimulationRuntimeSupport { - internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) => - capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal)); + 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, string moduleId) => + station.Modules.Count(module => string.Equals(module.ModuleId, moduleId, StringComparison.Ordinal)); - internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId) - { - if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition)) + internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId) { - return; + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition)) + { + return; + } + + station.Modules.Add(new StationModuleRuntime + { + Id = $"{station.Id}-module-{station.Modules.Count + 1}", + ModuleId = moduleId, + Health = definition.Hull, + MaxHealth = definition.Hull, + }); + station.Radius = GetStationRadius(world, station); } - station.Modules.Add(new StationModuleRuntime + internal static float GetStationRadius(SimulationWorld world, StationRuntime station) { - Id = $"{station.Id}-module-{station.Modules.Count + 1}", - ModuleId = moduleId, - Health = definition.Hull, - MaxHealth = definition.Hull, - }); - station.Radius = GetStationRadius(world, station); - } - - internal static float GetStationRadius(SimulationWorld world, StationRuntime station) - { - var totalArea = station.Modules - .Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f) - .Sum(); - 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 float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => - inventory.TryGetValue(itemId, out var amount) ? amount : 0f; - - internal static void AddInventory(IDictionary inventory, string itemId, float amount) - { - if (amount <= 0f) - { - return; + var totalArea = station.Modules + .Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f) + .Sum(); + return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f))); } - inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; - } + internal static float GetStationStorageCapacity(StationRuntime station, string storageClass) + { + var baseCapacity = storageClass switch + { + "manufactured" => 400f, + _ => 0f, + }; - internal static float RemoveInventory(IDictionary inventory, string itemId, float amount) - { - var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); - var removed = MathF.Min(current, amount); - var remaining = current - removed; - if (remaining <= 0.001f) - { - inventory.Remove(itemId); - } - else - { - inventory[itemId] = remaining; + 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; } - return removed; - } + internal static int CountModules(IEnumerable modules, string moduleId) => + modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); - internal static bool HasStationModules(StationRuntime station, params string[] modules) => - modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); + internal static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; - 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); - - internal static bool CanBuildClaimBeacon(ShipRuntime ship) => - string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal); - - internal static float ComputeWorkforceRatio(float population, float workforceRequired) - { - if (workforceRequired <= 0.01f) + internal static void AddInventory(IDictionary inventory, string itemId, float amount) { - return 1f; + if (amount <= 0f) + { + return; + } + + inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; } - var staffedRatio = MathF.Min(1f, population / workforceRequired); - return 0.1f + (0.9f * staffedRatio); - } - - internal static string? GetStorageRequirement(string storageClass) => - storageClass switch - { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => null, - }; - - internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) - { - if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + internal static float RemoveInventory(IDictionary inventory, string itemId, float amount) { - return 0f; + var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); + var removed = MathF.Min(current, amount); + var remaining = current - removed; + if (remaining <= 0.001f) + { + inventory.Remove(itemId); + } + else + { + inventory[itemId] = remaining; + } + + return removed; } - var storageClass = itemDefinition.CargoKind; - var requiredModule = GetStorageRequirement(storageClass); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + internal static bool HasStationModules(StationRuntime station, params string[] modules) => + modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); + + 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); + + internal static bool CanBuildClaimBeacon(ShipRuntime ship) => + string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal); + + internal static float ComputeWorkforceRatio(float population, float workforceRequired) { - return 0f; + if (workforceRequired <= 0.01f) + { + return 1f; + } + + var staffedRatio = MathF.Min(1f, population / workforceRequired); + return 0.1f + (0.9f * staffedRatio); } - var capacity = GetStationStorageCapacity(station, storageClass); - if (capacity <= 0.01f) + internal static string? GetStorageRequirement(string storageClass) => + storageClass switch + { + "solid" => "module_arg_stor_solid_m_01", + "liquid" => "module_arg_stor_liquid_m_01", + _ => null, + }; + + internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) { - return 0f; + if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + return 0f; + } + + var storageClass = itemDefinition.CargoKind; + var requiredModule = GetStorageRequirement(storageClass); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + { + return 0f; + } + + var capacity = GetStationStorageCapacity(station, storageClass); + if (capacity <= 0.01f) + { + return 0f; + } + + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) + .Sum(entry => entry.Value); + var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); + if (accepted <= 0.01f) + { + return 0f; + } + + AddInventory(station.Inventory, itemId, accepted); + return accepted; } - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) - .Sum(entry => entry.Value); - var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); - if (accepted <= 0.01f) + internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) => + recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount); + + internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) => + world.ConstructionSites.FirstOrDefault(site => + string.Equals(site.StationId, stationId, StringComparison.Ordinal) + && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); + + internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId) { - return 0f; + if (site.StationId is not null + && world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station) + { + return GetInventoryAmount(station.Inventory, itemId); + } + + return GetInventoryAmount(site.DeliveredItems, itemId); } - AddInventory(station.Inventory, itemId, accepted); - return accepted; - } + internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) => + site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value); - internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) => - recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount); - - internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) => - world.ConstructionSites.FirstOrDefault(site => - string.Equals(site.StationId, stationId, StringComparison.Ordinal) - && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); - - internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId) - { - if (site.StationId is not null - && world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station) - { - return GetInventoryAmount(station.Inventory, itemId); - } - - return GetInventoryAmount(site.DeliveredItems, itemId); - } - - internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) => - site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value); - - internal static float GetShipCargoAmount(ShipRuntime ship) => - ship.Inventory.Values.Sum(); + internal static float GetShipCargoAmount(ShipRuntime ship) => + ship.Inventory.Values.Sum(); } diff --git a/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs index c1d21ed..9a4fac3 100644 --- a/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs +++ b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs @@ -4,36 +4,36 @@ namespace SpaceGame.Api.Ships.Api; public sealed class EnqueueShipOrderHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Post("/api/ships/{shipId}/orders"); - AllowAnonymous(); - } - - public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken) - { - var shipId = Route("shipId"); - if (string.IsNullOrWhiteSpace(shipId)) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Post("/api/ships/{shipId}/orders"); + AllowAnonymous(); } - try + public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken) { - var snapshot = worldService.EnqueueShipOrder(shipId, request); - if (snapshot is null) - { - await SendNotFoundAsync(cancellationToken); - return; - } + var shipId = Route("shipId"); + if (string.IsNullOrWhiteSpace(shipId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } - await SendOkAsync(snapshot, cancellationToken); + try + { + var snapshot = worldService.EnqueueShipOrder(shipId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } } - catch (InvalidOperationException ex) - { - AddError(ex.Message); - await SendErrorsAsync(cancellation: cancellationToken); - } - } } diff --git a/apps/backend/Ships/Api/RemoveShipOrderHandler.cs b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs index 299b4ab..bc3b77d 100644 --- a/apps/backend/Ships/Api/RemoveShipOrderHandler.cs +++ b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs @@ -4,27 +4,27 @@ namespace SpaceGame.Api.Ships.Api; public sealed class RemoveShipOrderRequest { - public string ShipId { get; set; } = string.Empty; - public string OrderId { get; set; } = string.Empty; + public string ShipId { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; } public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Delete("/api/ships/{shipId}/orders/{orderId}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken) - { - var snapshot = worldService.RemoveShipOrder(request.ShipId, request.OrderId); - if (snapshot is null) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Delete("/api/ships/{shipId}/orders/{orderId}"); + AllowAnonymous(); } - await SendOkAsync(snapshot, cancellationToken); - } + public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.RemoveShipOrder(request.ShipId, request.OrderId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs index 5c14122..d77fe72 100644 --- a/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs +++ b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs @@ -4,28 +4,28 @@ namespace SpaceGame.Api.Ships.Api; public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Put("/api/ships/{shipId}/default-behavior"); - AllowAnonymous(); - } - - public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken) - { - var shipId = Route("shipId"); - if (string.IsNullOrWhiteSpace(shipId)) + public override void Configure() { - await SendNotFoundAsync(cancellationToken); - return; + Put("/api/ships/{shipId}/default-behavior"); + AllowAnonymous(); } - var snapshot = worldService.UpdateShipDefaultBehavior(shipId, request); - if (snapshot is null) + public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken) { - await SendNotFoundAsync(cancellationToken); - return; - } + var shipId = Route("shipId"); + if (string.IsNullOrWhiteSpace(shipId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } - await SendOkAsync(snapshot, cancellationToken); - } + var snapshot = worldService.UpdateShipDefaultBehavior(shipId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } } diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index 49b51d5..1ae78c5 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -2,156 +2,156 @@ namespace SpaceGame.Api.Ships.Runtime; public sealed class ShipRuntime { - public required string Id { get; init; } - public required string SystemId { get; set; } - public required ShipDefinition Definition { get; init; } - public required string FactionId { get; init; } - public required Vector3 Position { get; set; } - public required Vector3 TargetPosition { get; set; } - public required ShipSpatialStateRuntime SpatialState { get; set; } - public Vector3 Velocity { get; set; } = Vector3.Zero; - public ShipState State { get; set; } = ShipState.Idle; - public required DefaultBehaviorRuntime DefaultBehavior { get; set; } - public List OrderQueue { get; } = []; - public ShipPlanRuntime? ActivePlan { get; set; } - public required ShipSkillProfileRuntime Skills { get; set; } - public bool NeedsReplan { get; set; } = true; - public float ReplanCooldownSeconds { get; set; } - public Dictionary Inventory { get; } = new(StringComparer.Ordinal); - public string? DockedStationId { get; set; } - public int? AssignedDockingPadIndex { get; set; } - public string? CommanderId { get; set; } - public string? PolicySetId { get; set; } - public string ControlSourceKind { get; set; } = "unassigned"; - public string? ControlSourceId { get; set; } - public string? ControlReason { get; set; } - public string? LastReplanReason { get; set; } - public string? LastAccessFailureReason { get; set; } - public float Health { get; set; } - public HashSet KnownStationIds { get; } = new(StringComparer.Ordinal); - public List History { get; } = []; - public string LastSignature { get; set; } = string.Empty; - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string SystemId { get; set; } + public required ShipDefinition Definition { get; init; } + public required string FactionId { get; init; } + public required Vector3 Position { get; set; } + public required Vector3 TargetPosition { get; set; } + public required ShipSpatialStateRuntime SpatialState { get; set; } + public Vector3 Velocity { get; set; } = Vector3.Zero; + public ShipState State { get; set; } = ShipState.Idle; + public required DefaultBehaviorRuntime DefaultBehavior { get; set; } + public List OrderQueue { get; } = []; + public ShipPlanRuntime? ActivePlan { get; set; } + public required ShipSkillProfileRuntime Skills { get; set; } + public bool NeedsReplan { get; set; } = true; + public float ReplanCooldownSeconds { get; set; } + public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public string? DockedStationId { get; set; } + public int? AssignedDockingPadIndex { get; set; } + public string? CommanderId { get; set; } + public string? PolicySetId { get; set; } + public string ControlSourceKind { get; set; } = "unassigned"; + public string? ControlSourceId { get; set; } + public string? ControlReason { get; set; } + public string? LastReplanReason { get; set; } + public string? LastAccessFailureReason { get; set; } + public float Health { get; set; } + public HashSet KnownStationIds { get; } = new(StringComparer.Ordinal); + public List History { get; } = []; + public string LastSignature { get; set; } = string.Empty; + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class ShipSkillProfileRuntime { - public int Navigation { get; set; } - public int Trade { get; set; } - public int Mining { get; set; } - public int Combat { get; set; } - public int Construction { get; set; } + public int Navigation { get; set; } + public int Trade { get; set; } + public int Mining { get; set; } + public int Combat { get; set; } + public int Construction { get; set; } } public sealed class ShipOrderRuntime { - public required string Id { get; init; } - public required string Kind { get; init; } - public OrderStatus Status { get; set; } = OrderStatus.Queued; - public int Priority { get; set; } - public bool InterruptCurrentPlan { get; set; } = true; - public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; - public string? Label { get; set; } - public string? TargetEntityId { get; set; } - public string? TargetSystemId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? SourceStationId { get; set; } - public string? DestinationStationId { get; set; } - public string? ItemId { get; set; } - public string? NodeId { get; set; } - public string? ConstructionSiteId { get; set; } - public string? ModuleId { get; set; } - public float WaitSeconds { get; set; } - public float Radius { get; set; } - public int? MaxSystemRange { get; set; } - public bool KnownStationsOnly { get; set; } - public string? FailureReason { get; set; } + public required string Id { get; init; } + public required string Kind { get; init; } + public OrderStatus Status { get; set; } = OrderStatus.Queued; + public int Priority { get; set; } + public bool InterruptCurrentPlan { get; set; } = true; + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string? Label { get; set; } + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string? ItemId { get; set; } + public string? NodeId { get; set; } + public string? ConstructionSiteId { get; set; } + public string? ModuleId { get; set; } + public float WaitSeconds { get; set; } + public float Radius { get; set; } + public int? MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public string? FailureReason { get; set; } } public sealed class DefaultBehaviorRuntime { - public required string Kind { get; set; } - public string? HomeSystemId { get; set; } - public string? HomeStationId { get; set; } - public string? AreaSystemId { get; set; } - public string? TargetEntityId { get; set; } - public string? PreferredItemId { get; set; } - public string? PreferredNodeId { get; set; } - public string? PreferredConstructionSiteId { get; set; } - public string? PreferredModuleId { get; set; } - public Vector3? TargetPosition { get; set; } - public float WaitSeconds { get; set; } = 3f; - public float Radius { get; set; } = 24f; - public int MaxSystemRange { get; set; } - public bool KnownStationsOnly { get; set; } - public List PatrolPoints { get; set; } = []; - public int PatrolIndex { get; set; } - public List RepeatOrders { get; set; } = []; - public int RepeatIndex { get; set; } + public required string Kind { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? AreaSystemId { get; set; } + public string? TargetEntityId { get; set; } + public string? PreferredItemId { get; set; } + public string? PreferredNodeId { get; set; } + public string? PreferredConstructionSiteId { get; set; } + public string? PreferredModuleId { get; set; } + public Vector3? TargetPosition { get; set; } + public float WaitSeconds { get; set; } = 3f; + public float Radius { get; set; } = 24f; + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public List PatrolPoints { get; set; } = []; + public int PatrolIndex { get; set; } + public List RepeatOrders { get; set; } = []; + public int RepeatIndex { get; set; } } public sealed class ShipOrderTemplateRuntime { - public required string Kind { get; init; } - public string? Label { get; set; } - public string? TargetEntityId { get; set; } - public string? TargetSystemId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? SourceStationId { get; set; } - public string? DestinationStationId { get; set; } - public string? ItemId { get; set; } - public string? NodeId { get; set; } - public string? ConstructionSiteId { get; set; } - public string? ModuleId { get; set; } - public float WaitSeconds { get; set; } - public float Radius { get; set; } - public int? MaxSystemRange { get; set; } - public bool KnownStationsOnly { get; set; } + public required string Kind { get; init; } + public string? Label { get; set; } + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string? ItemId { get; set; } + public string? NodeId { get; set; } + public string? ConstructionSiteId { get; set; } + public string? ModuleId { get; set; } + public float WaitSeconds { get; set; } + public float Radius { get; set; } + public int? MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } } public sealed class ShipPlanRuntime { - public required string Id { get; init; } - public required AiPlanSourceKind SourceKind { get; init; } - public required string SourceId { get; init; } - public required string Kind { get; init; } - public required string Summary { get; set; } - public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned; - public int CurrentStepIndex { get; set; } - public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public string? InterruptReason { get; set; } - public string? FailureReason { get; set; } - public List Steps { get; } = []; + public required string Id { get; init; } + public required AiPlanSourceKind SourceKind { get; init; } + public required string SourceId { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } + public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned; + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public string? InterruptReason { get; set; } + public string? FailureReason { get; set; } + public List Steps { get; } = []; } public sealed class ShipPlanStepRuntime { - public required string Id { get; init; } - public required string Kind { get; init; } - public required string Summary { get; set; } - public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned; - public int CurrentSubTaskIndex { get; set; } - public string? BlockingReason { get; set; } - public List SubTasks { get; } = []; + public required string Id { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } + public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned; + public int CurrentSubTaskIndex { get; set; } + public string? BlockingReason { get; set; } + public List SubTasks { get; } = []; } public sealed class ShipSubTaskRuntime { - public required string Id { get; init; } - public required string Kind { get; init; } - public required string Summary { get; set; } - public WorkStatus Status { get; set; } = WorkStatus.Pending; - public string? TargetEntityId { get; set; } - public string? TargetSystemId { get; set; } - public string? TargetNodeId { get; set; } - public Vector3? TargetPosition { get; set; } - public string? ItemId { get; set; } - public string? ModuleId { get; set; } - public float Threshold { get; set; } - public float Amount { get; set; } - public float ElapsedSeconds { get; set; } - public float TotalSeconds { get; set; } - public float Progress { get; set; } - public string? BlockingReason { get; set; } + public required string Id { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } + public WorkStatus Status { get; set; } = WorkStatus.Pending; + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetNodeId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? ItemId { get; set; } + public string? ModuleId { get; set; } + public float Threshold { get; set; } + public float Amount { get; set; } + public float ElapsedSeconds { get; set; } + public float TotalSeconds { get; set; } + public float Progress { get; set; } + public string? BlockingReason { get; set; } } diff --git a/apps/backend/Ships/Simulation/ShipAiService.cs b/apps/backend/Ships/Simulation/ShipAiService.cs index a4b59c1..6090922 100644 --- a/apps/backend/Ships/Simulation/ShipAiService.cs +++ b/apps/backend/Ships/Simulation/ShipAiService.cs @@ -6,350 +6,350 @@ namespace SpaceGame.Api.Ships.Simulation; internal sealed class ShipAiService { - private const float WarpEngageDistanceKilometers = 250_000f; - private const float FrigateDps = 7f; - private const float DestroyerDps = 12f; - private const float CruiserDps = 18f; - private const float CapitalDps = 26f; + private const float WarpEngageDistanceKilometers = 250_000f; + private const float FrigateDps = 7f; + private const float DestroyerDps = 12f; + private const float CruiserDps = 18f; + private const float CapitalDps = 26f; - internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) - { - if (ship.ReplanCooldownSeconds > 0f) + internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) { - ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); + if (ship.ReplanCooldownSeconds > 0f) + { + ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); + } + + var previousState = ship.State; + var previousPlanId = ship.ActivePlan?.Id; + var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; + + EnsurePlan(world, ship, events); + ExecutePlan(world, ship, deltaSeconds, events); + TrackHistory(ship); + EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); } - var previousState = ship.State; - var previousPlanId = ship.ActivePlan?.Id; - var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; - - EnsurePlan(world, ship, events); - ExecutePlan(world, ship, deltaSeconds, events); - TrackHistory(ship); - EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); - } - - private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) - { - var emergencyPlan = BuildEmergencyPlan(world, ship); - if (emergencyPlan is not null) + private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) { - ship.LastReplanReason = "rule-safety"; - ReplacePlan(ship, emergencyPlan, "rule-safety", events); - return; + var emergencyPlan = BuildEmergencyPlan(world, ship); + if (emergencyPlan is not null) + { + ship.LastReplanReason = "rule-safety"; + ReplacePlan(ship, emergencyPlan, "rule-safety", events); + return; + } + + var topOrder = GetTopOrder(ship); + if (topOrder is not null && topOrder.Status == OrderStatus.Queued) + { + topOrder.Status = OrderStatus.Active; + } + + var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; + var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; + var currentPlan = ship.ActivePlan; + + if (currentPlan is not null + && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted + && currentPlan.SourceKind == desiredSourceKind + && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) + && !ship.NeedsReplan) + { + return; + } + + if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) + { + return; + } + + ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order + ? BuildOrderPlan(world, ship, topOrder!) + : BuildBehaviorPlan(world, ship); + + if (nextPlan is null) + { + nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); + } + + if (nextPlan.Kind != "idle") + { + ship.LastAccessFailureReason = null; + } + + ReplacePlan(ship, nextPlan, "replanned", events); } - var topOrder = GetTopOrder(ship); - if (topOrder is not null && topOrder.Status == OrderStatus.Queued) + private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) { - topOrder.Status = OrderStatus.Active; - } + var plan = ship.ActivePlan; + if (plan is null) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return; + } - var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; - var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; - var currentPlan = ship.ActivePlan; + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + CompletePlan(ship, plan, events); + return; + } - if (currentPlan is not null - && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted - && currentPlan.SourceKind == desiredSourceKind - && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) - && !ship.NeedsReplan) - { - return; - } + plan.Status = AiPlanStatus.Running; + plan.UpdatedAtUtc = DateTimeOffset.UtcNow; - if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) - { - return; - } + var step = plan.Steps[plan.CurrentStepIndex]; + if (step.Status == AiPlanStepStatus.Planned) + { + step.Status = AiPlanStepStatus.Running; + } - ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order - ? BuildOrderPlan(world, ship, topOrder!) - : BuildBehaviorPlan(world, ship); - - if (nextPlan is null) - { - nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); - } - - if (nextPlan.Kind != "idle") - { - ship.LastAccessFailureReason = null; - } - - ReplacePlan(ship, nextPlan, "replanned", events); - } - - private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) - { - var plan = ship.ActivePlan; - if (plan is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return; - } - - if (plan.CurrentStepIndex >= plan.Steps.Count) - { - CompletePlan(ship, plan, events); - return; - } - - plan.Status = AiPlanStatus.Running; - plan.UpdatedAtUtc = DateTimeOffset.UtcNow; - - var step = plan.Steps[plan.CurrentStepIndex]; - if (step.Status == AiPlanStepStatus.Planned) - { - step.Status = AiPlanStepStatus.Running; - } - - if (step.CurrentSubTaskIndex >= step.SubTasks.Count) - { - CompleteStep(plan, step); - return; - } - - var subTask = step.SubTasks[step.CurrentSubTaskIndex]; - if (subTask.Status == WorkStatus.Pending) - { - subTask.Status = WorkStatus.Active; - } - - var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); - switch (outcome) - { - case SubTaskOutcome.Active: - step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; - plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; - return; - case SubTaskOutcome.Completed: - subTask.Status = WorkStatus.Completed; - subTask.Progress = 1f; - step.CurrentSubTaskIndex += 1; - step.BlockingReason = null; if (step.CurrentSubTaskIndex >= step.SubTasks.Count) { - CompleteStep(plan, step); + CompleteStep(plan, step); + return; } - return; - case SubTaskOutcome.Failed: - subTask.Status = WorkStatus.Failed; - step.Status = AiPlanStepStatus.Failed; - plan.Status = AiPlanStatus.Failed; - plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; + + var subTask = step.SubTasks[step.CurrentSubTaskIndex]; + if (subTask.Status == WorkStatus.Pending) + { + subTask.Status = WorkStatus.Active; + } + + var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); + switch (outcome) + { + case SubTaskOutcome.Active: + step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; + plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; + return; + case SubTaskOutcome.Completed: + subTask.Status = WorkStatus.Completed; + subTask.Progress = 1f; + step.CurrentSubTaskIndex += 1; + step.BlockingReason = null; + if (step.CurrentSubTaskIndex >= step.SubTasks.Count) + { + CompleteStep(plan, step); + } + return; + case SubTaskOutcome.Failed: + subTask.Status = WorkStatus.Failed; + step.Status = AiPlanStepStatus.Failed; + plan.Status = AiPlanStatus.Failed; + plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.5f; + ship.LastReplanReason = plan.FailureReason; + return; + } + } + + private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) + { + step.Status = AiPlanStepStatus.Completed; + step.BlockingReason = null; + plan.CurrentStepIndex += 1; + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + plan.Status = AiPlanStatus.Completed; + } + } + + private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) + { + plan.Status = AiPlanStatus.Completed; + var completedOrder = plan.SourceKind == AiPlanSourceKind.Order + ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) + : null; + if (completedOrder is not null) + { + completedOrder.Status = OrderStatus.Completed; + ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); + } + else if (plan.SourceKind == AiPlanSourceKind.DefaultBehavior + && string.Equals(ship.DefaultBehavior.Kind, "repeat-orders", StringComparison.Ordinal) + && ship.DefaultBehavior.RepeatOrders.Count > 0) + { + ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; + } + + ship.ActivePlan = null; ship.NeedsReplan = true; - ship.ReplanCooldownSeconds = 0.5f; - ship.LastReplanReason = plan.FailureReason; - return; - } - } - - private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) - { - step.Status = AiPlanStepStatus.Completed; - step.BlockingReason = null; - plan.CurrentStepIndex += 1; - if (plan.CurrentStepIndex >= plan.Steps.Count) - { - plan.Status = AiPlanStatus.Completed; - } - } - - private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) - { - plan.Status = AiPlanStatus.Completed; - var completedOrder = plan.SourceKind == AiPlanSourceKind.Order - ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) - : null; - if (completedOrder is not null) - { - completedOrder.Status = OrderStatus.Completed; - ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); - } - else if (plan.SourceKind == AiPlanSourceKind.DefaultBehavior - && string.Equals(ship.DefaultBehavior.Kind, "repeat-orders", StringComparison.Ordinal) - && ship.DefaultBehavior.RepeatOrders.Count > 0) - { - ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; + ship.ReplanCooldownSeconds = 0.25f; + ship.LastReplanReason = "plan-completed"; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Label} completed {plan.Kind}.", DateTimeOffset.UtcNow)); } - ship.ActivePlan = null; - ship.NeedsReplan = true; - ship.ReplanCooldownSeconds = 0.25f; - ship.LastReplanReason = "plan-completed"; - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Label} completed {plan.Kind}.", DateTimeOffset.UtcNow)); - } - - private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) - { - if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) + private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) { - ship.ActivePlan.Status = AiPlanStatus.Interrupted; - ship.ActivePlan.InterruptReason = reason; + if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) + { + ship.ActivePlan.Status = AiPlanStatus.Interrupted; + ship.ActivePlan.InterruptReason = reason; + } + + ship.ActivePlan = nextPlan; + ship.NeedsReplan = false; + ship.ReplanCooldownSeconds = 0f; + ship.LastReplanReason = reason; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Label} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); } - ship.ActivePlan = nextPlan; - ship.NeedsReplan = false; - ship.ReplanCooldownSeconds = 0f; - ship.LastReplanReason = reason; - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Label} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); - } - - private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - if (policy is null) + private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) { - return null; + var policy = ResolvePolicy(world, ship.PolicySetId); + if (policy is null) + { + return null; + } + + var hullRatio = ship.Definition.MaxHealth <= 0.01f ? 1f : ship.Health / ship.Definition.MaxHealth; + if (hullRatio > policy.FleeHullRatio) + { + return null; + } + + var hostileNearby = world.Ships.Any(candidate => + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + candidate.SystemId == ship.SystemId && + candidate.Position.DistanceTo(ship.Position) <= 200f); + if (!hostileNearby) + { + return null; + } + + var safeStation = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", + SourceKind = AiPlanSourceKind.Rule, + SourceId = ShipOrderKinds.Flee, + Kind = "safety-flee", + Summary = "Emergency retreat", + }; + + if (safeStation is null) + { + plan.Steps.Add(CreateStep("step-flee-hold", "hold-position", "Hold position away from hostiles", + [ + CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) + ])); + return plan; + } + + plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", + [ + CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(world.Balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) + ])); + plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", + [ + CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) + ])); + return plan; } - var hullRatio = ship.Definition.MaxHealth <= 0.01f ? 1f : ship.Health / ship.Definition.MaxHealth; - if (hullRatio > policy.FleeHullRatio) + private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - return null; + return order.Kind switch + { + var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), + _ => null, + }; } - var hostileNearby = world.Ships.Any(candidate => - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - candidate.SystemId == ship.SystemId && - candidate.Position.DistanceTo(ship.Position) <= 200f); - if (!hostileNearby) + private ShipPlanRuntime? BuildBehaviorPlan(SimulationWorld world, ShipRuntime ship) { - return null; + var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); + return behaviorKind switch + { + "local-auto-mine" => BuildMiningBehaviorPlan(world, ship, "local-auto-mine", sourceId), + "advanced-auto-mine" => BuildMiningBehaviorPlan(world, ship, "advanced-auto-mine", sourceId), + "expert-auto-mine" => BuildMiningBehaviorPlan(world, ship, "expert-auto-mine", sourceId), + "local-auto-trade" => BuildTradeBehaviorPlan(world, ship, "local-auto-trade", sourceId), + "advanced-auto-trade" => BuildTradeBehaviorPlan(world, ship, "advanced-auto-trade", sourceId), + "fill-shortages" => BuildTradeBehaviorPlan(world, ship, "fill-shortages", sourceId), + "find-build-tasks" => BuildTradeBehaviorPlan(world, ship, "find-build-tasks", sourceId), + "revisit-known-stations" => BuildTradeBehaviorPlan(world, ship, "revisit-known-stations", sourceId), + "supply-fleet" => BuildTradeBehaviorPlan(world, ship, "supply-fleet", sourceId), + "construct-station" => BuildConstructionBehaviorPlan(world, ship, sourceId), + "attack-target" => BuildAttackBehaviorPlan(world, ship, sourceId), + "protect-position" => BuildProtectPositionBehaviorPlan(world, ship, sourceId), + "protect-ship" => BuildProtectShipBehaviorPlan(world, ship, sourceId), + "protect-station" => BuildProtectStationBehaviorPlan(world, ship, sourceId), + "police" => BuildPoliceBehaviorPlan(world, ship, sourceId), + "patrol" => BuildPatrolBehaviorPlan(world, ship, sourceId), + "dock-and-wait" => BuildDockAndWaitBehaviorPlan(world, ship, sourceId), + "fly-and-wait" => BuildFlyAndWaitBehaviorPlan(ship, sourceId), + "fly-to-object" => BuildFlyToObjectBehaviorPlan(world, ship, sourceId), + "follow-ship" => BuildFollowShipBehaviorPlan(world, ship, sourceId), + "hold-position" => BuildBehaviorHoldPositionPlan(ship, sourceId), + "auto-salvage" => BuildAutoSalvageBehaviorPlan(world, ship, sourceId), + "repeat-orders" => BuildRepeatOrdersBehaviorPlan(world, ship, sourceId), + _ => CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"), + }; } - var safeStation = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - - var plan = new ShipPlanRuntime + private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) { - Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", - SourceKind = AiPlanSourceKind.Rule, - SourceId = ShipOrderKinds.Flee, - Kind = "safety-flee", - Summary = "Emergency retreat", - }; - - if (safeStation is null) - { - plan.Steps.Add(CreateStep("step-flee-hold", "hold-position", "Hold position away from hostiles", - [ - CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) - ])); - return plan; + var assignment = ResolveAssignment(world, ship); + return assignment is null + ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) + : (assignment.BehaviorKind, assignment.ObjectiveId); } - plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", - [ - CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(world.Balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) - ])); - plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", - [ - CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) - ])); - return plan; - } - - private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - return order.Kind switch + private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) { - var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), - var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), - _ => null, - }; - } - - private ShipPlanRuntime? BuildBehaviorPlan(SimulationWorld world, ShipRuntime ship) - { - var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); - return behaviorKind switch - { - "local-auto-mine" => BuildMiningBehaviorPlan(world, ship, "local-auto-mine", sourceId), - "advanced-auto-mine" => BuildMiningBehaviorPlan(world, ship, "advanced-auto-mine", sourceId), - "expert-auto-mine" => BuildMiningBehaviorPlan(world, ship, "expert-auto-mine", sourceId), - "local-auto-trade" => BuildTradeBehaviorPlan(world, ship, "local-auto-trade", sourceId), - "advanced-auto-trade" => BuildTradeBehaviorPlan(world, ship, "advanced-auto-trade", sourceId), - "fill-shortages" => BuildTradeBehaviorPlan(world, ship, "fill-shortages", sourceId), - "find-build-tasks" => BuildTradeBehaviorPlan(world, ship, "find-build-tasks", sourceId), - "revisit-known-stations" => BuildTradeBehaviorPlan(world, ship, "revisit-known-stations", sourceId), - "supply-fleet" => BuildTradeBehaviorPlan(world, ship, "supply-fleet", sourceId), - "construct-station" => BuildConstructionBehaviorPlan(world, ship, sourceId), - "attack-target" => BuildAttackBehaviorPlan(world, ship, sourceId), - "protect-position" => BuildProtectPositionBehaviorPlan(world, ship, sourceId), - "protect-ship" => BuildProtectShipBehaviorPlan(world, ship, sourceId), - "protect-station" => BuildProtectStationBehaviorPlan(world, ship, sourceId), - "police" => BuildPoliceBehaviorPlan(world, ship, sourceId), - "patrol" => BuildPatrolBehaviorPlan(world, ship, sourceId), - "dock-and-wait" => BuildDockAndWaitBehaviorPlan(world, ship, sourceId), - "fly-and-wait" => BuildFlyAndWaitBehaviorPlan(ship, sourceId), - "fly-to-object" => BuildFlyToObjectBehaviorPlan(world, ship, sourceId), - "follow-ship" => BuildFollowShipBehaviorPlan(world, ship, sourceId), - "hold-position" => BuildBehaviorHoldPositionPlan(ship, sourceId), - "auto-salvage" => BuildAutoSalvageBehaviorPlan(world, ship, sourceId), - "repeat-orders" => BuildRepeatOrdersBehaviorPlan(world, ship, sourceId), - _ => CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"), - }; - } - - private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) - { - var assignment = ResolveAssignment(world, ship); - return assignment is null - ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) - : (assignment.BehaviorKind, assignment.ObjectiveId); - } - - private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) - { - var targetSystemId = order.TargetSystemId ?? ship.SystemId; - var targetPosition = order.TargetPosition ?? ship.Position; - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "move", - order.Label ?? "Move order", - [ - CreateStep("step-move", "travel", order.Label ?? "Travel", + var targetSystemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "move", + order.Label ?? "Move order", + [ + CreateStep("step-move", "travel", order.Label ?? "Travel", [ CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) ]) - ]); - } - - private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); - if (station is null) - { - order.FailureReason = "station-missing"; - return null; + ]); } - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "dock-at-station", - order.Label ?? $"Dock at {station.Label}", - [ - CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", + private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "dock-at-station", + order.Label ?? $"Dock at {station.Label}", + [ + CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", [ CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(world.Balance.ArrivalThreshold, station.Radius + 12f), 0f) ]), @@ -357,163 +357,163 @@ internal sealed class ShipAiService [ CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f) ]) - ]); - } - - private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) - { - order.FailureReason = "trade-order-incomplete"; - return null; + ]); } - var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); - if (route is null) + private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "trade-route-missing"; - return null; + if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) + { + order.FailureReason = "trade-order-incomplete"; + return null; + } + + var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); + if (route is null) + { + order.FailureReason = "trade-route-missing"; + return null; + } + + return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); } - return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); - } - - private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var homeStation = ResolveStation(world, order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); - var node = ResolveNode(world, order.NodeId); - if (homeStation is null || node is null) + private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "mine-order-incomplete"; - return null; + var homeStation = ResolveStation(world, order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); + var node = ResolveNode(world, order.NodeId); + if (homeStation is null || node is null) + { + order.FailureReason = "mine-order-incomplete"; + return null; + } + + return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, homeStation, order.Label ?? $"Mine {node.ItemId}"); } - return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, homeStation, order.Label ?? $"Mine {node.ItemId}"); - } - - private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); - if (site is null) + private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "construction-site-missing"; - return null; + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); + if (site is null) + { + order.FailureReason = "construction-site-missing"; + return null; + } + + var supportStation = ResolveSupportStation(world, ship, site); + if (supportStation is null) + { + order.FailureReason = "support-station-missing"; + return null; + } + + return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); } - var supportStation = ResolveSupportStation(world, ship, site); - if (supportStation is null) + private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "support-station-missing"; - return null; + var targetId = order.TargetEntityId; + if (targetId is null) + { + order.FailureReason = "attack-target-missing"; + return null; + } + + return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); } - return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); - } - - private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetId = order.TargetEntityId; - if (targetId is null) + private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "attack-target-missing"; - return null; - } - - return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); - } - - private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) - { - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - "hold-position", - order.Label ?? "Hold position", - [ - CreateStep("step-hold", "hold-position", order.Label ?? "Hold position", + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "hold-position", + order.Label ?? "Hold position", + [ + CreateStep("step-hold", "hold-position", order.Label ?? "Hold position", [ CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f) ]) - ]); - } - - private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); - if (station is null) - { - order.FailureReason = "station-missing"; - return null; + ]); } - return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); - } - - private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) - { - var systemId = order.TargetSystemId ?? ship.SystemId; - var targetPosition = order.TargetPosition ?? ship.Position; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); - } - - private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetEntityId = order.TargetEntityId; - if (targetEntityId is null) + private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "target-missing"; - return null; + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); } - var objectTarget = ResolveObjectTarget(world, targetEntityId); - if (objectTarget is null) + private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "target-missing"; - return null; + var systemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); } - return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); - } - - private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) + private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - order.FailureReason = "target-ship-missing"; - return null; + var targetEntityId = order.TargetEntityId; + if (targetEntityId is null) + { + order.FailureReason = "target-missing"; + return null; + } + + var objectTarget = ResolveObjectTarget(world, targetEntityId); + if (objectTarget is null) + { + order.FailureReason = "target-missing"; + return null; + } + + return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); } - return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Label}"); - } - - private ShipPlanRuntime? BuildMiningBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (homeStation is null) + private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No home station"); + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + order.FailureReason = "target-ship-missing"; + return null; + } + + return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Label}"); } - var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); - return opportunity is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No mineable node") - : BuildMiningPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, opportunity.Node, opportunity.DropOffStation, opportunity.Summary); - } + private ShipPlanRuntime? BuildMiningBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (homeStation is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No home station"); + } - private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) - { - var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); - return CreatePlan( - ship, - sourceKind, - sourceId, - "mine-and-deliver", - summary, - [ - CreateStep("step-mine", "mine", $"Mine {node.ItemId}", + var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); + return opportunity is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No mineable node") + : BuildMiningPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, opportunity.Node, opportunity.DropOffStation, opportunity.Summary); + } + + private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) + { + var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + return CreatePlan( + ship, + sourceKind, + sourceId, + "mine-and-deliver", + summary, + [ + CreateStep("step-mine", "mine", $"Mine {node.ItemId}", [ CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.CargoCapacity) @@ -525,46 +525,46 @@ internal sealed class ShipAiService CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity), CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) ]) - ]); - } - - private ShipPlanRuntime? BuildTradeBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (string.Equals(behaviorKind, "supply-fleet", StringComparison.Ordinal)) - { - var fleetPlan = SelectFleetSupplyPlan(world, ship, homeStation); - return fleetPlan is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No fleet to supply") - : BuildFleetSupplyPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, fleetPlan); + ]); } - var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); - if (route is not null) + private ShipPlanRuntime? BuildTradeBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) { - return BuildTradePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, route, route.Summary); + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (string.Equals(behaviorKind, "supply-fleet", StringComparison.Ordinal)) + { + var fleetPlan = SelectFleetSupplyPlan(world, ship, homeStation); + return fleetPlan is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No fleet to supply") + : BuildFleetSupplyPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, fleetPlan); + } + + var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); + if (route is not null) + { + return BuildTradePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, route, route.Summary); + } + + if (string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) + && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) + { + return BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); + } + + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No trade route"); } - if (string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) - && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) + private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) { - return BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); - } - - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No trade route"); - } - - private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "trade-route", - summary, - [ - CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", + return CreatePlan( + ship, + sourceKind, + sourceId, + "trade-route", + summary, + [ + CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", [ CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f), @@ -578,19 +578,19 @@ internal sealed class ShipAiService CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f) ]) - ]); - } + ]); + } - private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "supply-fleet", - plan.Summary, - [ - CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", + private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "supply-fleet", + plan.Summary, + [ + CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", [ CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f), @@ -602,39 +602,39 @@ internal sealed class ShipAiService CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Label}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f), CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId), ]) - ]); - } - - private ShipPlanRuntime? BuildConstructionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) - ?? world.ConstructionSites - .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) - .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (site is null) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No construction site"); + ]); } - var supportStation = ResolveSupportStation(world, ship, site); - return supportStation is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No support station") - : BuildConstructionPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, site, supportStation, $"Build {site.BlueprintId}"); - } + private ShipPlanRuntime? BuildConstructionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) + ?? world.ConstructionSites + .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (site is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No construction site"); + } - private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) - { - var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; - return CreatePlan( - ship, - sourceKind, - sourceId, - "construction-support", - summary, - [ - CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", + var supportStation = ResolveSupportStation(world, ship, site); + return supportStation is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No support station") + : BuildConstructionPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, site, supportStation, $"Build {site.BlueprintId}"); + } + + private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) + { + var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; + return CreatePlan( + ship, + sourceKind, + sourceId, + "construction-support", + summary, + [ + CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", [ CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) @@ -643,78 +643,78 @@ internal sealed class ShipAiService [ CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) ]) - ]); - } - - private ShipPlanRuntime? BuildAttackBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var targetId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; - if (targetId is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); + ]); } - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetId, assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId, "Attack target"); - } + private ShipPlanRuntime? BuildAttackBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var targetId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + if (targetId is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } - private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "attack-target", - summary, - [ - CreateStep("step-attack", "attack-target", summary, + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetId, assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId, "Attack target"); + } + + private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "attack-target", + summary, + [ + CreateStep("step-attack", "attack-target", summary, [ CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) ]) - ]); - } - - private ShipPlanRuntime BuildPatrolBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; - var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); - if (patrolThreat is not null) - { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, patrolThreat.EntityId, patrolThreat.SystemId, "Patrol intercept"); + ]); } - var patrolPoints = ship.DefaultBehavior.PatrolPoints; - Vector3 targetPosition; - string targetSystemId; - if (patrolPoints.Count > 0) + private ShipPlanRuntime BuildPatrolBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - var index = ship.DefaultBehavior.PatrolIndex % patrolPoints.Count; - targetPosition = patrolPoints[index]; - ship.DefaultBehavior.PatrolIndex = (index + 1) % patrolPoints.Count; - targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - } - else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? ResolveAssignment(world, ship)?.HomeStationId) is { } homeStation) - { - var patrolRadius = homeStation.Radius + 90f; - targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); - targetSystemId = homeStation.SystemId; - } - else - { - targetPosition = ship.Position; - targetSystemId = ship.SystemId; - } + var assignment = ResolveAssignment(world, ship); + var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); + if (patrolThreat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, patrolThreat.EntityId, patrolThreat.SystemId, "Patrol intercept"); + } - return CreatePlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - "patrol", - "Patrol sector", - [ - CreateStep("step-patrol-travel", "travel", "Travel patrol waypoint", + var patrolPoints = ship.DefaultBehavior.PatrolPoints; + Vector3 targetPosition; + string targetSystemId; + if (patrolPoints.Count > 0) + { + var index = ship.DefaultBehavior.PatrolIndex % patrolPoints.Count; + targetPosition = patrolPoints[index]; + ship.DefaultBehavior.PatrolIndex = (index + 1) % patrolPoints.Count; + targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + } + else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? ResolveAssignment(world, ship)?.HomeStationId) is { } homeStation) + { + var patrolRadius = homeStation.Radius + 90f; + targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); + targetSystemId = homeStation.SystemId; + } + else + { + targetPosition = ship.Position; + targetSystemId = ship.SystemId; + } + + return CreatePlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + "patrol", + "Patrol sector", + [ + CreateStep("step-patrol-travel", "travel", "Travel patrol waypoint", [ CreateSubTask("sub-patrol-travel", ShipTaskKinds.Travel, "Travel patrol waypoint", targetSystemId, targetPosition, null, 10f, 0f) ]), @@ -722,133 +722,133 @@ internal sealed class ShipAiService [ CreateSubTask("sub-patrol-hold", ShipTaskKinds.HoldPosition, "Hold patrol waypoint", targetSystemId, targetPosition, null, 0f, 2f) ]) - ]); - } - - private ShipPlanRuntime? BuildPoliceBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; - var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; - var contact = SelectPoliceContact(world, ship, systemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); - if (contact is null) - { - return BuildPatrolBehaviorPlan(world, ship, sourceId); + ]); } - return contact.Engage - ? BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, "Police engage") - : BuildFollowPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Police inspect"); - } - - private ShipPlanRuntime BuildProtectPositionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; - var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); - if (threat is not null) + private ShipPlanRuntime? BuildPoliceBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, "Protect position"); + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; + var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; + var contact = SelectPoliceContact(world, ship, systemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); + if (contact is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + return contact.Engage + ? BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, "Police engage") + : BuildFollowPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Police inspect"); } - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Protect position"); - } - - private ShipPlanRuntime BuildProtectShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var guardTarget = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); - if (guardTarget is null) + private ShipPlanRuntime BuildProtectPositionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - return BuildPatrolBehaviorPlan(world, ship, sourceId); + var assignment = ResolveAssignment(world, ship); + var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, "Protect position"); + } + + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Protect position"); } - var threat = SelectThreatTarget(world, ship, guardTarget.SystemId, guardTarget.Position, MathF.Max(90f, ship.DefaultBehavior.Radius), excludeEntityId: guardTarget.Id); - if (threat is not null) + private ShipPlanRuntime BuildProtectShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {guardTarget.Definition.Label}"); + var guardTarget = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); + if (guardTarget is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + var threat = SelectThreatTarget(world, ship, guardTarget.SystemId, guardTarget.Position, MathF.Max(90f, ship.DefaultBehavior.Radius), excludeEntityId: guardTarget.Id); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {guardTarget.Definition.Label}"); + } + + return BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, guardTarget, MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Escort {guardTarget.Definition.Label}"); } - return BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, guardTarget, MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Escort {guardTarget.Definition.Label}"); - } - - private ShipPlanRuntime BuildProtectStationBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - if (station is null) + private ShipPlanRuntime BuildProtectStationBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - return BuildPatrolBehaviorPlan(world, ship, sourceId); + var assignment = ResolveAssignment(world, ship); + var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {station.Label}"); + } + + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Guard {station.Label}"); } - var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); - if (threat is not null) + private ShipPlanRuntime BuildDockAndWaitBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {station.Label}"); + var station = ResolveStation(world, ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); + return station is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No station to dock") + : BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Dock and wait at {station.Label}"); } - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Guard {station.Label}"); - } - - private ShipPlanRuntime BuildDockAndWaitBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var station = ResolveStation(world, ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); - return station is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No station to dock") - : BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Dock and wait at {station.Label}"); - } - - private ShipPlanRuntime BuildFlyAndWaitBehaviorPlan(ShipRuntime ship, string sourceId) - { - var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; - var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Fly and wait"); - } - - private ShipPlanRuntime BuildFlyToObjectBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var targetEntityId = ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; - var objectTarget = ResolveObjectTarget(world, targetEntityId); - return objectTarget is null || targetEntityId is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No object target") - : BuildFlyToObjectPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, "Fly to object"); - } - - private ShipPlanRuntime BuildFollowShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); - return targetShip is null - ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No ship to follow") - : BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetShip, MathF.Max(16f, ship.DefaultBehavior.Radius), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Follow {targetShip.Definition.Label}"); - } - - private ShipPlanRuntime BuildBehaviorHoldPositionPlan(ShipRuntime ship, string sourceId) - { - var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; - var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Hold position"); - } - - private ShipPlanRuntime BuildAutoSalvageBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - var assignment = ResolveAssignment(world, ship); - var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); - var salvage = SelectSalvageOpportunity(world, ship, homeStation); - if (salvage is null || homeStation is null) + private ShipPlanRuntime BuildFlyAndWaitBehaviorPlan(ShipRuntime ship, string sourceId) { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No salvage target"); + var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; + var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Fly and wait"); } - var approach = GetFormationPosition(salvage.Wreck.Position, ship.Id, MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f)); - return CreatePlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - "auto-salvage", - salvage.Summary, - [ - CreateStep("step-salvage-collect", "salvage", $"Salvage {salvage.Wreck.ItemId}", + private ShipPlanRuntime BuildFlyToObjectBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var targetEntityId = ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + var objectTarget = ResolveObjectTarget(world, targetEntityId); + return objectTarget is null || targetEntityId is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No object target") + : BuildFlyToObjectPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, "Fly to object"); + } + + private ShipPlanRuntime BuildFollowShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); + return targetShip is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No ship to follow") + : BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetShip, MathF.Max(16f, ship.DefaultBehavior.Radius), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Follow {targetShip.Definition.Label}"); + } + + private ShipPlanRuntime BuildBehaviorHoldPositionPlan(ShipRuntime ship, string sourceId) + { + var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; + var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Hold position"); + } + + private ShipPlanRuntime BuildAutoSalvageBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var salvage = SelectSalvageOpportunity(world, ship, homeStation); + if (salvage is null || homeStation is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No salvage target"); + } + + var approach = GetFormationPosition(salvage.Wreck.Position, ship.Id, MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f)); + return CreatePlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + "auto-salvage", + salvage.Summary, + [ + CreateStep("step-salvage-collect", "salvage", $"Salvage {salvage.Wreck.ItemId}", [ CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {salvage.Wreck.Id}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, 0f), CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {salvage.Wreck.ItemId}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), @@ -860,1849 +860,1849 @@ internal sealed class ShipAiService CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), ]) - ]); - } - - private ShipPlanRuntime BuildRepeatOrdersBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) - { - if (ship.DefaultBehavior.RepeatOrders.Count == 0) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No repeat orders"); + ]); } - var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; - var syntheticOrder = new ShipOrderRuntime + private ShipPlanRuntime BuildRepeatOrdersBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { - Id = $"repeat-{ship.Id}-{ship.DefaultBehavior.RepeatIndex}", - Kind = template.Kind, - Label = template.Label, - TargetEntityId = template.TargetEntityId, - TargetSystemId = template.TargetSystemId, - TargetPosition = template.TargetPosition, - SourceStationId = template.SourceStationId, - DestinationStationId = template.DestinationStationId, - ItemId = template.ItemId, - NodeId = template.NodeId, - ConstructionSiteId = template.ConstructionSiteId, - ModuleId = template.ModuleId, - WaitSeconds = template.WaitSeconds, - Radius = template.Radius, - MaxSystemRange = template.MaxSystemRange, - KnownStationsOnly = template.KnownStationsOnly, - }; + if (ship.DefaultBehavior.RepeatOrders.Count == 0) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No repeat orders"); + } - return BuildOrderPlan(world, ship, syntheticOrder) - ?? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Invalid repeat order"); - } + var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; + var syntheticOrder = new ShipOrderRuntime + { + Id = $"repeat-{ship.Id}-{ship.DefaultBehavior.RepeatIndex}", + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition, + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds, + Radius = template.Radius, + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly, + }; - private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "dock-and-wait", - summary, - [ - CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", + return BuildOrderPlan(world, ship, syntheticOrder) + ?? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Invalid repeat order"); + } + + private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "dock-and-wait", + summary, + [ + CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", [ CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f), CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f), CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds), ]) - ]); - } + ]); + } - private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "fly-and-wait", - summary, - [ - CreateStep("step-fly-wait", "fly-and-wait", summary, + private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "fly-and-wait", + summary, + [ + CreateStep("step-fly-wait", "fly-and-wait", summary, [ CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f), CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds), ]) - ]); - } + ]); + } - private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "fly-to-object", - summary, - [ - CreateStep("step-fly-object", "fly-to-object", summary, + private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "fly-to-object", + summary, + [ + CreateStep("step-fly-object", "fly-to-object", summary, [ CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)), ]) - ]); - } + ]); + } - private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) - { - return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); - } + private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) + { + return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); + } - private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "follow-ship", - summary, - [ - CreateStep("step-follow", "follow-target", summary, + private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "follow-ship", + summary, + [ + CreateStep("step-follow", "follow-target", summary, [ CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), ]) - ]); - } + ]); + } - private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) - { - return CreatePlan( - ship, - sourceKind, - sourceId, - "idle", - summary, - [ - CreateStep("step-idle", "hold-position", summary, + private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "idle", + summary, + [ + CreateStep("step-idle", "hold-position", summary, [ CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) ]) - ]); - } - - private static ShipPlanRuntime CreatePlan( - ShipRuntime ship, - AiPlanSourceKind sourceKind, - string sourceId, - string kind, - string summary, - IReadOnlyList steps) - { - var plan = new ShipPlanRuntime - { - Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", - SourceKind = sourceKind, - SourceId = sourceId, - Kind = kind, - Summary = summary, - }; - plan.Steps.AddRange(steps); - return plan; - } - - private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) - { - var step = new ShipPlanStepRuntime - { - Id = id, - Kind = kind, - Summary = summary, - }; - step.SubTasks.AddRange(subTasks); - return step; - } - - private static ShipSubTaskRuntime CreateSubTask( - string id, - string kind, - string summary, - string targetSystemId, - Vector3 targetPosition, - string? targetEntityId, - float threshold, - float amount, - string? itemId = null, - string? moduleId = null, - string? targetNodeId = null) => - new() - { - Id = id, - Kind = kind, - Summary = summary, - TargetSystemId = targetSystemId, - TargetPosition = targetPosition, - TargetEntityId = targetEntityId, - TargetNodeId = targetNodeId, - ItemId = itemId, - ModuleId = moduleId, - Threshold = threshold, - Amount = amount, - }; - - private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) - { - return subTask.Kind switch - { - var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), - var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), - var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), - _ => SubTaskOutcome.Failed, - }; - } - - private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - ship.State = ShipState.HoldingPosition; - ship.TargetPosition = subTask.TargetPosition ?? ship.Position; - ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); - return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) - { - subTask.BlockingReason = "follow-target-missing"; - return SubTaskOutcome.Failed; + ]); } - var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); - subTask.TargetSystemId = targetShip.SystemId; - subTask.TargetPosition = desiredPosition; - subTask.BlockingReason = null; - if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + private static ShipPlanRuntime CreatePlan( + ShipRuntime ship, + AiPlanSourceKind sourceKind, + string sourceId, + string kind, + string summary, + IReadOnlyList steps) { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", + SourceKind = sourceKind, + SourceId = sourceId, + Kind = kind, + Summary = summary, + }; + plan.Steps.AddRange(steps); + return plan; } - ship.State = ShipState.HoldingPosition; - ship.TargetPosition = desiredPosition; - ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); - return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) - { - if (subTask.TargetPosition is null || subTask.TargetSystemId is null) + private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) { - subTask.BlockingReason = "travel-target-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; + var step = new ShipPlanStepRuntime + { + Id = id, + Kind = kind, + Summary = summary, + }; + step.SubTasks.AddRange(subTasks); + return step; } - var targetPosition = ResolveCurrentTargetPosition(world, subTask); - var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); - ship.TargetPosition = targetPosition; - - if (ship.SystemId != subTask.TargetSystemId) - { - if (!HasShipCapabilities(ship.Definition, "ftl")) + private static ShipSubTaskRuntime CreateSubTask( + string id, + string kind, + string summary, + string targetSystemId, + Vector3 targetPosition, + string? targetEntityId, + float threshold, + float amount, + string? itemId = null, + string? moduleId = null, + string? targetNodeId = null) => + new() { - subTask.BlockingReason = "ftl-unavailable"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } + Id = id, + Kind = kind, + Summary = summary, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + TargetEntityId = targetEntityId, + TargetNodeId = targetNodeId, + ItemId = itemId, + ModuleId = moduleId, + Threshold = threshold, + Amount = amount, + }; - var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); - var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; - return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); + private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) + { + return subTask.Kind switch + { + var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), + var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), + _ => SubTaskOutcome.Failed, + }; } - var currentCelestial = ResolveCurrentCelestial(world, ship); - if (targetCelestial is not null - && currentCelestial is not null - && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) + private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - if (!HasShipCapabilities(ship.Definition, "warp")) - { + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = subTask.TargetPosition ?? ship.Position; + ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "follow-target-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + subTask.BlockingReason = null; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) + { + if (subTask.TargetPosition is null || subTask.TargetSystemId is null) + { + subTask.BlockingReason = "travel-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = ResolveCurrentTargetPosition(world, subTask); + var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); + ship.TargetPosition = targetPosition; + + if (ship.SystemId != subTask.TargetSystemId) + { + if (!HasShipCapabilities(ship.Definition, "ftl")) + { + subTask.BlockingReason = "ftl-unavailable"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); + var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; + return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); + } + + var currentCelestial = ResolveCurrentCelestial(world, ship); + if (targetCelestial is not null + && currentCelestial is not null + && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) + { + if (!HasShipCapabilities(ship.Definition, "warp")) + { + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + } + + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + + if (targetCelestial is not null + && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers + && HasShipCapabilities(ship.Definition, "warp")) + { + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); - } - - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); } - if (targetCelestial is not null - && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers - && HasShipCapabilities(ship.Definition, "warp")) + private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + var hostileStation = hostileShip is null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) + : null; + if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) + || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) + { + subTask.BlockingReason = "friendly-target"; + return SubTaskOutcome.Failed; + } + + if (hostileShip is null && hostileStation is null) + { + return SubTaskOutcome.Completed; + } + + var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; + var targetPosition = hostileShip?.Position ?? hostileStation!.Position; + var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; + subTask.TargetSystemId = targetSystemId; + subTask.TargetPosition = targetPosition; + subTask.Threshold = attackRange; + + if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.EngagingTarget; + ship.TargetPosition = targetPosition; + ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); + var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); + subTask.Progress = 1f; + + if (hostileShip is not null) + { + hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); + return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); + return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } - return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); - } - - private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - var hostileStation = hostileShip is null - ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) - : null; - if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) - || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) + private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - subTask.BlockingReason = "friendly-target"; - return SubTaskOutcome.Failed; + var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); + if (node is null || !CanExtractNode(ship, node, world)) + { + subTask.BlockingReason = "node-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); + ship.TargetPosition = targetPosition; + if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) + { + ship.State = ShipState.MiningApproach; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + var cargoAmount = GetShipCargoAmount(ship); + if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) + { + return SubTaskOutcome.Completed; + } + + ship.State = ShipState.Mining; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.MiningCycleSeconds)) + { + return SubTaskOutcome.Active; + } + + var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); + var mined = MathF.Min(world.Balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); + mined = MathF.Min(mined, node.OreRemaining); + if (mined <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + AddInventory(ship.Inventory, node.ItemId, mined); + node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); + if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f || node.OreRemaining <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + subTask.ElapsedSeconds = 0f; + return SubTaskOutcome.Active; } - if (hostileShip is null && hostileStation is null) + private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - return SubTaskOutcome.Completed; + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is null) + { + subTask.BlockingReason = "dock-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); + if (padIndex is null) + { + ship.State = ShipState.AwaitingDock; + ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); + if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) + { + ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + } + + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-for-pad"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.AssignedDockingPadIndex = padIndex; + var padPosition = GetDockingPadPosition(station, padIndex.Value); + ship.TargetPosition = padPosition; + if (ship.Position.DistanceTo(padPosition) > 4f) + { + ship.State = ShipState.DockingApproach; + ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.DockingDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docked; + ship.DockedStationId = station.Id; + station.DockedShipIds.Add(ship.Id); + ship.KnownStationIds.Add(station.Id); + ship.Position = padPosition; + ship.TargetPosition = padPosition; + return SubTaskOutcome.Completed; } - var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; - var targetPosition = hostileShip?.Position ?? hostileStation!.Position; - var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; - subTask.TargetSystemId = targetSystemId; - subTask.TargetPosition = targetPosition; - subTask.Threshold = attackRange; - - if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) + private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + if (ship.DockedStationId is null) + { + return SubTaskOutcome.Completed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; + } + + var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + ship.TargetPosition = undockTarget; + ship.State = ShipState.Undocking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.UndockingDuration)) + { + ship.Position = GetShipDockedPosition(ship, station); + return SubTaskOutcome.Active; + } + + ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); + if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) + { + return SubTaskOutcome.Active; + } + + station.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(station, ship.Id); + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; } - ship.State = ShipState.EngagingTarget; - ship.TargetPosition = targetPosition; - ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); - var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); - subTask.Progress = 1f; - - if (hostileShip is not null) + private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); - return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Loading; + var itemId = subTask.ItemId; + if (itemId is null) + { + return SubTaskOutcome.Completed; + } + + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity; + var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Trade); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); + if (moved > 0.01f) + { + RemoveInventory(station.Inventory, itemId, moved); + AddInventory(ship.Inventory, itemId, moved); + } + + var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); + return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; } - hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); - return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); - if (node is null || !CanExtractNode(ship, node, world)) + private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - subTask.BlockingReason = "node-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } - var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); - ship.TargetPosition = targetPosition; - if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) - { - ship.State = ShipState.MiningApproach; - ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } - var cargoAmount = GetShipCargoAmount(ship); - if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) - { - return SubTaskOutcome.Completed; - } + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Transferring; + var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); - ship.State = ShipState.Mining; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.MiningCycleSeconds)) - { - return SubTaskOutcome.Active; - } + if (subTask.ItemId is not null) + { + var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); + var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); + RemoveInventory(ship.Inventory, subTask.ItemId, accepted); + subTask.Progress = subTask.Amount <= 0.01f + ? 1f + : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); + return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } - var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); - var mined = MathF.Min(world.Balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); - mined = MathF.Min(mined, node.OreRemaining); - if (mined <= 0.01f) - { - return SubTaskOutcome.Completed; - } + foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var moved = MathF.Min(amount, transferRate * deltaSeconds); + var accepted = TryAddStationInventory(world, station, itemId, moved); + RemoveInventory(ship.Inventory, itemId, accepted); + if (accepted > 0.01f) + { + return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + } - AddInventory(ship.Inventory, node.ItemId, mined); - node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); - if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f || node.OreRemaining <= 0.01f) - { - return SubTaskOutcome.Completed; - } - - subTask.ElapsedSeconds = 0f; - return SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var station = ResolveStation(world, subTask.TargetEntityId); - if (station is null) - { - subTask.BlockingReason = "dock-target-missing"; - ship.State = ShipState.Blocked; - return SubTaskOutcome.Failed; - } - - var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); - if (padIndex is null) - { - ship.State = ShipState.AwaitingDock; - ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); - if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) - { - ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - } - - subTask.Status = WorkStatus.Blocked; - subTask.BlockingReason = "waiting-for-pad"; - return SubTaskOutcome.Active; - } - - subTask.Status = WorkStatus.Active; - subTask.BlockingReason = null; - ship.AssignedDockingPadIndex = padIndex; - var padPosition = GetDockingPadPosition(station, padIndex.Value); - ship.TargetPosition = padPosition; - if (ship.Position.DistanceTo(padPosition) > 4f) - { - ship.State = ShipState.DockingApproach; - ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Docking; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.DockingDuration)) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Docked; - ship.DockedStationId = station.Id; - station.DockedShipIds.Add(ship.Id); - ship.KnownStationIds.Add(station.Id); - ship.Position = padPosition; - ship.TargetPosition = padPosition; - return SubTaskOutcome.Completed; - } - - private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - return SubTaskOutcome.Completed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return SubTaskOutcome.Completed; - } - - var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); - ship.TargetPosition = undockTarget; - ship.State = ShipState.Undocking; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.UndockingDuration)) - { - ship.Position = GetShipDockedPosition(ship, station); - return SubTaskOutcome.Active; - } - - ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); - if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) - { - return SubTaskOutcome.Active; - } - - station.DockedShipIds.Remove(ship.Id); - ReleaseDockingPad(station, ship.Id); - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return SubTaskOutcome.Completed; - } - - private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - subTask.BlockingReason = "not-docked"; - return SubTaskOutcome.Failed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - subTask.BlockingReason = "station-missing"; - return SubTaskOutcome.Failed; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.State = ShipState.Loading; - var itemId = subTask.ItemId; - if (itemId is null) - { - return SubTaskOutcome.Completed; - } - - var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity; - var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); - var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Trade); - var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); - if (moved > 0.01f) - { - RemoveInventory(station.Inventory, itemId, moved); - AddInventory(ship.Inventory, itemId, moved); - } - - var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); - subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); - return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - subTask.BlockingReason = "not-docked"; - return SubTaskOutcome.Failed; - } - - var station = ResolveStation(world, ship.DockedStationId); - if (station is null) - { - subTask.BlockingReason = "station-missing"; - return SubTaskOutcome.Failed; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.State = ShipState.Transferring; - var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); - - if (subTask.ItemId is not null) - { - var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); - var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); - RemoveInventory(ship.Inventory, subTask.ItemId, accepted); - subTask.Progress = subTask.Amount <= 0.01f - ? 1f - : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); - return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) - { - var moved = MathF.Min(amount, transferRate * deltaSeconds); - var accepted = TryAddStationInventory(world, station, itemId, moved); - RemoveInventory(ship.Inventory, itemId, accepted); - if (accepted > 0.01f) - { return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } } - return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); - if (targetShip is null) + private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - subTask.BlockingReason = "target-ship-missing"; - return SubTaskOutcome.Failed; + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "target-ship-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + if (subTask.ItemId is null) + { + return SubTaskOutcome.Completed; + } + + var targetCapacity = MathF.Max(0f, targetShip.Definition.CargoCapacity - GetShipCargoAmount(targetShip)); + if (targetCapacity <= 0.01f) + { + subTask.BlockingReason = "target-cargo-full"; + return SubTaskOutcome.Failed; + } + + var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); + if (moved > 0.01f) + { + RemoveInventory(ship.Inventory, subTask.ItemId, moved); + AddInventory(targetShip.Inventory, subTask.ItemId, moved); + } + + var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); + return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.CargoCapacity - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; } - var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); - subTask.TargetSystemId = targetShip.SystemId; - subTask.TargetPosition = desiredPosition; - if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); + if (wreck is null) + { + return SubTaskOutcome.Completed; + } + + var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); + ship.TargetPosition = desiredPosition; + if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) + { + subTask.TargetSystemId = wreck.SystemId; + subTask.TargetPosition = desiredPosition; + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + if (remainingCapacity <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, world.Balance.MiningCycleSeconds * 0.8f))) + { + return SubTaskOutcome.Active; + } + + var salvageRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); + var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); + if (recovered > 0.01f) + { + AddInventory(ship.Inventory, wreck.ItemId, recovered); + wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); + } + + if (wreck.RemainingAmount <= 0.01f) + { + world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); + } + + subTask.ElapsedSeconds = 0f; + return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; } - ship.State = ShipState.Transferring; - ship.TargetPosition = desiredPosition; - ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); - if (subTask.ItemId is null) + private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - return SubTaskOutcome.Completed; + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-target-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.DeliveringConstruction; + var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Construction); + foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); + var remaining = MathF.Max(0f, required.Value - delivered); + if (remaining <= 0.01f) + { + continue; + } + + var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); + var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); + if (moved <= 0.01f) + { + continue; + } + + RemoveInventory(station.Inventory, required.Key, moved); + AddInventory(site.Inventory, required.Key, moved); + AddInventory(site.DeliveredItems, required.Key, moved); + break; + } + + subTask.Progress = site.RequiredItems.Count == 0 + ? 1f + : site.RequiredItems.Sum(required => + required.Value <= 0.01f + ? 1f + : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; + return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } - var targetCapacity = MathF.Max(0f, targetShip.Definition.CargoCapacity - GetShipCargoAmount(targetShip)); - if (targetCapacity <= 0.01f) + private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { - subTask.BlockingReason = "target-cargo-full"; - return SubTaskOutcome.Failed; + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-site-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + ship.State = ShipState.WaitingMaterials; + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-materials"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.Constructing; + site.AssignedConstructorShipIds.Add(ship.Id); + site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); + subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); + if (site.Progress < recipe.Duration) + { + return SubTaskOutcome.Active; + } + + if (site.StationId is null) + { + CompleteStationFoundation(world, station, site); + } + else + { + AddStationModule(world, station, site.BlueprintId); + PrepareNextConstructionSiteStep(world, station, site); + } + + site.State = ConstructionSiteStateKinds.Completed; + return SubTaskOutcome.Completed; } - var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); - var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); - var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); - if (moved > 0.01f) + private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) { - RemoveInventory(ship.Inventory, subTask.ItemId, moved); - AddInventory(targetShip.Inventory, subTask.ItemId, moved); + subTask.TotalSeconds = requiredSeconds; + subTask.ElapsedSeconds += deltaSeconds; + subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); + if (subTask.ElapsedSeconds < requiredSeconds) + { + return false; + } + + subTask.ElapsedSeconds = 0f; + return true; } - var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); - subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); - return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.CargoCapacity - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); - if (wreck is null) + private SubTaskOutcome UpdateLocalTravel( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 targetPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival) { - return SubTaskOutcome.Completed; - } + var distance = ship.Position.DistanceTo(targetPosition); + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.Transit = null; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); - var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); - ship.TargetPosition = desiredPosition; - if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) - { - subTask.TargetSystemId = wreck.SystemId; - subTask.TargetPosition = desiredPosition; - return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); - } + if (distance <= MathF.Max(subTask.Threshold, world.Balance.ArrivalThreshold)) + { + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } - ship.State = ShipState.Transferring; - var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); - if (remainingCapacity <= 0.01f) - { - return SubTaskOutcome.Completed; - } - - if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, world.Balance.MiningCycleSeconds * 0.8f))) - { - return SubTaskOutcome.Active; - } - - var salvageRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); - var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); - if (recovered > 0.01f) - { - AddInventory(ship.Inventory, wreck.ItemId, recovered); - wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); - } - - if (wreck.RemainingAmount <= 0.01f) - { - world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); - } - - subTask.ElapsedSeconds = 0f; - return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f - ? SubTaskOutcome.Completed - : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - var station = site is null ? null : ResolveSupportStation(world, ship, site); - if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) - { - subTask.BlockingReason = "construction-target-missing"; - return SubTaskOutcome.Failed; - } - - var supportPosition = ResolveSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - ship.TargetPosition = supportPosition; - ship.Position = supportPosition; - ship.State = ShipState.DeliveringConstruction; - var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Construction); - foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) - { - var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); - var remaining = MathF.Max(0f, required.Value - delivered); - if (remaining <= 0.01f) - { - continue; - } - - var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); - var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); - if (moved <= 0.01f) - { - continue; - } - - RemoveInventory(station.Inventory, required.Key, moved); - AddInventory(site.Inventory, required.Key, moved); - AddInventory(site.DeliveredItems, required.Key, moved); - break; - } - - subTask.Progress = site.RequiredItems.Count == 0 - ? 1f - : site.RequiredItems.Sum(required => - required.Value <= 0.01f - ? 1f - : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; - return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) - { - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - var station = site is null ? null : ResolveSupportStation(world, ship, site); - if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) - { - subTask.BlockingReason = "construction-site-missing"; - return SubTaskOutcome.Failed; - } - - var supportPosition = ResolveSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) - { - ship.State = ShipState.WaitingMaterials; - subTask.Status = WorkStatus.Blocked; - subTask.BlockingReason = "waiting-materials"; - return SubTaskOutcome.Active; - } - - subTask.Status = WorkStatus.Active; - subTask.BlockingReason = null; - ship.TargetPosition = supportPosition; - ship.Position = supportPosition; - ship.State = ShipState.Constructing; - site.AssignedConstructorShipIds.Add(ship.Id); - site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); - subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); - if (site.Progress < recipe.Duration) - { - return SubTaskOutcome.Active; - } - - if (site.StationId is null) - { - CompleteStationFoundation(world, station, site); - } - else - { - AddStationModule(world, station, site.BlueprintId); - PrepareNextConstructionSiteStep(world, station, site); - } - - site.State = ConstructionSiteStateKinds.Completed; - return SubTaskOutcome.Completed; - } - - private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) - { - subTask.TotalSeconds = requiredSeconds; - subTask.ElapsedSeconds += deltaSeconds; - subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); - if (subTask.ElapsedSeconds < requiredSeconds) - { - return false; - } - - subTask.ElapsedSeconds = 0f; - return true; - } - - private SubTaskOutcome UpdateLocalTravel( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - string targetSystemId, - Vector3 targetPosition, - CelestialRuntime? targetCelestial, - bool completeOnArrival) - { - var distance = ship.Position.DistanceTo(targetPosition); - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.Transit = null; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); - - if (distance <= MathF.Max(subTask.Threshold, world.Balance.ArrivalThreshold)) - { - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - ship.State = ShipState.LocalFlight; - ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return SubTaskOutcome.Active; - } - - private SubTaskOutcome UpdateWarpTransit( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - Vector3 targetPosition, - CelestialRuntime targetCelestial, - bool completeOnArrival) - { - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id) - { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKinds.Warp, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = targetCelestial.Id, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - subTask.ElapsedSeconds = 0f; - } - - ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = targetCelestial.Id; - - var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != ShipState.Warping) - { - ship.State = ShipState.SpoolingWarp; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) - { + ship.State = ShipState.LocalFlight; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; - } - - ship.State = ShipState.Warping; } - var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null - ? ship.Position.DistanceTo(targetPosition) - : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); - ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); - transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); - subTask.Progress = transit.Progress; - if (ship.Position.DistanceTo(targetPosition) > 18f) + private SubTaskOutcome UpdateWarpTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + Vector3 targetPosition, + CelestialRuntime targetCelestial, + bool completeOnArrival) { - return SubTaskOutcome.Active; + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKinds.Warp, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = targetCelestial.Id, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = targetCelestial.Id; + + var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + if (ship.State != ShipState.Warping) + { + ship.State = ShipState.SpoolingWarp; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Warping; + } + + var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null + ? ship.Position.DistanceTo(targetPosition) + : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); + ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); + transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + subTask.Progress = transit.Progress; + if (ship.Position.DistanceTo(targetPosition) > 18f) + { + return SubTaskOutcome.Active; + } + + return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); } - return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); - } - - private SubTaskOutcome UpdateFtlTransit( - SimulationWorld world, - ShipRuntime ship, - ShipSubTaskRuntime subTask, - float deltaSeconds, - string targetSystemId, - Vector3 entryPosition, - CelestialRuntime? targetCelestial, - bool completeOnArrival, - Vector3 finalTargetPosition) - { - var destinationNodeId = targetCelestial?.Id; - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId) + private SubTaskOutcome UpdateFtlTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 entryPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival, + Vector3 finalTargetPosition) { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKinds.FtlTransit, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = destinationNodeId, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - subTask.ElapsedSeconds = 0f; + var destinationNodeId = targetCelestial?.Id; + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKinds.FtlTransit, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = destinationNodeId, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = destinationNodeId; + + if (ship.State != ShipState.Ftl) + { + ship.State = ShipState.SpoolingFtl; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Ftl; + } + + var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); + var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); + var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); + transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); + subTask.Progress = transit.Progress; + if (transit.Progress < 0.999f) + { + return SubTaskOutcome.Active; + } + + ship.Position = entryPosition; + ship.TargetPosition = finalTargetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } - ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = destinationNodeId; - - if (ship.State != ShipState.Ftl) + private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) { - ship.State = ShipState.SpoolingFtl; - if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) - { - return SubTaskOutcome.Active; - } - - ship.State = ShipState.Ftl; + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } - var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); - var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); - var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); - transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); - subTask.Progress = transit.Progress; - if (transit.Progress < 0.999f) + private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) { - return SubTaskOutcome.Active; + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (ship is not null) + { + return ship.Position; + } + + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is not null) + { + return station.Position; + } + + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial.Position; + } + + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (wreck is not null) + { + return wreck.Position; + } + } + + return subTask.TargetPosition ?? Vector3.Zero; } - ship.Position = entryPosition; - ship.TargetPosition = finalTargetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) - { - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentSystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; - } - - private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) - { - if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) { - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (ship is not null) - { - return ship.Position; - } + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); + } - var station = ResolveStation(world, subTask.TargetEntityId); - if (station is not null) - { - return station.Position; - } + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (site?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + } - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (celestial is not null) - { - return celestial.Position; - } + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial; + } - var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (wreck is not null) - { - return wreck.Position; - } - } + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) + { + return world.Celestials + .Where(candidate => candidate.SystemId == wreck.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) + .FirstOrDefault(); + } + } - return subTask.TargetPosition ?? Vector3.Zero; - } - - private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) - { - if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) - { - var station = ResolveStation(world, subTask.TargetEntityId); - if (station?.CelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); - } - - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (site?.CelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - } - - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); - if (celestial is not null) - { - return celestial; - } - - if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) - { return world.Celestials - .Where(candidate => candidate.SystemId == wreck.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) + .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) .FirstOrDefault(); - } } - return world.Celestials - .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) - { - if (ship.SpatialState.CurrentCelestialId is not null) + private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); - } - - return world.Celestials - .Where(candidate => candidate.SystemId == ship.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => - world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); - - private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => - world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; - - private static float GetLocalTravelSpeed(ShipRuntime ship) => - SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); - - private static float GetWarpTravelSpeed(ShipRuntime ship) => - SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); - - private static float GetSkillFactor(int skillLevel) => - Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); - - private static int GetEffectiveSkillLevel( - SimulationWorld world, - ShipRuntime ship, - Func captainSelector, - Func managerSelector) - { - var captainLevel = captainSelector(ship.Skills); - if (ship.CommanderId is null) - { - return captainLevel; - } - - var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); - var manager = shipCommander?.ParentCommanderId is null - ? shipCommander - : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; - return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); - } - - private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) - { - if (explicitRange > 0) - { - return explicitRange; - } - - var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); - var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); - var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); - return behaviorKind switch - { - "local-auto-mine" or "local-auto-trade" => 0, - "advanced-auto-mine" => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), - "advanced-auto-trade" => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), - "expert-auto-mine" => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), - "fill-shortages" or "find-build-tasks" or "revisit-known-stations" or "supply-fleet" => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), - "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), - _ => Math.Max(world.Systems.Count - 1, 0), - }; - } - - private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) - { - if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) - { - return 0; - } - - var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); - return world.Systems - .OrderBy(system => system.Position.DistanceTo(originPosition)) - .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) - .Select(system => system.Definition.Id) - .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) - .Count(); - } - - private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => - maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; - - private static float GetShipDamagePerSecond(ShipRuntime ship) => - ship.Definition.Class switch - { - "frigate" => FrigateDps, - "destroyer" => DestroyerDps, - "cruiser" => CruiserDps, - "capital" => CapitalDps, - _ => 4f, - }; - - private static MiningOpportunity? SelectMiningOpportunity( - SimulationWorld world, - ShipRuntime ship, - StationRuntime homeStation, - CommanderAssignmentRuntime? assignment, - string behaviorKind) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.PreferredItemId; - var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); - var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); - string? deniedReason = null; - var opportunity = world.Nodes - .Where(node => - { - if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) + if (ship.SpatialState.CurrentCelestialId is not null) { - return false; + return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); } - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) - { - deniedReason ??= reason; - return false; - } - - return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); - }) - .Select(node => - { - var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); - var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); - var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; - var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; - var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) - + (node.OreRemaining * 0.025f) - + (demandScore * (string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal) ? 22f : 12f)) - + (effectiveMiningSkill * 10f) - - distancePenalty - - routeRiskPenalty - - node.Position.DistanceTo(ship.Position); - return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); - }) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (opportunity is null && deniedReason is not null) - { - ship.LastAccessFailureReason = deniedReason; + return world.Celestials + .Where(candidate => candidate.SystemId == ship.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); } - return opportunity; - } + private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => + world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); - private static TradeRoutePlan? SelectTradeRoute( - SimulationWorld world, - ShipRuntime ship, - StationRuntime? homeStation, - string behaviorKind, - bool knownStationsOnly) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - var stationsById = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .ToDictionary(station => station.Id, StringComparer.Ordinal); - var originSystemId = homeStation?.SystemId ?? ship.SystemId; - var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); - var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); - var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal); - string? deniedReason = null; + private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => + world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; - var route = world.MarketOrders - .Where(order => - order.FactionId == ship.FactionId && - order.Kind == MarketOrderKinds.Buy && - order.RemainingAmount > 0.01f) - .Select(order => - { - StationRuntime? destination = null; - ConstructionSiteRuntime? destinationSite = null; - if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) + private static float GetLocalTravelSpeed(ShipRuntime ship) => + SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetWarpTravelSpeed(ShipRuntime ship) => + SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetSkillFactor(int skillLevel) => + Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); + + private static int GetEffectiveSkillLevel( + SimulationWorld world, + ShipRuntime ship, + Func captainSelector, + Func managerSelector) + { + var captainLevel = captainSelector(ship.Skills); + if (ship.CommanderId is null) { - destination = destinationStation; + return captainLevel; } - else if (order.ConstructionSiteId is not null) + + var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); + var manager = shipCommander?.ParentCommanderId is null + ? shipCommander + : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; + return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); + } + + private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) + { + if (explicitRange > 0) { - destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); - if (destinationSite is not null) + return explicitRange; + } + + var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); + return behaviorKind switch + { + "local-auto-mine" or "local-auto-trade" => 0, + "advanced-auto-mine" => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), + "advanced-auto-trade" => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), + "expert-auto-mine" => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), + "fill-shortages" or "find-build-tasks" or "revisit-known-stations" or "supply-fleet" => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + _ => Math.Max(world.Systems.Count - 1, 0), + }; + } + + private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) + { + if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) + { + return 0; + } + + var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); + return world.Systems + .OrderBy(system => system.Position.DistanceTo(originPosition)) + .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) + .Select(system => system.Definition.Id) + .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) + .Count(); + } + + private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => + maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; + + private static float GetShipDamagePerSecond(ShipRuntime ship) => + ship.Definition.Class switch + { + "frigate" => FrigateDps, + "destroyer" => DestroyerDps, + "cruiser" => CruiserDps, + "capital" => CapitalDps, + _ => 4f, + }; + + private static MiningOpportunity? SelectMiningOpportunity( + SimulationWorld world, + ShipRuntime ship, + StationRuntime homeStation, + CommanderAssignmentRuntime? assignment, + string behaviorKind) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.PreferredItemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + string? deniedReason = null; + var opportunity = world.Nodes + .Where(node => { - destination = ResolveSupportStation(world, ship, destinationSite); - } - } + if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) + { + return false; + } - if (destination is null) - { - return null; - } + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) + { + deniedReason ??= reason; + return false; + } - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) - { - deniedReason ??= destinationDeniedReason; - return null; - } - if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) - { - return null; - } - if (requireKnownStations - && ship.KnownStationIds.Count > 0 - && !ship.KnownStationIds.Contains(destination.Id) - && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) - { - return null; - } - if (string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is null) - { - return null; - } - if (!string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is not null) - { - return null; - } - - var source = stationsById.Values - .Where(station => - { - if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) - { - return false; - } - - if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) - { - deniedReason ??= sourceDeniedReason; - return false; - } - - if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) - { - return false; - } - - return !requireKnownStations - || ship.KnownStationIds.Count == 0 - || ship.KnownStationIds.Contains(station.Id) - || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); + return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); }) - .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) - .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .Select(node => + { + var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); + var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); + var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; + var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; + var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) + + (node.OreRemaining * 0.025f) + + (demandScore * (string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal) ? 22f : 12f)) + + (effectiveMiningSkill * 10f) + - distancePenalty + - routeRiskPenalty + - node.Position.DistanceTo(ship.Position); + return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (opportunity is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return opportunity; + } + + private static TradeRoutePlan? SelectTradeRoute( + SimulationWorld world, + ShipRuntime ship, + StationRuntime? homeStation, + string behaviorKind, + bool knownStationsOnly) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var stationsById = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .ToDictionary(station => station.Id, StringComparer.Ordinal); + var originSystemId = homeStation?.SystemId ?? ship.SystemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal); + string? deniedReason = null; + + var route = world.MarketOrders + .Where(order => + order.FactionId == ship.FactionId && + order.Kind == MarketOrderKinds.Buy && + order.RemainingAmount > 0.01f) + .Select(order => + { + StationRuntime? destination = null; + ConstructionSiteRuntime? destinationSite = null; + if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) + { + destination = destinationStation; + } + else if (order.ConstructionSiteId is not null) + { + destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); + if (destinationSite is not null) + { + destination = ResolveSupportStation(world, ship, destinationSite); + } + } + + if (destination is null) + { + return null; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) + { + deniedReason ??= destinationDeniedReason; + return null; + } + if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) + { + return null; + } + if (requireKnownStations + && ship.KnownStationIds.Count > 0 + && !ship.KnownStationIds.Contains(destination.Id) + && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) + { + return null; + } + if (string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is null) + { + return null; + } + if (!string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is not null) + { + return null; + } + + var source = stationsById.Values + .Where(station => + { + if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) + { + deniedReason ??= sourceDeniedReason; + return false; + } + + if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) + { + return false; + } + + return !requireKnownStations + || ship.KnownStationIds.Count == 0 + || ship.KnownStationIds.Contains(station.Id) + || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); + }) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) + .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (source is null) + { + return null; + } + + var shortageBias = string.Equals(behaviorKind, "fill-shortages", StringComparison.Ordinal) + ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f + : 0f; + var buildBias = destinationSite is null ? 0f : 65f; + var revisitBias = string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) + ? 28f + : 0f; + var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; + var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; + var riskPenalty = + (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) + + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; + var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); + var score = (order.Valuation * 50f) + + shortageBias + + buildBias + + revisitBias + + regionalNeedBias + + (effectiveTradeSkill * 12f) + - systemRangePenalty + - riskPenalty + - distanceScore; + var summary = destinationSite is null + ? $"{order.ItemId}: {source.Label} -> {destination.Label}" + : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; + return new TradeRoutePlan(source, destination, order.ItemId, score, summary); + }) + .Where(route => route is not null) + .Cast() + .OrderByDescending(route => route.Score) + .ThenBy(route => route.ItemId, StringComparer.Ordinal) + .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (route is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return route; + } + + private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + var assignment = ResolveAssignment(world, ship); + var targetCandidates = world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.FactionId == ship.FactionId && + candidate.Definition.CargoCapacity > 0.01f && + (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) + .OrderByDescending(candidate => candidate.Definition.Kind == "military" ? 1 : 0) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + if (targetCandidates.Count == 0) + { + return null; + } + + var sourceStations = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (source is null) + .ToList(); + foreach (var target in targetCandidates) { - return null; + var itemId = assignment?.ItemId + ?? sourceStations + .SelectMany(station => station.Inventory) + .Where(entry => entry.Value > 2f) + .OrderByDescending(entry => entry.Value) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => entry.Key) + .FirstOrDefault(); + if (itemId is null) + { + continue; + } + + var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); + if (source is null) + { + continue; + } + + var amount = MathF.Min(MathF.Max(10f, ship.Definition.CargoCapacity * 0.5f), GetInventoryAmount(source.Inventory, itemId)); + return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Label} with {itemId}"); } - var shortageBias = string.Equals(behaviorKind, "fill-shortages", StringComparison.Ordinal) - ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f - : 0f; - var buildBias = destinationSite is null ? 0f : 65f; - var revisitBias = string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) - ? 28f - : 0f; - var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; - var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; - var riskPenalty = - (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) - + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; - var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); - var score = (order.Valuation * 50f) - + shortageBias - + buildBias - + revisitBias - + regionalNeedBias - + (effectiveTradeSkill * 12f) - - systemRangePenalty - - riskPenalty - - distanceScore; - var summary = destinationSite is null - ? $"{order.ItemId}: {source.Label} -> {destination.Label}" - : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; - return new TradeRoutePlan(source, destination, order.ItemId, score, summary); - }) - .Where(route => route is not null) - .Cast() - .OrderByDescending(route => route.Score) - .ThenBy(route => route.ItemId, StringComparer.Ordinal) - .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) - .FirstOrDefault(); - if (route is null && deniedReason is not null) - { - ship.LastAccessFailureReason = deniedReason; + return null; } - return route; - } - - private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - var assignment = ResolveAssignment(world, ship); - var targetCandidates = world.Ships - .Where(candidate => - candidate.Id != ship.Id && - candidate.FactionId == ship.FactionId && - candidate.Definition.CargoCapacity > 0.01f && - (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) - .OrderByDescending(candidate => candidate.Definition.Kind == "military" ? 1 : 0) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .ToList(); - if (targetCandidates.Count == 0) + private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) { - return null; - } - - var sourceStations = world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .ToList(); - foreach (var target in targetCandidates) - { - var itemId = assignment?.ItemId - ?? sourceStations - .SelectMany(station => station.Inventory) - .Where(entry => entry.Value > 2f) - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => entry.Key) + var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null + ? [homeStation.Id] + : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + return candidateIds + .Select(id => ResolveStation(world, id)) + .Where(station => station is not null && station.FactionId == ship.FactionId) + .Cast() + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) .FirstOrDefault(); - if (itemId is null) - { - continue; - } - - var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); - if (source is null) - { - continue; - } - - var amount = MathF.Min(MathF.Max(10f, ship.Definition.CargoCapacity * 0.5f), GetInventoryAmount(source.Inventory, itemId)); - return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Label} with {itemId}"); } - return null; - } - - private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null - ? [homeStation.Id] - : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); - return candidateIds - .Select(id => ResolveStation(world, id)) - .Where(station => station is not null && station.FactionId == ship.FactionId) - .Cast() - .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) - .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) - .ThenBy(station => station.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) - { - if (!string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal)) + private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) { - return homeStation; + if (!string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal)) + { + return homeStation; + } + + return world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) + .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault() + ?? homeStation; } - return world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) - .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) - .ThenBy(station => station.Id, StringComparer.Ordinal) - .FirstOrDefault() - ?? homeStation; - } - - private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) - { - var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? - .CommoditySignals - .FirstOrDefault(candidate => candidate.ItemId == itemId); - var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) - .Join( - world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), - bottleneck => bottleneck.RegionId, - region => region.Id, - (bottleneck, _) => bottleneck.Severity) - .DefaultIfEmpty() - .Max() ?? 0f; - if (signal is null) + private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) { - return regionalBottleneckScore * 8f; + var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? + .CommoditySignals + .FirstOrDefault(candidate => candidate.ItemId == itemId); + var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) + .Join( + world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), + bottleneck => bottleneck.RegionId, + region => region.Id, + (bottleneck, _) => bottleneck.Severity) + .DefaultIfEmpty() + .Max() ?? 0f; + if (signal is null) + { + return regionalBottleneckScore * 8f; + } + + return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); } - return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); - } - - private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) - { - var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); - if (region is null) + private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) { - return 0f; + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); + if (region is null) + { + return 0f; + } + + var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) + && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); + var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); + return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); } - var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks - .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) - && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); - var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments - .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); - return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); - } - - private static ThreatTargetCandidate? SelectThreatTarget( - SimulationWorld world, - ShipRuntime ship, - string targetSystemId, - Vector3 anchorPosition, - float radius, - string? excludeEntityId = null) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - return world.Ships - .Where(candidate => - candidate.Id != excludeEntityId && - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) - .Select(candidate => new ThreatTargetCandidate( - candidate.Id, - candidate.SystemId, - candidate.Position, - 100f - + (candidate.Definition.Kind == "military" ? 30f : 0f) - - candidate.Position.DistanceTo(anchorPosition) - - candidate.Position.DistanceTo(ship.Position) - + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) - .Concat(world.Stations - .Where(candidate => - candidate.Id != excludeEntityId && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) - .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) - { - var policy = ResolvePolicy(world, ship.PolicySetId); - return world.Ships - .Where(candidate => - candidate.Id != ship.Id && - candidate.Health > 0f && - candidate.FactionId != ship.FactionId && - string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && - candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) - .Select(candidate => - { - var engage = candidate.Definition.Kind == "military" - || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); - var score = (engage ? 80f : 40f) - - candidate.Position.DistanceTo(anchorPosition) - - candidate.Position.DistanceTo(ship.Position) - + (candidate.Definition.Kind == "transport" ? 8f : 0f); - return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); - }) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) - { - if (homeStation is null) + private static ThreatTargetCandidate? SelectThreatTarget( + SimulationWorld world, + ShipRuntime ship, + string targetSystemId, + Vector3 anchorPosition, + float radius, + string? excludeEntityId = null) { - return null; + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) + .Select(candidate => new ThreatTargetCandidate( + candidate.Id, + candidate.SystemId, + candidate.Position, + 100f + + (candidate.Definition.Kind == "military" ? 30f : 0f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) + .Concat(world.Stations + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) + .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); } - var rangeBudget = ResolveBehaviorSystemRange(world, ship, "auto-salvage", ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); - return world.Wrecks - .Where(wreck => - wreck.RemainingAmount > 0.01f && - IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) - .Select(wreck => new SalvageOpportunity( - wreck, - (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), - $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) - .OrderByDescending(candidate => candidate.Score) - .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) - .FirstOrDefault(); - } - - private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) - { - if (entityId is null) + private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) { - return null; + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) + .Select(candidate => + { + var engage = candidate.Definition.Kind == "military" + || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); + var score = (engage ? 80f : 40f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (candidate.Definition.Kind == "transport" ? 8f : 0f); + return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); } - if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) + private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) { - return (ship.SystemId, ship.Position); + if (homeStation is null) + { + return null; + } + + var rangeBudget = ResolveBehaviorSystemRange(world, ship, "auto-salvage", ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); + return world.Wrecks + .Where(wreck => + wreck.RemainingAmount > 0.01f && + IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) + .Select(wreck => new SalvageOpportunity( + wreck, + (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), + $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) + .FirstOrDefault(); } - if (ResolveStation(world, entityId) is { } station) + private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) { - return (station.SystemId, station.Position); + if (entityId is null) + { + return null; + } + + if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) + { + return (ship.SystemId, ship.Position); + } + + if (ResolveStation(world, entityId) is { } station) + { + return (station.SystemId, station.Position); + } + + if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) + { + return (celestial.SystemId, celestial.Position); + } + + if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) + { + var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; + return (site.SystemId, position); + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) + { + return (wreck.SystemId, wreck.Position); + } + + return null; } - if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) + private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) { - return (celestial.SystemId, celestial.Position); + var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); + var angle = (hash % 360) * (MathF.PI / 180f); + return new Vector3( + anchorPosition.X + (MathF.Cos(angle) * radius), + anchorPosition.Y, + anchorPosition.Z + (MathF.Sin(angle) * radius)); } - if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) + private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) { - var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; - return (site.SystemId, position); + var source = ResolveStation(world, sourceStationId); + var destination = ResolveStation(world, destinationStationId); + return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); } - if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) + private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => + stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); + + private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => + nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); + + private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => + policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); + + private static bool IsSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind) => + TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); + + private static bool TryCheckSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind, + out string? denialReason) { - return (wreck.SystemId, wreck.Position); + denialReason = null; + if (policy?.BlacklistedSystemIds.Contains(systemId) == true) + { + denialReason = $"blacklisted:{systemId}"; + return false; + } + + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); + var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; + if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + { + return true; + } + + var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) + ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) + : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); + if (!hasAccess) + { + denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; + return false; + } + + if (policy?.AvoidHostileSystems != true) + { + return true; + } + + if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) + { + denialReason = $"hostile-authority:{authorityFactionId}"; + return false; + } + + var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => + !string.Equals(candidate, factionId, StringComparison.Ordinal) + && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); + if (hostileInfluencer is not null) + { + denialReason = $"hostile-influence:{hostileInfluencer}"; + return false; + } + + return true; } - return null; - } + private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => + ship.CommanderId is null + ? null + : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; - private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) - { - var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); - var angle = (hash % 360) * (MathF.PI / 180f); - return new Vector3( - anchorPosition.X + (MathF.Cos(angle) * radius), - anchorPosition.Y, - anchorPosition.Z + (MathF.Sin(angle) * radius)); - } - - private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) - { - var source = ResolveStation(world, sourceStationId); - var destination = ResolveStation(world, destinationStationId); - return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); - } - - private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => - stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); - - private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => - nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); - - private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => - policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); - - private static bool IsSystemAllowed( - SimulationWorld world, - PolicySetRuntime? policy, - string factionId, - string systemId, - string accessKind) => - TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); - - private static bool TryCheckSystemAllowed( - SimulationWorld world, - PolicySetRuntime? policy, - string factionId, - string systemId, - string accessKind, - out string? denialReason) - { - denialReason = null; - if (policy?.BlacklistedSystemIds.Contains(systemId) == true) - { - denialReason = $"blacklisted:{systemId}"; - return false; - } - - var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); - var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; - if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) - { - return true; - } - - var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) - ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) - : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); - if (!hasAccess) - { - denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; - return false; - } - - if (policy?.AvoidHostileSystems != true) - { - return true; - } - - if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) - { - denialReason = $"hostile-authority:{authorityFactionId}"; - return false; - } - - var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => - !string.Equals(candidate, factionId, StringComparison.Ordinal) - && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); - if (hostileInfluencer is not null) - { - denialReason = $"hostile-influence:{hostileInfluencer}"; - return false; - } - - return true; - } - - private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => - ship.CommanderId is null - ? null - : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; - - private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => - ship.OrderQueue - .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .FirstOrDefault(); - - private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => - plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; - - private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) - { - return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) - ?? world.Stations - .Where(station => station.FactionId == ship.FactionId) - .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) - .ThenBy(station => station.Id, StringComparer.Ordinal) + private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => + ship.OrderQueue + .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) .FirstOrDefault(); - } - private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) - { - if (ship.DockedStationId is not null) + private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => + plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; + + private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) { - return GetShipDockedPosition(ship, station); + return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) + ?? world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); } - if (site?.StationId is null && site is not null) + private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) { - var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; - return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + if (ship.DockedStationId is not null) + { + return GetShipDockedPosition(ship, station); + } + + if (site?.StationId is null && site is not null) + { + var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; + return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + } + + return GetConstructionHoldPosition(station, ship.Id); } - return GetConstructionHoldPosition(station, ship.Id); - } + private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => + ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); - private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => - ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); - - private static void TrackHistory(ShipRuntime ship) - { - var plan = ship.ActivePlan; - var step = GetCurrentStep(plan); - var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; - var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; - if (ship.LastSignature == signature) + private static void TrackHistory(ShipRuntime ship) { - return; + var plan = ship.ActivePlan; + var step = GetCurrentStep(plan); + var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; + var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; + if (ship.LastSignature == signature) + { + return; + } + + ship.LastSignature = signature; + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); + if (ship.History.Count > 24) + { + ship.History.RemoveAt(0); + } } - ship.LastSignature = signature; - ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); - if (ship.History.Count > 24) + private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) { - ship.History.RemoveAt(0); - } - } + var currentPlanId = ship.ActivePlan?.Id; + var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; + var occurredAtUtc = DateTimeOffset.UtcNow; + if (previousState != ship.State) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); + } - private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) - { - var currentPlanId = ship.ActivePlan?.Id; - var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; - var occurredAtUtc = DateTimeOffset.UtcNow; - if (previousState != ship.State) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); + if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Label} switched active plan.", occurredAtUtc)); + } + + if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Label} advanced plan step.", occurredAtUtc)); + } } - if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) + private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) { - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Label} switched active plan.", occurredAtUtc)); + var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + if (anchor is null || site.BlueprintId is null) + { + site.State = ConstructionSiteStateKinds.Destroyed; + return; + } + + var station = new StationRuntime + { + Id = $"station-{world.Stations.Count + 1}", + SystemId = site.SystemId, + Label = BuildFoundedStationLabel(site.TargetDefinitionId), + Category = "station", + Objective = DetermineFoundationObjective(site.TargetDefinitionId), + Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, + Position = anchor.Position, + FactionId = site.FactionId, + CelestialId = site.CelestialId, + Health = 600f, + MaxHealth = 600f, + }; + + foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) + { + AddStationModule(world, station, moduleId); + } + + world.Stations.Add(station); + StationLifecycleService.EnsureStationCommander(world, station); + anchor.OccupyingStructureId = station.Id; + site.StationId = station.Id; + PrepareNextConstructionSiteStep(world, station, site); } - if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) + private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) { - events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Label} advanced plan step.", occurredAtUtc)); - } - } + var modules = new List { "module_arg_dock_m_01_lowtech" }; + foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) + { + if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + var storageModule = GetStorageRequirement(itemDefinition.CargoKind); + 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"); + } + } + } - private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) - { - var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - if (anchor is null || site.BlueprintId is null) - { - site.State = ConstructionSiteStateKinds.Destroyed; - return; + if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + + if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + modules.Add("module_gen_prod_energycells_01"); + } + + modules.Add(primaryModuleId); + return modules.Distinct(StringComparer.Ordinal).ToList(); } - var station = new StationRuntime - { - Id = $"station-{world.Stations.Count + 1}", - SystemId = site.SystemId, - Label = BuildFoundedStationLabel(site.TargetDefinitionId), - Category = "station", - Objective = DetermineFoundationObjective(site.TargetDefinitionId), - Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, - Position = anchor.Position, - FactionId = site.FactionId, - CelestialId = site.CelestialId, - Health = 600f, - MaxHealth = 600f, - }; - - foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) - { - AddStationModule(world, station, moduleId); - } - - world.Stations.Add(station); - StationLifecycleService.EnsureStationCommander(world, station); - anchor.OccupyingStructureId = station.Id; - site.StationId = station.Id; - PrepareNextConstructionSiteStep(world, station, site); - } - - private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) - { - var modules = new List { "module_arg_dock_m_01_lowtech" }; - foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) - { - if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + private static string DetermineFoundationObjective(string commodityId) => + commodityId switch { - var storageModule = GetStorageRequirement(itemDefinition.CargoKind); - 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"); - } - } + "energycells" => "power", + "water" => "water", + "refinedmetals" => "refinery", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + "shipyard" => "shipyard", + _ => "general", + }; + + private static string BuildFoundedStationLabel(string commodityId) => + $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; + + private enum SubTaskOutcome + { + Active, + Completed, + Failed, } - if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) - { - modules.Add("module_arg_stor_container_m_01"); - } + private sealed record TradeRoutePlan( + StationRuntime SourceStation, + StationRuntime DestinationStation, + string ItemId, + float Score, + string Summary); - if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) - { - modules.Add("module_gen_prod_energycells_01"); - } + private sealed record MiningOpportunity( + ResourceNodeRuntime Node, + StationRuntime DropOffStation, + float Score, + string Summary); - modules.Add(primaryModuleId); - return modules.Distinct(StringComparer.Ordinal).ToList(); - } + private sealed record FleetSupplyPlan( + StationRuntime SourceStation, + ShipRuntime TargetShip, + string ItemId, + float Amount, + float Radius, + string Summary); - private static string DetermineFoundationObjective(string commodityId) => - commodityId switch - { - "energycells" => "power", - "water" => "water", - "refinedmetals" => "refinery", - "hullparts" => "hullparts", - "claytronics" => "claytronics", - "shipyard" => "shipyard", - _ => "general", - }; + private sealed record ThreatTargetCandidate( + string EntityId, + string SystemId, + Vector3 Position, + float Score); - private static string BuildFoundedStationLabel(string commodityId) => - $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; + private sealed record PoliceContactCandidate( + string EntityId, + string SystemId, + Vector3 Position, + bool Engage, + float Score); - private enum SubTaskOutcome - { - Active, - Completed, - Failed, - } - - private sealed record TradeRoutePlan( - StationRuntime SourceStation, - StationRuntime DestinationStation, - string ItemId, - float Score, - string Summary); - - private sealed record MiningOpportunity( - ResourceNodeRuntime Node, - StationRuntime DropOffStation, - float Score, - string Summary); - - private sealed record FleetSupplyPlan( - StationRuntime SourceStation, - ShipRuntime TargetShip, - string ItemId, - float Amount, - float Radius, - string Summary); - - private sealed record ThreatTargetCandidate( - string EntityId, - string SystemId, - Vector3 Position, - float Score); - - private sealed record PoliceContactCandidate( - string EntityId, - string SystemId, - Vector3 Position, - bool Engage, - float Score); - - private sealed record SalvageOpportunity( - WreckRuntime Wreck, - float Score, - string Summary); + private sealed record SalvageOpportunity( + WreckRuntime Wreck, + float Score, + string Summary); } diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index 88f142a..85ad6be 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -3,146 +3,146 @@ namespace SpaceGame.Api.Simulation.Core; public sealed class SimulationEngine { - private readonly OrbitalSimulationOptions _orbitalSimulation; - private readonly OrbitalStateUpdater _orbitalStateUpdater; - private readonly InfrastructureSimulationService _infrastructureSimulation; - private readonly GeopoliticalSimulationService _geopolitics; - private readonly CommanderPlanningService _commanderPlanning; - private readonly PlayerFactionService _playerFaction; - private readonly StationSimulationService _stationSimulation; - private readonly StationLifecycleService _stationLifecycle; - private readonly ShipAiService _shipAi; - private readonly SimulationProjectionService _projection; + private readonly OrbitalSimulationOptions _orbitalSimulation; + private readonly OrbitalStateUpdater _orbitalStateUpdater; + private readonly InfrastructureSimulationService _infrastructureSimulation; + private readonly GeopoliticalSimulationService _geopolitics; + private readonly CommanderPlanningService _commanderPlanning; + private readonly PlayerFactionService _playerFaction; + private readonly StationSimulationService _stationSimulation; + private readonly StationLifecycleService _stationLifecycle; + private readonly ShipAiService _shipAi; + private readonly SimulationProjectionService _projection; - public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null) - { - _orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions(); - _orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation); - _infrastructureSimulation = new InfrastructureSimulationService(); - _geopolitics = new GeopoliticalSimulationService(); - _commanderPlanning = new CommanderPlanningService(); - _playerFaction = new PlayerFactionService(); - _stationSimulation = new StationSimulationService(); - _stationLifecycle = new StationLifecycleService(_stationSimulation); - _shipAi = new ShipAiService(); - _projection = new SimulationProjectionService(_orbitalSimulation); - } - - public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) - { - var nowUtc = DateTimeOffset.UtcNow; - var events = new List(); - var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f); - world.GeneratedAtUtc = nowUtc; - - world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; - - _orbitalStateUpdater.Update(world); - _infrastructureSimulation.UpdateClaims(world, events); - _infrastructureSimulation.UpdateConstructionSites(world, events); - _geopolitics.Update(world, simulationDeltaSeconds, events); - _commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events); - _playerFaction.Update(world, simulationDeltaSeconds, events); - _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events); - - foreach (var ship in world.Ships.ToList()) + public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null) { - if (ship.Health <= 0f) - { - continue; - } - - var previousPosition = ship.Position; - _shipAi.UpdateShip(world, ship, simulationDeltaSeconds, events); - ship.Velocity = ship.Position.Subtract(previousPosition).Divide(simulationDeltaSeconds); + _orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions(); + _orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation); + _infrastructureSimulation = new InfrastructureSimulationService(); + _geopolitics = new GeopoliticalSimulationService(); + _commanderPlanning = new CommanderPlanningService(); + _playerFaction = new PlayerFactionService(); + _stationSimulation = new StationSimulationService(); + _stationLifecycle = new StationLifecycleService(_stationSimulation); + _shipAi = new ShipAiService(); + _projection = new SimulationProjectionService(_orbitalSimulation); } - _orbitalStateUpdater.SyncSpatialState(world); - CleanupDestroyedEntities(world, events); - return _projection.BuildDelta(world, sequence, events); - } - - public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) => - _projection.BuildSnapshot(world, sequence); - - public void PrimeDeltaBaseline(SimulationWorld world) => - _projection.PrimeDeltaBaseline(world); - - internal static float GetShipCargoAmount(ShipRuntime ship) => - SimulationRuntimeSupport.GetShipCargoAmount(ship); - - private static void CleanupDestroyedEntities(SimulationWorld world, ICollection events) - { - foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList()) + public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { - CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f)); - world.Ships.Remove(ship); - if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation) - { - dockedStation.DockedShipIds.Remove(ship.Id); - dockedStation.DockingPadAssignments.Remove(ship.AssignedDockingPadIndex ?? -1); - } + var nowUtc = DateTimeOffset.UtcNow; + var events = new List(); + var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f); + world.GeneratedAtUtc = nowUtc; - if (world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId) is { } faction) - { - faction.ShipsLost += 1; - } + world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; - if (ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId) is { } commander) - { - commander.IsAlive = false; - } + _orbitalStateUpdater.Update(world); + _infrastructureSimulation.UpdateClaims(world, events); + _infrastructureSimulation.UpdateConstructionSites(world, events); + _geopolitics.Update(world, simulationDeltaSeconds, events); + _commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events); + _playerFaction.Update(world, simulationDeltaSeconds, events); + _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events); - events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow)); + foreach (var ship in world.Ships.ToList()) + { + if (ship.Health <= 0f) + { + continue; + } + + var previousPosition = ship.Position; + _shipAi.UpdateShip(world, ship, simulationDeltaSeconds, events); + ship.Velocity = ship.Position.Subtract(previousPosition).Divide(simulationDeltaSeconds); + } + + _orbitalStateUpdater.SyncSpatialState(world); + CleanupDestroyedEntities(world, events); + return _projection.BuildDelta(world, sequence, events); } - foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList()) + public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) => + _projection.BuildSnapshot(world, sequence); + + public void PrimeDeltaBaseline(SimulationWorld world) => + _projection.PrimeDeltaBaseline(world); + + internal static float GetShipCargoAmount(ShipRuntime ship) => + SimulationRuntimeSupport.GetShipCargoAmount(ship); + + private static void CleanupDestroyedEntities(SimulationWorld world, ICollection events) { - CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f); - world.Stations.Remove(station); + foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList()) + { + CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f)); + world.Ships.Remove(ship); + if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation) + { + dockedStation.DockedShipIds.Remove(ship.Id); + dockedStation.DockingPadAssignments.Remove(ship.AssignedDockingPadIndex ?? -1); + } - if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial) - { - celestial.OccupyingStructureId = null; - } + if (world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId) is { } faction) + { + faction.ShipsLost += 1; + } - foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId)) - { - claim.Health = 0f; - claim.State = ClaimStateKinds.Destroyed; - } + if (ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId) is { } commander) + { + commander.IsAlive = false; + } - foreach (var site in world.ConstructionSites.Where(candidate => candidate.StationId == station.Id)) - { - site.State = ConstructionSiteStateKinds.Destroyed; - } + events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow)); + } - events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow)); - } - } + foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList()) + { + CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f); + world.Stations.Remove(station); - private static void CreateWreck(SimulationWorld world, string sourceKind, string sourceEntityId, string systemId, Vector3 position, float amount) - { - var itemId = world.ItemDefinitions.ContainsKey("scrapmetal") - ? "scrapmetal" - : world.ItemDefinitions.ContainsKey("rawscrap") - ? "rawscrap" - : world.ItemDefinitions.Keys.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); - if (itemId is null || amount <= 0.01f) - { - return; + if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial) + { + celestial.OccupyingStructureId = null; + } + + foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId)) + { + claim.Health = 0f; + claim.State = ClaimStateKinds.Destroyed; + } + + foreach (var site in world.ConstructionSites.Where(candidate => candidate.StationId == station.Id)) + { + site.State = ConstructionSiteStateKinds.Destroyed; + } + + events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow)); + } } - world.Wrecks.Add(new WreckRuntime + private static void CreateWreck(SimulationWorld world, string sourceKind, string sourceEntityId, string systemId, Vector3 position, float amount) { - Id = $"wreck-{sourceKind}-{sourceEntityId}", - SourceKind = sourceKind, - SourceEntityId = sourceEntityId, - SystemId = systemId, - Position = position, - ItemId = itemId, - RemainingAmount = amount, - MaxAmount = amount, - }); - } + var itemId = world.ItemDefinitions.ContainsKey("scrapmetal") + ? "scrapmetal" + : world.ItemDefinitions.ContainsKey("rawscrap") + ? "rawscrap" + : world.ItemDefinitions.Keys.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); + if (itemId is null || amount <= 0.01f) + { + return; + } + + world.Wrecks.Add(new WreckRuntime + { + Id = $"wreck-{sourceKind}-{sourceEntityId}", + SourceKind = sourceKind, + SourceEntityId = sourceEntityId, + SystemId = systemId, + Position = position, + ItemId = itemId, + RemainingAmount = amount, + MaxAmount = amount, + }); + } } diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index eb73c77..d40ceaf 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -1,215 +1,1139 @@ using System.Globalization; +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService; -using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Simulation.Core; internal sealed class SimulationProjectionService { - private readonly OrbitalSimulationOptions _orbitalSimulation; + private readonly OrbitalSimulationOptions _orbitalSimulation; - internal SimulationProjectionService(OrbitalSimulationOptions orbitalSimulation) - { - _orbitalSimulation = orbitalSimulation; - } + internal SimulationProjectionService(OrbitalSimulationOptions orbitalSimulation) + { + _orbitalSimulation = orbitalSimulation; + } - internal WorldDelta BuildDelta(SimulationWorld world, long sequence, IReadOnlyList events) => - new( - sequence, - world.TickIntervalMs, - world.OrbitalTimeSeconds, - new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), - world.GeneratedAtUtc, - false, - events, - BuildCelestialDeltas(world), - BuildNodeDeltas(world), - BuildStationDeltas(world), - BuildClaimDeltas(world), - BuildConstructionSiteDeltas(world), - BuildMarketOrderDeltas(world), - BuildPolicyDeltas(world), - BuildShipDeltas(world), - BuildFactionDeltas(world), - BuildPlayerFactionDelta(world), - BuildGeopoliticsDelta(world)); + internal WorldDelta BuildDelta(SimulationWorld world, long sequence, IReadOnlyList events) => + new( + sequence, + world.TickIntervalMs, + world.OrbitalTimeSeconds, + new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), + world.GeneratedAtUtc, + false, + events, + BuildCelestialDeltas(world), + BuildNodeDeltas(world), + BuildStationDeltas(world), + BuildClaimDeltas(world), + BuildConstructionSiteDeltas(world), + BuildMarketOrderDeltas(world), + BuildPolicyDeltas(world), + BuildShipDeltas(world), + BuildFactionDeltas(world), + BuildPlayerFactionDelta(world), + BuildGeopoliticsDelta(world)); - public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) - { - PrimeDeltaBaseline(world); + public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) + { + PrimeDeltaBaseline(world); - return new WorldSnapshot( - world.Label, - world.Seed, - sequence, - world.TickIntervalMs, - world.OrbitalTimeSeconds, - new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), - world.GeneratedAtUtc, - world.Systems.Select(system => new SystemSnapshot( - system.Definition.Id, - system.Definition.Label, - ToDto(system.Position), - system.Definition.Stars.Select(star => new StarSnapshot( - star.Kind, - star.Color, - star.Glow, - star.Size, - star.OrbitRadius, - star.OrbitSpeed, - star.OrbitPhaseAtEpoch)).ToList(), - system.Definition.Planets.Select(planet => new PlanetSnapshot( - planet.Label, - planet.PlanetType, - planet.Shape, - planet.Moons.Select(moon => new MoonSnapshot( - moon.Label, - moon.Size, - moon.Color, - moon.OrbitRadius, - moon.OrbitSpeed, - moon.OrbitPhaseAtEpoch, - moon.OrbitInclination, - moon.OrbitLongitudeOfAscendingNode)).ToList(), - planet.OrbitRadius, - planet.OrbitSpeed, - planet.OrbitEccentricity, - planet.OrbitInclination, - planet.OrbitLongitudeOfAscendingNode, - planet.OrbitArgumentOfPeriapsis, - planet.OrbitPhaseAtEpoch, - planet.Size, - planet.Color, - planet.HasRing)).ToList())).ToList(), - world.Celestials.Select(ToCelestialDelta).Select(c => new CelestialSnapshot( - c.Id, - c.SystemId, - c.Kind, - c.OrbitalAnchor, - c.LocalSpaceRadius, - c.ParentNodeId, - c.OccupyingStructureId, - c.OrbitReferenceId)).ToList(), - world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot( - node.Id, - node.SystemId, - node.LocalPosition, - node.CelestialId, - node.SourceKind, - node.OreRemaining, - node.MaxOre, - node.ItemId)).ToList(), - world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( - station.Id, - station.Label, - station.Category, - station.Objective, + return new WorldSnapshot( + world.Label, + world.Seed, + sequence, + world.TickIntervalMs, + world.OrbitalTimeSeconds, + new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), + world.GeneratedAtUtc, + world.Systems.Select(system => new SystemSnapshot( + system.Definition.Id, + system.Definition.Label, + ToDto(system.Position), + system.Definition.Stars.Select(star => new StarSnapshot( + star.Kind, + star.Color, + star.Glow, + star.Size, + star.OrbitRadius, + star.OrbitSpeed, + star.OrbitPhaseAtEpoch)).ToList(), + system.Definition.Planets.Select(planet => new PlanetSnapshot( + planet.Label, + planet.PlanetType, + planet.Shape, + planet.Moons.Select(moon => new MoonSnapshot( + moon.Label, + moon.Size, + moon.Color, + moon.OrbitRadius, + moon.OrbitSpeed, + moon.OrbitPhaseAtEpoch, + moon.OrbitInclination, + moon.OrbitLongitudeOfAscendingNode)).ToList(), + planet.OrbitRadius, + planet.OrbitSpeed, + planet.OrbitEccentricity, + planet.OrbitInclination, + planet.OrbitLongitudeOfAscendingNode, + planet.OrbitArgumentOfPeriapsis, + planet.OrbitPhaseAtEpoch, + planet.Size, + planet.Color, + planet.HasRing)).ToList())).ToList(), + world.Celestials.Select(ToCelestialDelta).Select(c => new CelestialSnapshot( + c.Id, + c.SystemId, + c.Kind, + c.OrbitalAnchor, + c.LocalSpaceRadius, + c.ParentNodeId, + c.OccupyingStructureId, + c.OrbitReferenceId)).ToList(), + world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot( + node.Id, + node.SystemId, + node.LocalPosition, + node.CelestialId, + node.SourceKind, + node.OreRemaining, + node.MaxOre, + node.ItemId)).ToList(), + world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( + station.Id, + station.Label, + station.Category, + station.Objective, + station.SystemId, + station.LocalPosition, + station.CelestialId, + station.Color, + station.DockedShips, + station.DockedShipIds, + station.DockingPads, + station.CurrentProcesses, + station.Inventory, + station.FactionId, + station.CommanderId, + station.PolicySetId, + station.Population, + station.PopulationCapacity, + station.WorkforceRequired, + station.WorkforceEffectiveRatio, + station.StorageUsage, + station.InstalledModules, + station.MarketOrderIds)).ToList(), + world.Claims.Select(ToClaimDelta).Select(claim => new ClaimSnapshot( + claim.Id, + claim.FactionId, + claim.SystemId, + claim.CelestialId, + claim.State, + claim.Health, + claim.PlacedAtUtc, + claim.ActivatesAtUtc)).ToList(), + world.ConstructionSites.Select(site => ToConstructionSiteDelta(world, site)).Select(site => new ConstructionSiteSnapshot( + site.Id, + site.FactionId, + site.SystemId, + site.CelestialId, + site.TargetKind, + site.TargetDefinitionId, + site.BlueprintId, + site.ClaimId, + site.StationId, + site.State, + site.Progress, + site.Inventory, + site.RequiredItems, + site.DeliveredItems, + site.AssignedConstructorShipIds, + site.MarketOrderIds)).ToList(), + world.MarketOrders.Select(ToMarketOrderDelta).Select(order => new MarketOrderSnapshot( + order.Id, + order.FactionId, + order.StationId, + order.ConstructionSiteId, + order.Kind, + order.ItemId, + order.Amount, + order.RemainingAmount, + order.Valuation, + order.ReserveThreshold, + order.PolicySetId, + order.State)).ToList(), + world.Policies.Select(ToPolicySetDelta).Select(policy => new PolicySetSnapshot( + policy.Id, + policy.OwnerKind, + policy.OwnerId, + policy.TradeAccessPolicy, + policy.DockingAccessPolicy, + policy.ConstructionAccessPolicy, + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot( + ship.Id, + ship.Label, + ship.Kind, + ship.Class, + ship.SystemId, + ship.LocalPosition, + ship.LocalVelocity, + ship.TargetLocalPosition, + ship.State, + ship.OrderQueue, + ship.DefaultBehavior, + ship.Assignment, + ship.Skills, + ship.ActivePlan, + ship.CurrentStepId, + ship.ActiveSubTasks, + ship.ControlSourceKind, + ship.ControlSourceId, + ship.ControlReason, + ship.LastReplanReason, + ship.LastAccessFailureReason, + ship.CelestialId, + ship.DockedStationId, + ship.CommanderId, + ship.PolicySetId, + ship.CargoCapacity, + ship.TravelSpeed, + ship.TravelSpeedUnit, + ship.Inventory, + ship.FactionId, + ship.Health, + ship.History, + ship.SpatialState)).ToList(), + world.Factions.Select(faction => ToFactionDelta(world, faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot( + faction.Id, + faction.Label, + faction.Color, + faction.Credits, + faction.PopulationTotal, + faction.OreMined, + faction.GoodsProduced, + faction.ShipsBuilt, + faction.ShipsLost, + faction.DefaultPolicySetId, + faction.Doctrine, + faction.Memory, + faction.StrategicState, + faction.DecisionLog, + faction.Commanders)).ToList(), + ToPlayerFactionSnapshot(world.PlayerFaction), + ToGeopoliticalStateSnapshot(world.Geopolitics)); + } + + public void PrimeDeltaBaseline(SimulationWorld world) + { + foreach (var node in world.Nodes) + { + node.LastDeltaSignature = BuildNodeSignature(node); + } + + foreach (var celestial in world.Celestials) + { + celestial.LastDeltaSignature = BuildCelestialSignature(celestial); + } + + foreach (var station in world.Stations) + { + station.LastDeltaSignature = BuildStationSignature(world, station); + } + + foreach (var claim in world.Claims) + { + claim.LastDeltaSignature = BuildClaimSignature(claim); + } + + foreach (var site in world.ConstructionSites) + { + site.LastDeltaSignature = BuildConstructionSiteSignature(site); + } + + foreach (var order in world.MarketOrders) + { + order.LastDeltaSignature = BuildMarketOrderSignature(order); + } + + foreach (var policy in world.Policies) + { + policy.LastDeltaSignature = BuildPolicySignature(policy); + } + + foreach (var ship in world.Ships) + { + ship.LastDeltaSignature = BuildShipSignature(world, ship); + } + + foreach (var faction in world.Factions) + { + faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id)); + } + + if (world.PlayerFaction is not null) + { + world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction); + } + + if (world.Geopolitics is not null) + { + world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics); + } + } + + private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var node in world.Nodes) + { + var signature = BuildNodeSignature(node); + if (signature == node.LastDeltaSignature) + { + continue; + } + + node.LastDeltaSignature = signature; + deltas.Add(ToNodeDelta(node)); + } + + return deltas; + } + + private static IReadOnlyList BuildCelestialDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var celestial in world.Celestials) + { + var signature = BuildCelestialSignature(celestial); + if (signature == celestial.LastDeltaSignature) + { + continue; + } + + celestial.LastDeltaSignature = signature; + deltas.Add(ToCelestialDelta(celestial)); + } + + return deltas; + } + + private static IReadOnlyList BuildStationDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var station in world.Stations) + { + var signature = BuildStationSignature(world, station); + if (signature == station.LastDeltaSignature) + { + continue; + } + + station.LastDeltaSignature = signature; + deltas.Add(ToStationDelta(world, station)); + } + + return deltas; + } + + private static IReadOnlyList BuildClaimDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var claim in world.Claims) + { + var signature = BuildClaimSignature(claim); + if (signature == claim.LastDeltaSignature) + { + continue; + } + + claim.LastDeltaSignature = signature; + deltas.Add(ToClaimDelta(claim)); + } + + return deltas; + } + + private static IReadOnlyList BuildConstructionSiteDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var site in world.ConstructionSites) + { + var signature = BuildConstructionSiteSignature(site); + if (signature == site.LastDeltaSignature) + { + continue; + } + + site.LastDeltaSignature = signature; + deltas.Add(ToConstructionSiteDelta(world, site)); + } + + return deltas; + } + + private static IReadOnlyList BuildMarketOrderDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var order in world.MarketOrders) + { + var signature = BuildMarketOrderSignature(order); + if (signature == order.LastDeltaSignature) + { + continue; + } + + order.LastDeltaSignature = signature; + deltas.Add(ToMarketOrderDelta(order)); + } + + return deltas; + } + + private static IReadOnlyList BuildPolicyDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var policy in world.Policies) + { + var signature = BuildPolicySignature(policy); + if (signature == policy.LastDeltaSignature) + { + continue; + } + + policy.LastDeltaSignature = signature; + deltas.Add(ToPolicySetDelta(policy)); + } + + return deltas; + } + + private IReadOnlyList BuildShipDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var ship in world.Ships) + { + var signature = BuildShipSignature(world, ship); + if (signature == ship.LastDeltaSignature) + { + continue; + } + + ship.LastDeltaSignature = signature; + deltas.Add(ToShipDelta(world, ship)); + } + + return deltas; + } + + private static IReadOnlyList BuildFactionDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var faction in world.Factions) + { + var commander = FindFactionCommander(world, faction.Id); + var signature = BuildFactionSignature(faction, commander); + if (signature == faction.LastDeltaSignature) + { + continue; + } + + faction.LastDeltaSignature = signature; + deltas.Add(ToFactionDelta(world, faction, commander)); + } + + return deltas; + } + + private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world) + { + if (world.PlayerFaction is null) + { + return null; + } + + var signature = BuildPlayerFactionSignature(world.PlayerFaction); + if (signature == world.PlayerFaction.LastDeltaSignature) + { + return null; + } + + world.PlayerFaction.LastDeltaSignature = signature; + return ToPlayerFactionSnapshot(world.PlayerFaction); + } + + private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world) + { + if (world.Geopolitics is null) + { + return null; + } + + var signature = BuildGeopoliticalSignature(world.Geopolitics); + if (signature == world.Geopolitics.LastDeltaSignature) + { + return null; + } + + world.Geopolitics.LastDeltaSignature = signature; + return ToGeopoliticalStateSnapshot(world.Geopolitics); + } + + private static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => + world.Commanders.FirstOrDefault(c => + c.FactionId == factionId && + string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); + + private static string BuildNodeSignature(ResourceNodeRuntime node) => + $"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}"; + + private static string BuildCelestialSignature(CelestialRuntime celestial) => + $"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}"; + + private static string BuildStationSignature(SimulationWorld world, StationRuntime station) + { + var processes = ToStationActionProgressSnapshots(world, station); + return string.Join("|", station.SystemId, - station.LocalPosition, - station.CelestialId, - station.Color, - station.DockedShips, - station.DockedShipIds, - station.DockingPads, - station.CurrentProcesses, - station.Inventory, - station.FactionId, - station.CommanderId, - station.PolicySetId, - station.Population, - station.PopulationCapacity, - station.WorkforceRequired, - station.WorkforceEffectiveRatio, - station.StorageUsage, - station.InstalledModules, - station.MarketOrderIds)).ToList(), - world.Claims.Select(ToClaimDelta).Select(claim => new ClaimSnapshot( - claim.Id, - claim.FactionId, - claim.SystemId, - claim.CelestialId, - claim.State, - claim.Health, - claim.PlacedAtUtc, - claim.ActivatesAtUtc)).ToList(), - world.ConstructionSites.Select(site => ToConstructionSiteDelta(world, site)).Select(site => new ConstructionSiteSnapshot( - site.Id, - site.FactionId, - site.SystemId, - site.CelestialId, - site.TargetKind, - site.TargetDefinitionId, - site.BlueprintId, - site.ClaimId, - site.StationId, - site.State, - site.Progress, - site.Inventory, - site.RequiredItems, - site.DeliveredItems, - site.AssignedConstructorShipIds, - site.MarketOrderIds)).ToList(), - world.MarketOrders.Select(ToMarketOrderDelta).Select(order => new MarketOrderSnapshot( - order.Id, - order.FactionId, - order.StationId, - order.ConstructionSiteId, - order.Kind, - order.ItemId, - order.Amount, - order.RemainingAmount, - order.Valuation, - order.ReserveThreshold, - order.PolicySetId, - order.State)).ToList(), - world.Policies.Select(ToPolicySetDelta).Select(policy => new PolicySetSnapshot( - policy.Id, - policy.OwnerKind, - policy.OwnerId, - policy.TradeAccessPolicy, - policy.DockingAccessPolicy, - policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy, - policy.CombatEngagementPolicy, - policy.AvoidHostileSystems, - policy.FleeHullRatio, - policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot( - ship.Id, - ship.Label, - ship.Kind, - ship.Class, + station.CelestialId ?? "none", + station.CommanderId ?? "none", + station.PolicySetId ?? "none", + BuildInventorySignature(station.Inventory), + string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")), + string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)), + station.DockingPadAssignments.Count.ToString(), + station.Population.ToString("0.###"), + station.PopulationCapacity.ToString("0.###"), + station.WorkforceRequired.ToString("0.###"), + station.WorkforceEffectiveRatio.ToString("0.###"), + string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)), + string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)), + station.ActiveConstruction?.ModuleId ?? "none", + station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0", + string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}"))); + } + + private static string BuildClaimSignature(ClaimRuntime claim) => + $"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; + + private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) => + $"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}"; + + private static string BuildMarketOrderSignature(MarketOrderRuntime order) => + $"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}"; + + private static string BuildPolicySignature(PolicySetRuntime policy) => + $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}|{policy.CombatEngagementPolicy}|{policy.AvoidHostileSystems}|{policy.FleeHullRatio:0.###}|{string.Join(",", policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal))}"; + + private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) => + string.Join("|", ship.SystemId, - ship.LocalPosition, - ship.LocalVelocity, - ship.TargetLocalPosition, - ship.State, - ship.OrderQueue, - ship.DefaultBehavior, - ship.Assignment, - ship.Skills, - ship.ActivePlan, - ship.CurrentStepId, - ship.ActiveSubTasks, + ship.Position.X.ToString("0.###"), + ship.Position.Y.ToString("0.###"), + ship.Position.Z.ToString("0.###"), + ship.Velocity.X.ToString("0.###"), + ship.Velocity.Y.ToString("0.###"), + ship.Velocity.Z.ToString("0.###"), + ship.TargetPosition.X.ToString("0.###"), + ship.TargetPosition.Y.ToString("0.###"), + ship.TargetPosition.Z.ToString("0.###"), + ship.State.ToContractValue(), + string.Join(",", ship.OrderQueue + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), + ship.DefaultBehavior.Kind, + ship.DefaultBehavior.TargetEntityId ?? "none", + ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none", + ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none", + ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none", + ship.DefaultBehavior.WaitSeconds.ToString("0.###"), + ship.DefaultBehavior.Radius.ToString("0.###"), + ship.DefaultBehavior.MaxSystemRange.ToString(CultureInfo.InvariantCulture), + ship.DefaultBehavior.KnownStationsOnly.ToString(), + string.Join(",", ship.DefaultBehavior.RepeatOrders.Select(order => + $"{order.Kind}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), + ship.DefaultBehavior.RepeatIndex.ToString(CultureInfo.InvariantCulture), + string.Join(",", ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal)), ship.ControlSourceKind, - ship.ControlSourceId, - ship.ControlReason, - ship.LastReplanReason, - ship.LastAccessFailureReason, - ship.CelestialId, - ship.DockedStationId, - ship.CommanderId, - ship.PolicySetId, - ship.CargoCapacity, - ship.TravelSpeed, - ship.TravelSpeedUnit, - ship.Inventory, - ship.FactionId, - ship.Health, - ship.History, - ship.SpatialState)).ToList(), - world.Factions.Select(faction => ToFactionDelta(world, faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot( + ship.ControlSourceId ?? "none", + ship.ControlReason ?? "none", + ship.LastReplanReason ?? "none", + ship.LastAccessFailureReason ?? "none", + ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment + ? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}" + : "no-assignment", + ship.ActivePlan?.Kind ?? "none", + ship.ActivePlan?.Status.ToContractValue() ?? "none", + ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1", + string.Join(",", + ToActiveSubTaskSnapshots(ship).Select(subTask => + $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), + ship.SpatialState.CurrentCelestialId ?? "none", + ship.DockedStationId ?? "none", + ship.CommanderId ?? "none", + ship.PolicySetId ?? "none", + ship.SpatialState.SpaceLayer, + ship.SpatialState.CurrentCelestialId ?? "none", + ship.SpatialState.MovementRegime, + ship.SpatialState.DestinationNodeId ?? "none", + ship.SpatialState.Transit?.Regime ?? "none", + ship.SpatialState.Transit?.OriginNodeId ?? "none", + ship.SpatialState.Transit?.DestinationNodeId ?? "none", + ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", + GetShipCargoAmount(ship).ToString("0.###"), + ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), + ship.Skills.Trade.ToString(CultureInfo.InvariantCulture), + ship.Skills.Mining.ToString(CultureInfo.InvariantCulture), + ship.Skills.Combat.ToString(CultureInfo.InvariantCulture), + ship.Skills.Construction.ToString(CultureInfo.InvariantCulture), + ship.Health.ToString("0.###"), + GetCurrentShipStep(ship)?.Id ?? "none"); + + private static string BuildInventorySignature(IReadOnlyDictionary inventory) => + string.Join(",", + inventory + .Where(entry => entry.Value > 0.001f) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => $"{entry.Key}:{entry.Value:0.###}")); + + private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander) + { + var assignmentSig = commander?.Assignment is null + ? string.Empty + : $"{commander.Assignment.ObjectiveId}:{commander.Assignment.Kind}:{commander.Assignment.BehaviorKind}:{commander.Assignment.Status}:{commander.Assignment.TargetSystemId}:{commander.Assignment.TargetEntityId}:{commander.Assignment.ItemId}"; + var state = faction.StrategicState; + var strategicSig = string.Join(";", + state.Status, + state.PlanCycle.ToString(CultureInfo.InvariantCulture), + state.EconomicAssessment.PrimaryExpansionSiteId ?? "none", + state.EconomicAssessment.PrimaryExpansionSystemId ?? "none", + state.ThreatAssessment.PrimaryThreatFactionId ?? "none", + state.ThreatAssessment.PrimaryThreatSystemId ?? "none", + state.Theaters.Count.ToString(CultureInfo.InvariantCulture), + state.Campaigns.Count.ToString(CultureInfo.InvariantCulture), + state.Objectives.Count.ToString(CultureInfo.InvariantCulture), + state.Reservations.Count.ToString(CultureInfo.InvariantCulture), + state.ProductionPrograms.Count.ToString(CultureInfo.InvariantCulture), + state.EconomicAssessment.CommoditySignals.Count.ToString(CultureInfo.InvariantCulture), + state.ThreatAssessment.ThreatSignals.Count.ToString(CultureInfo.InvariantCulture)); + var doctrineSig = $"{faction.Doctrine.StrategicPosture}:{faction.Doctrine.ExpansionPosture}:{faction.Doctrine.MilitaryPosture}:{faction.Doctrine.EconomicPosture}"; + var decisionSig = string.Join(",", faction.DecisionLog.Select(entry => entry.Id)); + var theaterSig = string.Join(";", + state.Theaters.OrderBy(theater => theater.Id, StringComparer.Ordinal) + .Select(theater => $"{theater.Id}:{theater.Kind}:{theater.SystemId}:{theater.Status}:{theater.Priority:0.###}:{theater.SupplyRisk:0.###}:{theater.TargetFactionId}:{theater.AnchorEntityId}:{theater.UpdatedAtUtc.UtcTicks}:{string.Join(",", theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal))}")); + var campaignSig = string.Join(";", + state.Campaigns.OrderBy(campaign => campaign.Id, StringComparer.Ordinal) + .Select(campaign => $"{campaign.Id}:{campaign.Kind}:{campaign.Status}:{campaign.Priority:0.###}:{campaign.TheaterId}:{campaign.TargetFactionId}:{campaign.TargetSystemId}:{campaign.TargetEntityId}:{campaign.CurrentStepIndex}:{campaign.PauseReason}:{campaign.ContinuationScore:0.###}:{campaign.SupplyAdequacy:0.###}:{campaign.ReplacementPressure:0.###}:{campaign.RequiresReinforcement}:{campaign.UpdatedAtUtc.UtcTicks}")); + var objectiveSig = string.Join(";", + state.Objectives.OrderBy(objective => objective.Id, StringComparer.Ordinal) + .Select(objective => $"{objective.Id}:{objective.CampaignId}:{objective.TheaterId}:{objective.Kind}:{objective.DelegationKind}:{objective.BehaviorKind}:{objective.Status}:{objective.Priority:0.###}:{objective.CommanderId}:{objective.TargetSystemId}:{objective.TargetEntityId}:{objective.ItemId}:{objective.CurrentStepIndex}:{objective.UseOrders}:{objective.StagingOrderKind}:{objective.ReinforcementLevel}:{objective.UpdatedAtUtc.UtcTicks}:{string.Join(",", objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}")); + var reservationSig = string.Join(";", + state.Reservations.OrderBy(reservation => reservation.Id, StringComparer.Ordinal) + .Select(reservation => $"{reservation.Id}:{reservation.ObjectiveId}:{reservation.CampaignId}:{reservation.AssetKind}:{reservation.AssetId}:{reservation.Priority:0.###}:{reservation.UpdatedAtUtc.UtcTicks}")); + var productionSig = string.Join(";", + state.ProductionPrograms.OrderBy(program => program.Id, StringComparer.Ordinal) + .Select(program => $"{program.Id}:{program.Kind}:{program.Status}:{program.Priority:0.###}:{program.CampaignId}:{program.CommodityId}:{program.ModuleId}:{program.ShipKind}:{program.TargetSystemId}:{program.TargetCount}:{program.CurrentCount}:{program.Notes}")); + return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}"; + } + + private static string BuildPlayerFactionSignature(PlayerFactionRuntime player) + { + var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}"; + var registrySig = string.Join("|", + player.AssetRegistry.ShipIds.Count, + player.AssetRegistry.StationIds.Count, + player.AssetRegistry.CommanderIds.Count, + player.AssetRegistry.FleetIds.Count, + player.AssetRegistry.TaskForceIds.Count, + player.AssetRegistry.StationGroupIds.Count, + player.AssetRegistry.EconomicRegionIds.Count, + player.AssetRegistry.FrontIds.Count, + player.AssetRegistry.ReserveIds.Count); + var orgSig = string.Join("|", + player.Fleets.Count, + player.TaskForces.Count, + player.StationGroups.Count, + player.EconomicRegions.Count, + player.Fronts.Count, + player.Reserves.Count, + player.Policies.Count, + player.AutomationPolicies.Count, + player.ReinforcementPolicies.Count, + player.ProductionPrograms.Count, + player.Directives.Count, + player.Assignments.Count, + player.Alerts.Count); + var policySig = string.Join(";", + player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}")); + var automationSig = string.Join(";", + player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}")); + var directiveSig = string.Join(";", + player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal) + .Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}")); + var assignmentSig = string.Join(";", + player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal) + .Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}")); + var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id)); + var orgDetailSig = string.Join(";", + player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}") + .Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}"))); + var alertSig = string.Join(";", + player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal) + .Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}")); + return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}"; + } + + private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state) + { + var diplomacySig = string.Join(";", + state.Diplomacy.Relations.OrderBy(relation => relation.Id, StringComparer.Ordinal) + .Select(relation => $"{relation.Id}:{relation.Posture}:{relation.TensionScore:0.###}:{relation.GrievanceScore:0.###}:{relation.TradeAccessPolicy}:{relation.MilitaryAccessPolicy}:{relation.WarStateId}:{relation.UpdatedAtUtc.UtcTicks}")); + var territorySig = string.Join(";", + state.Territory.ControlStates.OrderBy(control => control.SystemId, StringComparer.Ordinal) + .Select(control => $"{control.SystemId}:{control.ControllerFactionId}:{control.PrimaryClaimantFactionId}:{control.ControlKind}:{control.IsContested}:{control.ControlScore:0.###}:{control.StrategicValue:0.###}:{control.UpdatedAtUtc.UtcTicks}")); + var economySig = string.Join(";", + state.EconomyRegions.Regions.OrderBy(region => region.Id, StringComparer.Ordinal) + .Select(region => $"{region.Id}:{region.FactionId}:{region.Kind}:{region.Status}:{region.CoreSystemId}:{string.Join(",", region.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{region.UpdatedAtUtc.UtcTicks}")); + var tensionSig = string.Join(";", + state.Diplomacy.BorderTensions.OrderBy(tension => tension.Id, StringComparer.Ordinal) + .Select(tension => $"{tension.Id}:{tension.RelationId}:{tension.BorderEdgeId}:{tension.Status}:{tension.TensionScore:0.###}:{tension.IncidentScore:0.###}:{tension.MilitaryPressure:0.###}:{tension.AccessFriction:0.###}:{string.Join(",", tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{tension.UpdatedAtUtc.UtcTicks}")); + var frontSig = string.Join(";", + state.Territory.FrontLines.OrderBy(front => front.Id, StringComparer.Ordinal) + .Select(front => $"{front.Id}:{front.Kind}:{front.Status}:{front.AnchorSystemId}:{front.PressureScore:0.###}:{front.SupplyRisk:0.###}:{string.Join(",", front.FactionIds.OrderBy(id => id, StringComparer.Ordinal))}:{string.Join(",", front.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{front.UpdatedAtUtc.UtcTicks}")); + var corridorSig = string.Join(";", + state.EconomyRegions.Corridors.OrderBy(corridor => corridor.Id, StringComparer.Ordinal) + .Select(corridor => $"{corridor.Id}:{corridor.FactionId}:{corridor.Kind}:{corridor.Status}:{corridor.RiskScore:0.###}:{corridor.ThroughputScore:0.###}:{corridor.AccessState}:{string.Join(",", corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal))}:{corridor.UpdatedAtUtc.UtcTicks}")); + var bottleneckSig = string.Join(";", + state.EconomyRegions.Bottlenecks.OrderBy(bottleneck => bottleneck.Id, StringComparer.Ordinal) + .Select(bottleneck => $"{bottleneck.Id}:{bottleneck.RegionId}:{bottleneck.ItemId}:{bottleneck.Cause}:{bottleneck.Status}:{bottleneck.Severity:0.###}:{bottleneck.UpdatedAtUtc.UtcTicks}")); + var assessmentSig = string.Join(";", + state.EconomyRegions.SecurityAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) + .Select(assessment => $"security:{assessment.RegionId}:{assessment.SupplyRisk:0.###}:{assessment.BorderPressure:0.###}:{assessment.ActiveWarCount}:{assessment.HostileRelationCount}:{assessment.AccessFriction:0.###}:{assessment.UpdatedAtUtc.UtcTicks}") + .Concat(state.EconomyRegions.EconomicAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) + .Select(assessment => $"economic:{assessment.RegionId}:{assessment.SustainmentScore:0.###}:{assessment.ProductionDepth:0.###}:{assessment.ConstructionPressure:0.###}:{assessment.CorridorDependency:0.###}:{assessment.UpdatedAtUtc.UtcTicks}"))); + return $"{state.Cycle}|{state.UpdatedAtUtc.UtcTicks}|{state.Routes.Count}|{state.Diplomacy.Relations.Count}|{state.Diplomacy.Incidents.Count}|{state.Diplomacy.Wars.Count}|{state.Territory.ControlStates.Count}|{state.Territory.BorderEdges.Count}|{state.Territory.FrontLines.Count}|{state.EconomyRegions.Regions.Count}|{state.EconomyRegions.Corridors.Count}|{diplomacySig}|{territorySig}|{economySig}|{tensionSig}|{frontSig}|{corridorSig}|{bottleneckSig}|{assessmentSig}"; + } + + private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( + node.Id, + node.SystemId, + ToDto(node.Position), + node.CelestialId, + node.SourceKind, + node.OreRemaining, + node.MaxOre, + node.ItemId); + + private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new( + celestial.Id, + celestial.SystemId, + celestial.Kind.ToContractValue(), + ToDto(celestial.Position), + celestial.LocalSpaceRadius, + celestial.ParentNodeId, + celestial.OccupyingStructureId, + celestial.OrbitReferenceId); + + private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new( + station.Id, + station.Label, + station.Category, + station.Objective, + station.SystemId, + ToDto(station.Position), + station.CelestialId, + station.Color, + station.DockedShipIds.Count, + station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + GetDockingPadCount(station), + ToStationActionProgressSnapshots(world, station), + ToInventoryEntries(station.Inventory), + station.FactionId, + station.CommanderId, + station.PolicySetId, + station.Population, + station.PopulationCapacity, + station.WorkforceRequired, + station.WorkforceEffectiveRatio, + ToStationStorageUsageSnapshots(world, station), + station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(), + station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList()); + + private static IReadOnlyList ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) => + GetStationProductionLanes(world, station) + .Select(laneKey => + { + var recipe = SelectProductionRecipe(world, station, laneKey); + var timer = GetStationProductionTimer(station, laneKey); + var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f); + var progress = Math.Clamp(timer / duration, 0f, 1f); + return recipe is null || timer <= 0.01f + ? null + : new StationActionProgressSnapshot( + laneKey, + recipe.Label, + progress, + duration * (1f - progress), + duration, + recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(), + recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList()); + }) + .Where(snapshot => snapshot is not null) + .Cast() + .ToList(); + + private static IReadOnlyList ToStationStorageUsageSnapshots(SimulationWorld world, StationRuntime station) + { + string[] storageClasses = ["solid", "liquid", "container", "manufactured"]; + return storageClasses + .Select(storageClass => new StationStorageUsageSnapshot( + storageClass, + station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) + .Sum(entry => entry.Value), + GetStationStorageCapacity(station, storageClass))) + .Where(snapshot => snapshot.Capacity > 0.01f) + .ToList(); + } + + private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new( + claim.Id, + claim.FactionId, + claim.SystemId, + claim.CelestialId, + claim.State, + claim.Health, + claim.PlacedAtUtc, + claim.ActivatesAtUtc); + + private static ConstructionSiteDelta ToConstructionSiteDelta(SimulationWorld world, ConstructionSiteRuntime site) => new( + site.Id, + site.FactionId, + site.SystemId, + site.CelestialId, + site.TargetKind, + site.TargetDefinitionId, + site.BlueprintId, + site.ClaimId, + site.StationId, + site.State, + GetConstructionSiteProgress(world, site), + ToInventoryEntries(site.Inventory), + ToInventoryEntries(site.RequiredItems), + ToInventoryEntries(site.DeliveredItems), + site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); + + private static float GetConstructionSiteProgress(SimulationWorld world, ConstructionSiteRuntime site) + { + if (site.BlueprintId is not null + && world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe) + && recipe.Duration > 0.01f) + { + return Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); + } + + return Math.Clamp(site.Progress, 0f, 1f); + } + + private static MarketOrderDelta ToMarketOrderDelta(MarketOrderRuntime order) => new( + order.Id, + order.FactionId, + order.StationId, + order.ConstructionSiteId, + order.Kind, + order.ItemId, + order.Amount, + order.RemainingAmount, + order.Valuation, + order.ReserveThreshold, + order.PolicySetId, + order.State); + + private static PolicySetDelta ToPolicySetDelta(PolicySetRuntime policy) => new( + policy.Id, + policy.OwnerKind, + policy.OwnerId, + policy.TradeAccessPolicy, + policy.DockingAccessPolicy, + policy.ConstructionAccessPolicy, + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); + + private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) + { + var commander = ship.CommanderId is null ? null + : world.Commanders.FirstOrDefault(c => c.Id == ship.CommanderId && c.Kind == CommanderKind.Ship); + + return new ShipDelta( + ship.Id, + ship.Definition.Label, + ship.Definition.Kind, + ship.Definition.Class, + ship.SystemId, + ToDto(ship.Position), + ToDto(ship.Velocity), + ToDto(ship.TargetPosition), + ship.State.ToContractValue(), + ToShipOrderSnapshots(ship), + ToDefaultBehaviorSnapshot(ship.DefaultBehavior), + ToShipAssignmentSnapshot(commander), + new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction), + ToShipPlanSnapshot(ship.ActivePlan), + GetCurrentShipStep(ship)?.Id, + ToActiveSubTaskSnapshots(ship), + ship.ControlSourceKind, + ship.ControlSourceId, + ship.ControlReason, + ship.LastReplanReason, + ship.LastAccessFailureReason, + ship.SpatialState.CurrentCelestialId, + ship.DockedStationId, + ship.CommanderId, + ship.PolicySetId, + ship.Definition.CargoCapacity, + + ToShipTravelSpeed(ship).Speed, + ToShipTravelSpeed(ship).Unit, + ToInventoryEntries(ship.Inventory), + ship.FactionId, + ship.Health, + ship.History.ToList(), + ToShipSpatialStateSnapshot(ship.SpatialState)); + } + + private static (float Speed, string Unit) ToShipTravelSpeed(ShipRuntime ship) + { + return ship.SpatialState.MovementRegime switch + { + MovementRegimeKinds.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"), + MovementRegimeKinds.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"), + _ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"), + }; + } + + private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => + inventory + .Where(entry => entry.Value > 0.001f) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => new InventoryEntry(entry.Key, entry.Value)) + .ToList(); + + private static IReadOnlyList ToShipOrderSnapshots(ShipRuntime ship) => + ship.OrderQueue + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => new ShipOrderSnapshot( + order.Id, + order.Kind, + order.Status.ToContractValue(), + order.Priority, + order.InterruptCurrentPlan, + order.CreatedAtUtc, + order.Label, + order.TargetEntityId, + order.TargetSystemId, + order.TargetPosition is null ? null : ToDto(order.TargetPosition.Value), + order.SourceStationId, + order.DestinationStationId, + order.ItemId, + order.NodeId, + order.ConstructionSiteId, + order.ModuleId, + order.WaitSeconds, + order.Radius, + order.MaxSystemRange, + order.KnownStationsOnly, + order.FailureReason)) + .ToList(); + + private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) => + new( + behavior.Kind, + behavior.HomeSystemId, + behavior.HomeStationId, + behavior.AreaSystemId, + behavior.TargetEntityId, + behavior.PreferredItemId, + behavior.PreferredNodeId, + behavior.PreferredConstructionSiteId, + behavior.PreferredModuleId, + behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), + behavior.WaitSeconds, + behavior.Radius, + behavior.MaxSystemRange, + behavior.KnownStationsOnly, + behavior.PatrolPoints.Select(ToDto).ToList(), + behavior.PatrolIndex, + behavior.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + behavior.RepeatIndex); + + private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) => + new( + template.Kind, + template.Label, + template.TargetEntityId, + template.TargetSystemId, + template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value), + template.SourceStationId, + template.DestinationStationId, + template.ItemId, + template.NodeId, + template.ConstructionSiteId, + template.ModuleId, + template.WaitSeconds, + template.Radius, + template.MaxSystemRange, + template.KnownStationsOnly); + + private static ShipAssignmentSnapshot? ToShipAssignmentSnapshot(CommanderRuntime? commander) + { + if (commander?.Assignment is not { } assignment) + { + return null; + } + + return new ShipAssignmentSnapshot( + commander.Id, + commander.ParentCommanderId, + assignment.Kind, + assignment.BehaviorKind, + assignment.Status, + assignment.ObjectiveId, + assignment.CampaignId, + assignment.TheaterId, + assignment.Priority, + assignment.HomeSystemId, + assignment.HomeStationId, + assignment.TargetSystemId, + assignment.TargetEntityId, + assignment.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), + assignment.ItemId, + assignment.Notes, + assignment.UpdatedAtUtc); + } + + private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan) + { + if (plan is null) + { + return null; + } + + return new ShipPlanSnapshot( + plan.Id, + plan.SourceKind.ToContractValue(), + plan.SourceId, + plan.Kind, + plan.Status.ToContractValue(), + plan.Summary, + plan.CurrentStepIndex, + plan.CreatedAtUtc, + plan.UpdatedAtUtc, + plan.InterruptReason, + plan.FailureReason, + plan.Steps.Select(ToShipPlanStepSnapshot).ToList()); + } + + private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) => + new( + step.Id, + step.Kind, + step.Status.ToContractValue(), + step.Summary, + step.BlockingReason, + step.CurrentSubTaskIndex, + step.SubTasks.Select(ToShipSubTaskSnapshot).ToList()); + + private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) => + new( + subTask.Id, + subTask.Kind, + subTask.Status.ToContractValue(), + subTask.Summary, + subTask.TargetEntityId, + subTask.TargetSystemId, + subTask.TargetNodeId, + subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), + subTask.ItemId, + subTask.ModuleId, + subTask.Threshold, + subTask.Amount, + subTask.Progress, + subTask.ElapsedSeconds, + subTask.TotalSeconds, + subTask.BlockingReason); + + private static IReadOnlyList ToActiveSubTaskSnapshots(ShipRuntime ship) + { + var step = GetCurrentShipStep(ship); + if (step is null) + { + return []; + } + + return step.SubTasks + .Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked) + .Select(ToShipSubTaskSnapshot) + .ToList(); + } + + private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) => + ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count + ? null + : ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex]; + + private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander) + { + var assignment = commander.Assignment; + return new CommanderAssignmentSnapshot( + commander.Id, + assignment?.Kind ?? "unassigned", + assignment?.BehaviorKind ?? "none", + assignment?.Status ?? "idle", + assignment?.ObjectiveId, + assignment?.CampaignId, + assignment?.TheaterId, + commander.ParentCommanderId, + commander.ControlledEntityId, + assignment?.Priority ?? 0f, + assignment?.HomeSystemId, + assignment?.HomeStationId, + assignment?.TargetSystemId, + assignment?.TargetEntityId, + assignment?.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), + assignment?.ItemId, + assignment?.Notes, + assignment?.UpdatedAtUtc, + commander.ActiveObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + commander.SubordinateCommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); + } + + private static FactionDelta ToFactionDelta(SimulationWorld world, FactionRuntime faction, CommanderRuntime? commander) + { + var commanders = world.Commanders + .Where(candidate => candidate.FactionId == faction.Id) + .OrderBy(candidate => candidate.Kind, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .Select(ToCommanderAssignmentSnapshot) + .ToList(); + + return new FactionDelta( faction.Id, faction.Label, faction.Color, @@ -220,1675 +1144,751 @@ internal sealed class SimulationProjectionService faction.ShipsBuilt, faction.ShipsLost, faction.DefaultPolicySetId, - faction.Doctrine, - faction.Memory, - faction.StrategicState, - faction.DecisionLog, - faction.Commanders)).ToList(), - ToPlayerFactionSnapshot(world.PlayerFaction), - ToGeopoliticalStateSnapshot(world.Geopolitics)); - } - - public void PrimeDeltaBaseline(SimulationWorld world) - { - foreach (var node in world.Nodes) - { - node.LastDeltaSignature = BuildNodeSignature(node); + ToFactionDoctrineSnapshot(faction.Doctrine), + ToFactionMemorySnapshot(faction.Memory), + ToFactionStrategicStateSnapshot(faction.StrategicState), + ToFactionDecisionLogSnapshots(faction.DecisionLog), + commanders); } - foreach (var celestial in world.Celestials) - { - celestial.LastDeltaSignature = BuildCelestialSignature(celestial); - } - - foreach (var station in world.Stations) - { - station.LastDeltaSignature = BuildStationSignature(world, station); - } - - foreach (var claim in world.Claims) - { - claim.LastDeltaSignature = BuildClaimSignature(claim); - } - - foreach (var site in world.ConstructionSites) - { - site.LastDeltaSignature = BuildConstructionSiteSignature(site); - } - - foreach (var order in world.MarketOrders) - { - order.LastDeltaSignature = BuildMarketOrderSignature(order); - } - - foreach (var policy in world.Policies) - { - policy.LastDeltaSignature = BuildPolicySignature(policy); - } - - foreach (var ship in world.Ships) - { - ship.LastDeltaSignature = BuildShipSignature(world, ship); - } - - foreach (var faction in world.Factions) - { - faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id)); - } - - if (world.PlayerFaction is not null) - { - world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction); - } - - if (world.Geopolitics is not null) - { - world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics); - } - } - - private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var node in world.Nodes) - { - var signature = BuildNodeSignature(node); - if (signature == node.LastDeltaSignature) - { - continue; - } - - node.LastDeltaSignature = signature; - deltas.Add(ToNodeDelta(node)); - } - - return deltas; - } - - private static IReadOnlyList BuildCelestialDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var celestial in world.Celestials) - { - var signature = BuildCelestialSignature(celestial); - if (signature == celestial.LastDeltaSignature) - { - continue; - } - - celestial.LastDeltaSignature = signature; - deltas.Add(ToCelestialDelta(celestial)); - } - - return deltas; - } - - private static IReadOnlyList BuildStationDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var station in world.Stations) - { - var signature = BuildStationSignature(world, station); - if (signature == station.LastDeltaSignature) - { - continue; - } - - station.LastDeltaSignature = signature; - deltas.Add(ToStationDelta(world, station)); - } - - return deltas; - } - - private static IReadOnlyList BuildClaimDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var claim in world.Claims) - { - var signature = BuildClaimSignature(claim); - if (signature == claim.LastDeltaSignature) - { - continue; - } - - claim.LastDeltaSignature = signature; - deltas.Add(ToClaimDelta(claim)); - } - - return deltas; - } - - private static IReadOnlyList BuildConstructionSiteDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var site in world.ConstructionSites) - { - var signature = BuildConstructionSiteSignature(site); - if (signature == site.LastDeltaSignature) - { - continue; - } - - site.LastDeltaSignature = signature; - deltas.Add(ToConstructionSiteDelta(world, site)); - } - - return deltas; - } - - private static IReadOnlyList BuildMarketOrderDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var order in world.MarketOrders) - { - var signature = BuildMarketOrderSignature(order); - if (signature == order.LastDeltaSignature) - { - continue; - } - - order.LastDeltaSignature = signature; - deltas.Add(ToMarketOrderDelta(order)); - } - - return deltas; - } - - private static IReadOnlyList BuildPolicyDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var policy in world.Policies) - { - var signature = BuildPolicySignature(policy); - if (signature == policy.LastDeltaSignature) - { - continue; - } - - policy.LastDeltaSignature = signature; - deltas.Add(ToPolicySetDelta(policy)); - } - - return deltas; - } - - private IReadOnlyList BuildShipDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var ship in world.Ships) - { - var signature = BuildShipSignature(world, ship); - if (signature == ship.LastDeltaSignature) - { - continue; - } - - ship.LastDeltaSignature = signature; - deltas.Add(ToShipDelta(world, ship)); - } - - return deltas; - } - - private static IReadOnlyList BuildFactionDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var faction in world.Factions) - { - var commander = FindFactionCommander(world, faction.Id); - var signature = BuildFactionSignature(faction, commander); - if (signature == faction.LastDeltaSignature) - { - continue; - } - - faction.LastDeltaSignature = signature; - deltas.Add(ToFactionDelta(world, faction, commander)); - } - - return deltas; - } - - private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world) - { - if (world.PlayerFaction is null) - { - return null; - } - - var signature = BuildPlayerFactionSignature(world.PlayerFaction); - if (signature == world.PlayerFaction.LastDeltaSignature) - { - return null; - } - - world.PlayerFaction.LastDeltaSignature = signature; - return ToPlayerFactionSnapshot(world.PlayerFaction); - } - - private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world) - { - if (world.Geopolitics is null) - { - return null; - } - - var signature = BuildGeopoliticalSignature(world.Geopolitics); - if (signature == world.Geopolitics.LastDeltaSignature) - { - return null; - } - - world.Geopolitics.LastDeltaSignature = signature; - return ToGeopoliticalStateSnapshot(world.Geopolitics); - } - - private static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => - world.Commanders.FirstOrDefault(c => - c.FactionId == factionId && - string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); - - private static string BuildNodeSignature(ResourceNodeRuntime node) => - $"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}"; - - private static string BuildCelestialSignature(CelestialRuntime celestial) => - $"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}"; - - private static string BuildStationSignature(SimulationWorld world, StationRuntime station) - { - var processes = ToStationActionProgressSnapshots(world, station); - return string.Join("|", - station.SystemId, - station.CelestialId ?? "none", - station.CommanderId ?? "none", - station.PolicySetId ?? "none", - BuildInventorySignature(station.Inventory), - string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")), - string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)), - station.DockingPadAssignments.Count.ToString(), - station.Population.ToString("0.###"), - station.PopulationCapacity.ToString("0.###"), - station.WorkforceRequired.ToString("0.###"), - station.WorkforceEffectiveRatio.ToString("0.###"), - string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)), - string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)), - station.ActiveConstruction?.ModuleId ?? "none", - station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0", - string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}"))); - } - - private static string BuildClaimSignature(ClaimRuntime claim) => - $"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; - - private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) => - $"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}"; - - private static string BuildMarketOrderSignature(MarketOrderRuntime order) => - $"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}"; - - private static string BuildPolicySignature(PolicySetRuntime policy) => - $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}|{policy.CombatEngagementPolicy}|{policy.AvoidHostileSystems}|{policy.FleeHullRatio:0.###}|{string.Join(",", policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal))}"; - - private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) => - string.Join("|", - ship.SystemId, - ship.Position.X.ToString("0.###"), - ship.Position.Y.ToString("0.###"), - ship.Position.Z.ToString("0.###"), - ship.Velocity.X.ToString("0.###"), - ship.Velocity.Y.ToString("0.###"), - ship.Velocity.Z.ToString("0.###"), - ship.TargetPosition.X.ToString("0.###"), - ship.TargetPosition.Y.ToString("0.###"), - ship.TargetPosition.Z.ToString("0.###"), - ship.State.ToContractValue(), - string.Join(",", ship.OrderQueue - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), - ship.DefaultBehavior.Kind, - ship.DefaultBehavior.TargetEntityId ?? "none", - ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none", - ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none", - ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none", - ship.DefaultBehavior.WaitSeconds.ToString("0.###"), - ship.DefaultBehavior.Radius.ToString("0.###"), - ship.DefaultBehavior.MaxSystemRange.ToString(CultureInfo.InvariantCulture), - ship.DefaultBehavior.KnownStationsOnly.ToString(), - string.Join(",", ship.DefaultBehavior.RepeatOrders.Select(order => - $"{order.Kind}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), - ship.DefaultBehavior.RepeatIndex.ToString(CultureInfo.InvariantCulture), - string.Join(",", ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal)), - ship.ControlSourceKind, - ship.ControlSourceId ?? "none", - ship.ControlReason ?? "none", - ship.LastReplanReason ?? "none", - ship.LastAccessFailureReason ?? "none", - ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment - ? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}" - : "no-assignment", - ship.ActivePlan?.Kind ?? "none", - ship.ActivePlan?.Status.ToContractValue() ?? "none", - ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1", - string.Join(",", - ToActiveSubTaskSnapshots(ship).Select(subTask => - $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), - ship.SpatialState.CurrentCelestialId ?? "none", - ship.DockedStationId ?? "none", - ship.CommanderId ?? "none", - ship.PolicySetId ?? "none", - ship.SpatialState.SpaceLayer, - ship.SpatialState.CurrentCelestialId ?? "none", - ship.SpatialState.MovementRegime, - ship.SpatialState.DestinationNodeId ?? "none", - ship.SpatialState.Transit?.Regime ?? "none", - ship.SpatialState.Transit?.OriginNodeId ?? "none", - ship.SpatialState.Transit?.DestinationNodeId ?? "none", - ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", - GetShipCargoAmount(ship).ToString("0.###"), - ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), - ship.Skills.Trade.ToString(CultureInfo.InvariantCulture), - ship.Skills.Mining.ToString(CultureInfo.InvariantCulture), - ship.Skills.Combat.ToString(CultureInfo.InvariantCulture), - ship.Skills.Construction.ToString(CultureInfo.InvariantCulture), - ship.Health.ToString("0.###"), - GetCurrentShipStep(ship)?.Id ?? "none"); - - private static string BuildInventorySignature(IReadOnlyDictionary inventory) => - string.Join(",", - inventory - .Where(entry => entry.Value > 0.001f) - .OrderBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => $"{entry.Key}:{entry.Value:0.###}")); - - private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander) - { - var assignmentSig = commander?.Assignment is null - ? string.Empty - : $"{commander.Assignment.ObjectiveId}:{commander.Assignment.Kind}:{commander.Assignment.BehaviorKind}:{commander.Assignment.Status}:{commander.Assignment.TargetSystemId}:{commander.Assignment.TargetEntityId}:{commander.Assignment.ItemId}"; - var state = faction.StrategicState; - var strategicSig = string.Join(";", - state.Status, - state.PlanCycle.ToString(CultureInfo.InvariantCulture), - state.EconomicAssessment.PrimaryExpansionSiteId ?? "none", - state.EconomicAssessment.PrimaryExpansionSystemId ?? "none", - state.ThreatAssessment.PrimaryThreatFactionId ?? "none", - state.ThreatAssessment.PrimaryThreatSystemId ?? "none", - state.Theaters.Count.ToString(CultureInfo.InvariantCulture), - state.Campaigns.Count.ToString(CultureInfo.InvariantCulture), - state.Objectives.Count.ToString(CultureInfo.InvariantCulture), - state.Reservations.Count.ToString(CultureInfo.InvariantCulture), - state.ProductionPrograms.Count.ToString(CultureInfo.InvariantCulture), - state.EconomicAssessment.CommoditySignals.Count.ToString(CultureInfo.InvariantCulture), - state.ThreatAssessment.ThreatSignals.Count.ToString(CultureInfo.InvariantCulture)); - var doctrineSig = $"{faction.Doctrine.StrategicPosture}:{faction.Doctrine.ExpansionPosture}:{faction.Doctrine.MilitaryPosture}:{faction.Doctrine.EconomicPosture}"; - var decisionSig = string.Join(",", faction.DecisionLog.Select(entry => entry.Id)); - var theaterSig = string.Join(";", - state.Theaters.OrderBy(theater => theater.Id, StringComparer.Ordinal) - .Select(theater => $"{theater.Id}:{theater.Kind}:{theater.SystemId}:{theater.Status}:{theater.Priority:0.###}:{theater.SupplyRisk:0.###}:{theater.TargetFactionId}:{theater.AnchorEntityId}:{theater.UpdatedAtUtc.UtcTicks}:{string.Join(",", theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal))}")); - var campaignSig = string.Join(";", - state.Campaigns.OrderBy(campaign => campaign.Id, StringComparer.Ordinal) - .Select(campaign => $"{campaign.Id}:{campaign.Kind}:{campaign.Status}:{campaign.Priority:0.###}:{campaign.TheaterId}:{campaign.TargetFactionId}:{campaign.TargetSystemId}:{campaign.TargetEntityId}:{campaign.CurrentStepIndex}:{campaign.PauseReason}:{campaign.ContinuationScore:0.###}:{campaign.SupplyAdequacy:0.###}:{campaign.ReplacementPressure:0.###}:{campaign.RequiresReinforcement}:{campaign.UpdatedAtUtc.UtcTicks}")); - var objectiveSig = string.Join(";", - state.Objectives.OrderBy(objective => objective.Id, StringComparer.Ordinal) - .Select(objective => $"{objective.Id}:{objective.CampaignId}:{objective.TheaterId}:{objective.Kind}:{objective.DelegationKind}:{objective.BehaviorKind}:{objective.Status}:{objective.Priority:0.###}:{objective.CommanderId}:{objective.TargetSystemId}:{objective.TargetEntityId}:{objective.ItemId}:{objective.CurrentStepIndex}:{objective.UseOrders}:{objective.StagingOrderKind}:{objective.ReinforcementLevel}:{objective.UpdatedAtUtc.UtcTicks}:{string.Join(",", objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}")); - var reservationSig = string.Join(";", - state.Reservations.OrderBy(reservation => reservation.Id, StringComparer.Ordinal) - .Select(reservation => $"{reservation.Id}:{reservation.ObjectiveId}:{reservation.CampaignId}:{reservation.AssetKind}:{reservation.AssetId}:{reservation.Priority:0.###}:{reservation.UpdatedAtUtc.UtcTicks}")); - var productionSig = string.Join(";", - state.ProductionPrograms.OrderBy(program => program.Id, StringComparer.Ordinal) - .Select(program => $"{program.Id}:{program.Kind}:{program.Status}:{program.Priority:0.###}:{program.CampaignId}:{program.CommodityId}:{program.ModuleId}:{program.ShipKind}:{program.TargetSystemId}:{program.TargetCount}:{program.CurrentCount}:{program.Notes}")); - return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}"; - } - - private static string BuildPlayerFactionSignature(PlayerFactionRuntime player) - { - var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}"; - var registrySig = string.Join("|", - player.AssetRegistry.ShipIds.Count, - player.AssetRegistry.StationIds.Count, - player.AssetRegistry.CommanderIds.Count, - player.AssetRegistry.FleetIds.Count, - player.AssetRegistry.TaskForceIds.Count, - player.AssetRegistry.StationGroupIds.Count, - player.AssetRegistry.EconomicRegionIds.Count, - player.AssetRegistry.FrontIds.Count, - player.AssetRegistry.ReserveIds.Count); - var orgSig = string.Join("|", - player.Fleets.Count, - player.TaskForces.Count, - player.StationGroups.Count, - player.EconomicRegions.Count, - player.Fronts.Count, - player.Reserves.Count, - player.Policies.Count, - player.AutomationPolicies.Count, - player.ReinforcementPolicies.Count, - player.ProductionPrograms.Count, - player.Directives.Count, - player.Assignments.Count, - player.Alerts.Count); - var policySig = string.Join(";", - player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal) - .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}")); - var automationSig = string.Join(";", - player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal) - .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}")); - var directiveSig = string.Join(";", - player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal) - .Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}")); - var assignmentSig = string.Join(";", - player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal) - .Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}")); - var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id)); - var orgDetailSig = string.Join(";", - player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}") - .Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}")) - .Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}"))); - var alertSig = string.Join(";", - player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal) - .Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}")); - return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}"; - } - - private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state) - { - var diplomacySig = string.Join(";", - state.Diplomacy.Relations.OrderBy(relation => relation.Id, StringComparer.Ordinal) - .Select(relation => $"{relation.Id}:{relation.Posture}:{relation.TensionScore:0.###}:{relation.GrievanceScore:0.###}:{relation.TradeAccessPolicy}:{relation.MilitaryAccessPolicy}:{relation.WarStateId}:{relation.UpdatedAtUtc.UtcTicks}")); - var territorySig = string.Join(";", - state.Territory.ControlStates.OrderBy(control => control.SystemId, StringComparer.Ordinal) - .Select(control => $"{control.SystemId}:{control.ControllerFactionId}:{control.PrimaryClaimantFactionId}:{control.ControlKind}:{control.IsContested}:{control.ControlScore:0.###}:{control.StrategicValue:0.###}:{control.UpdatedAtUtc.UtcTicks}")); - var economySig = string.Join(";", - state.EconomyRegions.Regions.OrderBy(region => region.Id, StringComparer.Ordinal) - .Select(region => $"{region.Id}:{region.FactionId}:{region.Kind}:{region.Status}:{region.CoreSystemId}:{string.Join(",", region.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{region.UpdatedAtUtc.UtcTicks}")); - var tensionSig = string.Join(";", - state.Diplomacy.BorderTensions.OrderBy(tension => tension.Id, StringComparer.Ordinal) - .Select(tension => $"{tension.Id}:{tension.RelationId}:{tension.BorderEdgeId}:{tension.Status}:{tension.TensionScore:0.###}:{tension.IncidentScore:0.###}:{tension.MilitaryPressure:0.###}:{tension.AccessFriction:0.###}:{string.Join(",", tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{tension.UpdatedAtUtc.UtcTicks}")); - var frontSig = string.Join(";", - state.Territory.FrontLines.OrderBy(front => front.Id, StringComparer.Ordinal) - .Select(front => $"{front.Id}:{front.Kind}:{front.Status}:{front.AnchorSystemId}:{front.PressureScore:0.###}:{front.SupplyRisk:0.###}:{string.Join(",", front.FactionIds.OrderBy(id => id, StringComparer.Ordinal))}:{string.Join(",", front.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{front.UpdatedAtUtc.UtcTicks}")); - var corridorSig = string.Join(";", - state.EconomyRegions.Corridors.OrderBy(corridor => corridor.Id, StringComparer.Ordinal) - .Select(corridor => $"{corridor.Id}:{corridor.FactionId}:{corridor.Kind}:{corridor.Status}:{corridor.RiskScore:0.###}:{corridor.ThroughputScore:0.###}:{corridor.AccessState}:{string.Join(",", corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal))}:{corridor.UpdatedAtUtc.UtcTicks}")); - var bottleneckSig = string.Join(";", - state.EconomyRegions.Bottlenecks.OrderBy(bottleneck => bottleneck.Id, StringComparer.Ordinal) - .Select(bottleneck => $"{bottleneck.Id}:{bottleneck.RegionId}:{bottleneck.ItemId}:{bottleneck.Cause}:{bottleneck.Status}:{bottleneck.Severity:0.###}:{bottleneck.UpdatedAtUtc.UtcTicks}")); - var assessmentSig = string.Join(";", - state.EconomyRegions.SecurityAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) - .Select(assessment => $"security:{assessment.RegionId}:{assessment.SupplyRisk:0.###}:{assessment.BorderPressure:0.###}:{assessment.ActiveWarCount}:{assessment.HostileRelationCount}:{assessment.AccessFriction:0.###}:{assessment.UpdatedAtUtc.UtcTicks}") - .Concat(state.EconomyRegions.EconomicAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) - .Select(assessment => $"economic:{assessment.RegionId}:{assessment.SustainmentScore:0.###}:{assessment.ProductionDepth:0.###}:{assessment.ConstructionPressure:0.###}:{assessment.CorridorDependency:0.###}:{assessment.UpdatedAtUtc.UtcTicks}"))); - return $"{state.Cycle}|{state.UpdatedAtUtc.UtcTicks}|{state.Routes.Count}|{state.Diplomacy.Relations.Count}|{state.Diplomacy.Incidents.Count}|{state.Diplomacy.Wars.Count}|{state.Territory.ControlStates.Count}|{state.Territory.BorderEdges.Count}|{state.Territory.FrontLines.Count}|{state.EconomyRegions.Regions.Count}|{state.EconomyRegions.Corridors.Count}|{diplomacySig}|{territorySig}|{economySig}|{tensionSig}|{frontSig}|{corridorSig}|{bottleneckSig}|{assessmentSig}"; - } - - private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( - node.Id, - node.SystemId, - ToDto(node.Position), - node.CelestialId, - node.SourceKind, - node.OreRemaining, - node.MaxOre, - node.ItemId); - - private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new( - celestial.Id, - celestial.SystemId, - celestial.Kind.ToContractValue(), - ToDto(celestial.Position), - celestial.LocalSpaceRadius, - celestial.ParentNodeId, - celestial.OccupyingStructureId, - celestial.OrbitReferenceId); - - private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new( - station.Id, - station.Label, - station.Category, - station.Objective, - station.SystemId, - ToDto(station.Position), - station.CelestialId, - station.Color, - station.DockedShipIds.Count, - station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - GetDockingPadCount(station), - ToStationActionProgressSnapshots(world, station), - ToInventoryEntries(station.Inventory), - station.FactionId, - station.CommanderId, - station.PolicySetId, - station.Population, - station.PopulationCapacity, - station.WorkforceRequired, - station.WorkforceEffectiveRatio, - ToStationStorageUsageSnapshots(world, station), - station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(), - station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList()); - - private static IReadOnlyList ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) => - GetStationProductionLanes(world, station) - .Select(laneKey => - { - var recipe = SelectProductionRecipe(world, station, laneKey); - var timer = GetStationProductionTimer(station, laneKey); - var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f); - var progress = Math.Clamp(timer / duration, 0f, 1f); - return recipe is null || timer <= 0.01f - ? null - : new StationActionProgressSnapshot( - laneKey, - recipe.Label, - progress, - duration * (1f - progress), - duration, - recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(), - recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList()); - }) - .Where(snapshot => snapshot is not null) - .Cast() - .ToList(); - - private static IReadOnlyList ToStationStorageUsageSnapshots(SimulationWorld world, StationRuntime station) - { - string[] storageClasses = ["solid", "liquid", "container", "manufactured"]; - return storageClasses - .Select(storageClass => new StationStorageUsageSnapshot( - storageClass, - station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass) - .Sum(entry => entry.Value), - GetStationStorageCapacity(station, storageClass))) - .Where(snapshot => snapshot.Capacity > 0.01f) - .ToList(); - } - - private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new( - claim.Id, - claim.FactionId, - claim.SystemId, - claim.CelestialId, - claim.State, - claim.Health, - claim.PlacedAtUtc, - claim.ActivatesAtUtc); - - private static ConstructionSiteDelta ToConstructionSiteDelta(SimulationWorld world, ConstructionSiteRuntime site) => new( - site.Id, - site.FactionId, - site.SystemId, - site.CelestialId, - site.TargetKind, - site.TargetDefinitionId, - site.BlueprintId, - site.ClaimId, - site.StationId, - site.State, - GetConstructionSiteProgress(world, site), - ToInventoryEntries(site.Inventory), - ToInventoryEntries(site.RequiredItems), - ToInventoryEntries(site.DeliveredItems), - site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); - - private static float GetConstructionSiteProgress(SimulationWorld world, ConstructionSiteRuntime site) - { - if (site.BlueprintId is not null - && world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe) - && recipe.Duration > 0.01f) - { - return Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); - } - - return Math.Clamp(site.Progress, 0f, 1f); - } - - private static MarketOrderDelta ToMarketOrderDelta(MarketOrderRuntime order) => new( - order.Id, - order.FactionId, - order.StationId, - order.ConstructionSiteId, - order.Kind, - order.ItemId, - order.Amount, - order.RemainingAmount, - order.Valuation, - order.ReserveThreshold, - order.PolicySetId, - order.State); - - private static PolicySetDelta ToPolicySetDelta(PolicySetRuntime policy) => new( - policy.Id, - policy.OwnerKind, - policy.OwnerId, - policy.TradeAccessPolicy, - policy.DockingAccessPolicy, - policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy, - policy.CombatEngagementPolicy, - policy.AvoidHostileSystems, - policy.FleeHullRatio, - policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); - - private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) - { - var commander = ship.CommanderId is null ? null - : world.Commanders.FirstOrDefault(c => c.Id == ship.CommanderId && c.Kind == CommanderKind.Ship); - - return new ShipDelta( - ship.Id, - ship.Definition.Label, - ship.Definition.Kind, - ship.Definition.Class, - ship.SystemId, - ToDto(ship.Position), - ToDto(ship.Velocity), - ToDto(ship.TargetPosition), - ship.State.ToContractValue(), - ToShipOrderSnapshots(ship), - ToDefaultBehaviorSnapshot(ship.DefaultBehavior), - ToShipAssignmentSnapshot(commander), - new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction), - ToShipPlanSnapshot(ship.ActivePlan), - GetCurrentShipStep(ship)?.Id, - ToActiveSubTaskSnapshots(ship), - ship.ControlSourceKind, - ship.ControlSourceId, - ship.ControlReason, - ship.LastReplanReason, - ship.LastAccessFailureReason, - ship.SpatialState.CurrentCelestialId, - ship.DockedStationId, - ship.CommanderId, - ship.PolicySetId, - ship.Definition.CargoCapacity, - - ToShipTravelSpeed(ship).Speed, - ToShipTravelSpeed(ship).Unit, - ToInventoryEntries(ship.Inventory), - ship.FactionId, - ship.Health, - ship.History.ToList(), - ToShipSpatialStateSnapshot(ship.SpatialState)); - } - - private static (float Speed, string Unit) ToShipTravelSpeed(ShipRuntime ship) - { - return ship.SpatialState.MovementRegime switch - { - MovementRegimeKinds.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"), - MovementRegimeKinds.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"), - _ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"), - }; - } - - private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => - inventory - .Where(entry => entry.Value > 0.001f) - .OrderBy(entry => entry.Key, StringComparer.Ordinal) - .Select(entry => new InventoryEntry(entry.Key, entry.Value)) - .ToList(); - - private static IReadOnlyList ToShipOrderSnapshots(ShipRuntime ship) => - ship.OrderQueue - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => new ShipOrderSnapshot( - order.Id, - order.Kind, - order.Status.ToContractValue(), - order.Priority, - order.InterruptCurrentPlan, - order.CreatedAtUtc, - order.Label, - order.TargetEntityId, - order.TargetSystemId, - order.TargetPosition is null ? null : ToDto(order.TargetPosition.Value), - order.SourceStationId, - order.DestinationStationId, - order.ItemId, - order.NodeId, - order.ConstructionSiteId, - order.ModuleId, - order.WaitSeconds, - order.Radius, - order.MaxSystemRange, - order.KnownStationsOnly, - order.FailureReason)) - .ToList(); - - private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) => - new( - behavior.Kind, - behavior.HomeSystemId, - behavior.HomeStationId, - behavior.AreaSystemId, - behavior.TargetEntityId, - behavior.PreferredItemId, - behavior.PreferredNodeId, - behavior.PreferredConstructionSiteId, - behavior.PreferredModuleId, - behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), - behavior.WaitSeconds, - behavior.Radius, - behavior.MaxSystemRange, - behavior.KnownStationsOnly, - behavior.PatrolPoints.Select(ToDto).ToList(), - behavior.PatrolIndex, - behavior.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), - behavior.RepeatIndex); - - private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) => - new( - template.Kind, - template.Label, - template.TargetEntityId, - template.TargetSystemId, - template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value), - template.SourceStationId, - template.DestinationStationId, - template.ItemId, - template.NodeId, - template.ConstructionSiteId, - template.ModuleId, - template.WaitSeconds, - template.Radius, - template.MaxSystemRange, - template.KnownStationsOnly); - - private static ShipAssignmentSnapshot? ToShipAssignmentSnapshot(CommanderRuntime? commander) - { - if (commander?.Assignment is not { } assignment) - { - return null; - } - - return new ShipAssignmentSnapshot( - commander.Id, - commander.ParentCommanderId, - assignment.Kind, - assignment.BehaviorKind, - assignment.Status, - assignment.ObjectiveId, - assignment.CampaignId, - assignment.TheaterId, - assignment.Priority, - assignment.HomeSystemId, - assignment.HomeStationId, - assignment.TargetSystemId, - assignment.TargetEntityId, - assignment.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), - assignment.ItemId, - assignment.Notes, - assignment.UpdatedAtUtc); - } - - private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan) - { - if (plan is null) - { - return null; - } - - return new ShipPlanSnapshot( - plan.Id, - plan.SourceKind.ToContractValue(), - plan.SourceId, - plan.Kind, - plan.Status.ToContractValue(), - plan.Summary, - plan.CurrentStepIndex, - plan.CreatedAtUtc, - plan.UpdatedAtUtc, - plan.InterruptReason, - plan.FailureReason, - plan.Steps.Select(ToShipPlanStepSnapshot).ToList()); - } - - private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) => - new( - step.Id, - step.Kind, - step.Status.ToContractValue(), - step.Summary, - step.BlockingReason, - step.CurrentSubTaskIndex, - step.SubTasks.Select(ToShipSubTaskSnapshot).ToList()); - - private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) => - new( - subTask.Id, - subTask.Kind, - subTask.Status.ToContractValue(), - subTask.Summary, - subTask.TargetEntityId, - subTask.TargetSystemId, - subTask.TargetNodeId, - subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), - subTask.ItemId, - subTask.ModuleId, - subTask.Threshold, - subTask.Amount, - subTask.Progress, - subTask.ElapsedSeconds, - subTask.TotalSeconds, - subTask.BlockingReason); - - private static IReadOnlyList ToActiveSubTaskSnapshots(ShipRuntime ship) - { - var step = GetCurrentShipStep(ship); - if (step is null) - { - return []; - } - - return step.SubTasks - .Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked) - .Select(ToShipSubTaskSnapshot) - .ToList(); - } - - private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) => - ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count - ? null - : ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex]; - - private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander) - { - var assignment = commander.Assignment; - return new CommanderAssignmentSnapshot( - commander.Id, - assignment?.Kind ?? "unassigned", - assignment?.BehaviorKind ?? "none", - assignment?.Status ?? "idle", - assignment?.ObjectiveId, - assignment?.CampaignId, - assignment?.TheaterId, - commander.ParentCommanderId, - commander.ControlledEntityId, - assignment?.Priority ?? 0f, - assignment?.HomeSystemId, - assignment?.HomeStationId, - assignment?.TargetSystemId, - assignment?.TargetEntityId, - assignment?.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), - assignment?.ItemId, - assignment?.Notes, - assignment?.UpdatedAtUtc, - commander.ActiveObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - commander.SubordinateCommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); - } - - private static FactionDelta ToFactionDelta(SimulationWorld world, FactionRuntime faction, CommanderRuntime? commander) - { - var commanders = world.Commanders - .Where(candidate => candidate.FactionId == faction.Id) - .OrderBy(candidate => candidate.Kind, StringComparer.Ordinal) - .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) - .Select(ToCommanderAssignmentSnapshot) - .ToList(); - - return new FactionDelta( - faction.Id, - faction.Label, - faction.Color, - faction.Credits, - faction.PopulationTotal, - faction.OreMined, - faction.GoodsProduced, - faction.ShipsBuilt, - faction.ShipsLost, - faction.DefaultPolicySetId, - ToFactionDoctrineSnapshot(faction.Doctrine), - ToFactionMemorySnapshot(faction.Memory), - ToFactionStrategicStateSnapshot(faction.StrategicState), - ToFactionDecisionLogSnapshots(faction.DecisionLog), - commanders); - } - - private static FactionDoctrineSnapshot ToFactionDoctrineSnapshot(FactionDoctrineRuntime doctrine) => new( - doctrine.StrategicPosture, - doctrine.ExpansionPosture, - doctrine.MilitaryPosture, - doctrine.EconomicPosture, - doctrine.DesiredControlledSystems, - doctrine.DesiredMilitaryPerFront, - doctrine.DesiredMinersPerSystem, - doctrine.DesiredTransportsPerSystem, - doctrine.DesiredConstructors, - doctrine.ReserveCreditsRatio, - doctrine.ExpansionBudgetRatio, - doctrine.WarBudgetRatio, - doctrine.ReserveMilitaryRatio, - doctrine.OffensiveReadinessThreshold, - doctrine.SupplySecurityBias, - doctrine.FailureAversion, - doctrine.ReinforcementLeadPerFront); - - private static FactionMemorySnapshot ToFactionMemorySnapshot(FactionMemoryRuntime memory) => new( - memory.LastPlanCycle, - memory.UpdatedAtUtc, - memory.LastObservedShipsBuilt, - memory.LastObservedShipsLost, - memory.LastObservedCredits, - memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - memory.KnownEnemyFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - memory.SystemMemories - .OrderBy(entry => entry.SystemId, StringComparer.Ordinal) - .Select(entry => new FactionSystemMemorySnapshot( - entry.SystemId, - entry.LastSeenAtUtc, - entry.LastEnemyShipCount, - entry.LastEnemyStationCount, - entry.ControlledByFaction, - entry.LastRole, - NormalizeFiniteFloat(entry.FrontierPressure), - NormalizeFiniteFloat(entry.RouteRisk), - NormalizeFiniteFloat(entry.HistoricalShortagePressure), - entry.OffensiveFailures, - entry.DefensiveFailures, - entry.OffensiveSuccesses, - entry.DefensiveSuccesses, - entry.LastContestedAtUtc, - entry.LastShortageAtUtc)) - .ToList(), - memory.CommodityMemories - .OrderBy(entry => entry.ItemId, StringComparer.Ordinal) - .Select(entry => new FactionCommodityMemorySnapshot( - entry.ItemId, - NormalizeFiniteFloat(entry.HistoricalShortageScore), - NormalizeFiniteFloat(entry.HistoricalSurplusScore), - NormalizeFiniteFloat(entry.LastObservedBacklog), - entry.UpdatedAtUtc, - entry.LastCriticalAtUtc)) - .ToList(), - memory.RecentOutcomes + private static FactionDoctrineSnapshot ToFactionDoctrineSnapshot(FactionDoctrineRuntime doctrine) => new( + doctrine.StrategicPosture, + doctrine.ExpansionPosture, + doctrine.MilitaryPosture, + doctrine.EconomicPosture, + doctrine.DesiredControlledSystems, + doctrine.DesiredMilitaryPerFront, + doctrine.DesiredMinersPerSystem, + doctrine.DesiredTransportsPerSystem, + doctrine.DesiredConstructors, + doctrine.ReserveCreditsRatio, + doctrine.ExpansionBudgetRatio, + doctrine.WarBudgetRatio, + doctrine.ReserveMilitaryRatio, + doctrine.OffensiveReadinessThreshold, + doctrine.SupplySecurityBias, + doctrine.FailureAversion, + doctrine.ReinforcementLeadPerFront); + + private static FactionMemorySnapshot ToFactionMemorySnapshot(FactionMemoryRuntime memory) => new( + memory.LastPlanCycle, + memory.UpdatedAtUtc, + memory.LastObservedShipsBuilt, + memory.LastObservedShipsLost, + memory.LastObservedCredits, + memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + memory.KnownEnemyFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + memory.SystemMemories + .OrderBy(entry => entry.SystemId, StringComparer.Ordinal) + .Select(entry => new FactionSystemMemorySnapshot( + entry.SystemId, + entry.LastSeenAtUtc, + entry.LastEnemyShipCount, + entry.LastEnemyStationCount, + entry.ControlledByFaction, + entry.LastRole, + NormalizeFiniteFloat(entry.FrontierPressure), + NormalizeFiniteFloat(entry.RouteRisk), + NormalizeFiniteFloat(entry.HistoricalShortagePressure), + entry.OffensiveFailures, + entry.DefensiveFailures, + entry.OffensiveSuccesses, + entry.DefensiveSuccesses, + entry.LastContestedAtUtc, + entry.LastShortageAtUtc)) + .ToList(), + memory.CommodityMemories + .OrderBy(entry => entry.ItemId, StringComparer.Ordinal) + .Select(entry => new FactionCommodityMemorySnapshot( + entry.ItemId, + NormalizeFiniteFloat(entry.HistoricalShortageScore), + NormalizeFiniteFloat(entry.HistoricalSurplusScore), + NormalizeFiniteFloat(entry.LastObservedBacklog), + entry.UpdatedAtUtc, + entry.LastCriticalAtUtc)) + .ToList(), + memory.RecentOutcomes + .OrderBy(entry => entry.OccurredAtUtc) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .Select(entry => new FactionOutcomeRecordSnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedCampaignId, + entry.RelatedObjectiveId, + entry.OccurredAtUtc)) + .ToList()); + + private static FactionStrategicStateSnapshot ToFactionStrategicStateSnapshot(FactionStrategicStateRuntime state) => new( + state.PlanCycle, + state.UpdatedAtUtc, + state.Status, + new FactionBudgetSnapshot( + state.Budget.ReservedCredits, + state.Budget.ExpansionCredits, + state.Budget.WarCredits, + state.Budget.ReservedMilitaryAssets, + state.Budget.ReservedLogisticsAssets, + state.Budget.ReservedConstructionAssets), + new FactionEconomicAssessmentSnapshot( + state.EconomicAssessment.PlanCycle, + state.EconomicAssessment.UpdatedAtUtc, + state.EconomicAssessment.MilitaryShipCount, + state.EconomicAssessment.MinerShipCount, + state.EconomicAssessment.TransportShipCount, + state.EconomicAssessment.ConstructorShipCount, + state.EconomicAssessment.ControlledSystemCount, + state.EconomicAssessment.TargetMilitaryShipCount, + state.EconomicAssessment.TargetMinerShipCount, + state.EconomicAssessment.TargetTransportShipCount, + state.EconomicAssessment.TargetConstructorShipCount, + state.EconomicAssessment.HasShipyard, + state.EconomicAssessment.HasWarIndustrySupplyChain, + state.EconomicAssessment.PrimaryExpansionSiteId, + state.EconomicAssessment.PrimaryExpansionSystemId, + NormalizeFiniteFloat(state.EconomicAssessment.ReplacementPressure), + NormalizeFiniteFloat(state.EconomicAssessment.SustainmentScore), + NormalizeFiniteFloat(state.EconomicAssessment.LogisticsSecurityScore), + state.EconomicAssessment.CriticalShortageCount, + state.EconomicAssessment.IndustrialBottleneckItemId, + state.EconomicAssessment.CommoditySignals.Select(signal => new FactionCommoditySignalSnapshot( + signal.ItemId, + NormalizeFiniteFloat(signal.AvailableStock), + NormalizeFiniteFloat(signal.OnHand), + NormalizeFiniteFloat(signal.ProductionRatePerSecond), + NormalizeFiniteFloat(signal.CommittedProductionRatePerSecond), + NormalizeFiniteFloat(signal.UsageRatePerSecond), + NormalizeFiniteFloat(signal.NetRatePerSecond), + NormalizeFiniteFloat(signal.ProjectedNetRatePerSecond), + NormalizeFiniteFloat(signal.LevelSeconds), + signal.Level, + NormalizeFiniteFloat(signal.ProjectedProductionRatePerSecond), + NormalizeFiniteFloat(signal.BuyBacklog), + NormalizeFiniteFloat(signal.ReservedForConstruction))).ToList()), + new FactionThreatAssessmentSnapshot( + state.ThreatAssessment.PlanCycle, + state.ThreatAssessment.UpdatedAtUtc, + state.ThreatAssessment.EnemyFactionCount, + state.ThreatAssessment.EnemyShipCount, + state.ThreatAssessment.EnemyStationCount, + state.ThreatAssessment.PrimaryThreatFactionId, + state.ThreatAssessment.PrimaryThreatSystemId, + state.ThreatAssessment.ThreatSignals.Select(signal => new FactionThreatSignalSnapshot( + signal.ScopeId, + signal.ScopeKind, + signal.EnemyShipCount, + signal.EnemyStationCount, + signal.EnemyFactionId)).ToList()), + state.Theaters.Select(theater => new FactionTheaterSnapshot( + theater.Id, + theater.Kind, + theater.SystemId, + theater.Status, + theater.Priority, + NormalizeFiniteFloat(theater.SupplyRisk), + NormalizeFiniteFloat(theater.FriendlyAssetValue), + theater.TargetFactionId, + theater.AnchorEntityId, + theater.AnchorPosition is null ? null : ToDto(theater.AnchorPosition.Value), + theater.UpdatedAtUtc, + theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Campaigns.Select(campaign => new FactionCampaignSnapshot( + campaign.Id, + campaign.Kind, + campaign.Status, + campaign.Priority, + campaign.TheaterId, + campaign.TargetFactionId, + campaign.TargetSystemId, + campaign.TargetEntityId, + campaign.CommodityId, + campaign.SupportStationId, + campaign.CurrentStepIndex, + campaign.CreatedAtUtc, + campaign.UpdatedAtUtc, + campaign.Summary, + campaign.PauseReason, + NormalizeFiniteFloat(campaign.ContinuationScore), + NormalizeFiniteFloat(campaign.SupplyAdequacy), + NormalizeFiniteFloat(campaign.ReplacementPressure), + campaign.FailureCount, + campaign.SuccessCount, + campaign.FleetCommanderId, + campaign.RequiresReinforcement, + campaign.Steps.Select(ToFactionPlanStepSnapshot).ToList(), + campaign.ObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Objectives.Select(objective => new FactionObjectiveSnapshot( + objective.Id, + objective.CampaignId, + objective.TheaterId, + objective.Kind, + objective.DelegationKind, + objective.BehaviorKind, + objective.Status, + objective.Priority, + objective.CommanderId, + objective.HomeSystemId, + objective.HomeStationId, + objective.TargetSystemId, + objective.TargetEntityId, + objective.TargetPosition is null ? null : ToDto(objective.TargetPosition.Value), + objective.ItemId, + objective.Notes, + objective.CurrentStepIndex, + objective.CreatedAtUtc, + objective.UpdatedAtUtc, + objective.UseOrders, + objective.StagingOrderKind, + objective.ReinforcementLevel, + objective.Steps.Select(ToFactionPlanStepSnapshot).ToList(), + objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Reservations.Select(reservation => new FactionReservationSnapshot( + reservation.Id, + reservation.ObjectiveId, + reservation.CampaignId, + reservation.AssetKind, + reservation.AssetId, + reservation.Priority, + reservation.CreatedAtUtc, + reservation.UpdatedAtUtc)).ToList(), + state.ProductionPrograms.Select(program => new FactionProductionProgramSnapshot( + program.Id, + program.Kind, + program.Status, + program.Priority, + program.CampaignId, + program.CommodityId, + program.ModuleId, + program.ShipKind, + program.TargetSystemId, + program.TargetCount, + program.CurrentCount, + program.Notes)).ToList()); + + private static FactionPlanStepSnapshot ToFactionPlanStepSnapshot(FactionPlanStepRuntime step) => new( + step.Id, + step.Kind, + step.Status, + step.Summary, + step.BlockingReason); + + private static IReadOnlyList ToFactionDecisionLogSnapshots(IReadOnlyCollection entries) => + entries .OrderBy(entry => entry.OccurredAtUtc) .ThenBy(entry => entry.Id, StringComparer.Ordinal) - .Select(entry => new FactionOutcomeRecordSnapshot( + .Select(entry => new FactionDecisionLogEntrySnapshot( entry.Id, entry.Kind, entry.Summary, - entry.RelatedCampaignId, - entry.RelatedObjectiveId, + entry.RelatedEntityId, + entry.PlanCycle, entry.OccurredAtUtc)) - .ToList()); + .ToList(); - private static FactionStrategicStateSnapshot ToFactionStrategicStateSnapshot(FactionStrategicStateRuntime state) => new( - state.PlanCycle, - state.UpdatedAtUtc, - state.Status, - new FactionBudgetSnapshot( - state.Budget.ReservedCredits, - state.Budget.ExpansionCredits, - state.Budget.WarCredits, - state.Budget.ReservedMilitaryAssets, - state.Budget.ReservedLogisticsAssets, - state.Budget.ReservedConstructionAssets), - new FactionEconomicAssessmentSnapshot( - state.EconomicAssessment.PlanCycle, - state.EconomicAssessment.UpdatedAtUtc, - state.EconomicAssessment.MilitaryShipCount, - state.EconomicAssessment.MinerShipCount, - state.EconomicAssessment.TransportShipCount, - state.EconomicAssessment.ConstructorShipCount, - state.EconomicAssessment.ControlledSystemCount, - state.EconomicAssessment.TargetMilitaryShipCount, - state.EconomicAssessment.TargetMinerShipCount, - state.EconomicAssessment.TargetTransportShipCount, - state.EconomicAssessment.TargetConstructorShipCount, - state.EconomicAssessment.HasShipyard, - state.EconomicAssessment.HasWarIndustrySupplyChain, - state.EconomicAssessment.PrimaryExpansionSiteId, - state.EconomicAssessment.PrimaryExpansionSystemId, - NormalizeFiniteFloat(state.EconomicAssessment.ReplacementPressure), - NormalizeFiniteFloat(state.EconomicAssessment.SustainmentScore), - NormalizeFiniteFloat(state.EconomicAssessment.LogisticsSecurityScore), - state.EconomicAssessment.CriticalShortageCount, - state.EconomicAssessment.IndustrialBottleneckItemId, - state.EconomicAssessment.CommoditySignals.Select(signal => new FactionCommoditySignalSnapshot( - signal.ItemId, - NormalizeFiniteFloat(signal.AvailableStock), - NormalizeFiniteFloat(signal.OnHand), - NormalizeFiniteFloat(signal.ProductionRatePerSecond), - NormalizeFiniteFloat(signal.CommittedProductionRatePerSecond), - NormalizeFiniteFloat(signal.UsageRatePerSecond), - NormalizeFiniteFloat(signal.NetRatePerSecond), - NormalizeFiniteFloat(signal.ProjectedNetRatePerSecond), - NormalizeFiniteFloat(signal.LevelSeconds), - signal.Level, - NormalizeFiniteFloat(signal.ProjectedProductionRatePerSecond), - NormalizeFiniteFloat(signal.BuyBacklog), - NormalizeFiniteFloat(signal.ReservedForConstruction))).ToList()), - new FactionThreatAssessmentSnapshot( - state.ThreatAssessment.PlanCycle, - state.ThreatAssessment.UpdatedAtUtc, - state.ThreatAssessment.EnemyFactionCount, - state.ThreatAssessment.EnemyShipCount, - state.ThreatAssessment.EnemyStationCount, - state.ThreatAssessment.PrimaryThreatFactionId, - state.ThreatAssessment.PrimaryThreatSystemId, - state.ThreatAssessment.ThreatSignals.Select(signal => new FactionThreatSignalSnapshot( - signal.ScopeId, - signal.ScopeKind, - signal.EnemyShipCount, - signal.EnemyStationCount, - signal.EnemyFactionId)).ToList()), - state.Theaters.Select(theater => new FactionTheaterSnapshot( - theater.Id, - theater.Kind, - theater.SystemId, - theater.Status, - theater.Priority, - NormalizeFiniteFloat(theater.SupplyRisk), - NormalizeFiniteFloat(theater.FriendlyAssetValue), - theater.TargetFactionId, - theater.AnchorEntityId, - theater.AnchorPosition is null ? null : ToDto(theater.AnchorPosition.Value), - theater.UpdatedAtUtc, - theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Campaigns.Select(campaign => new FactionCampaignSnapshot( - campaign.Id, - campaign.Kind, - campaign.Status, - campaign.Priority, - campaign.TheaterId, - campaign.TargetFactionId, - campaign.TargetSystemId, - campaign.TargetEntityId, - campaign.CommodityId, - campaign.SupportStationId, - campaign.CurrentStepIndex, - campaign.CreatedAtUtc, - campaign.UpdatedAtUtc, - campaign.Summary, - campaign.PauseReason, - NormalizeFiniteFloat(campaign.ContinuationScore), - NormalizeFiniteFloat(campaign.SupplyAdequacy), - NormalizeFiniteFloat(campaign.ReplacementPressure), - campaign.FailureCount, - campaign.SuccessCount, - campaign.FleetCommanderId, - campaign.RequiresReinforcement, - campaign.Steps.Select(ToFactionPlanStepSnapshot).ToList(), - campaign.ObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Objectives.Select(objective => new FactionObjectiveSnapshot( - objective.Id, - objective.CampaignId, - objective.TheaterId, - objective.Kind, - objective.DelegationKind, - objective.BehaviorKind, - objective.Status, - objective.Priority, - objective.CommanderId, - objective.HomeSystemId, - objective.HomeStationId, - objective.TargetSystemId, - objective.TargetEntityId, - objective.TargetPosition is null ? null : ToDto(objective.TargetPosition.Value), - objective.ItemId, - objective.Notes, - objective.CurrentStepIndex, - objective.CreatedAtUtc, - objective.UpdatedAtUtc, - objective.UseOrders, - objective.StagingOrderKind, - objective.ReinforcementLevel, - objective.Steps.Select(ToFactionPlanStepSnapshot).ToList(), - objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Reservations.Select(reservation => new FactionReservationSnapshot( - reservation.Id, - reservation.ObjectiveId, - reservation.CampaignId, - reservation.AssetKind, - reservation.AssetId, - reservation.Priority, - reservation.CreatedAtUtc, - reservation.UpdatedAtUtc)).ToList(), - state.ProductionPrograms.Select(program => new FactionProductionProgramSnapshot( - program.Id, - program.Kind, - program.Status, - program.Priority, - program.CampaignId, - program.CommodityId, - program.ModuleId, - program.ShipKind, - program.TargetSystemId, - program.TargetCount, - program.CurrentCount, - program.Notes)).ToList()); - - private static FactionPlanStepSnapshot ToFactionPlanStepSnapshot(FactionPlanStepRuntime step) => new( - step.Id, - step.Kind, - step.Status, - step.Summary, - step.BlockingReason); - - private static IReadOnlyList ToFactionDecisionLogSnapshots(IReadOnlyCollection entries) => - entries - .OrderBy(entry => entry.OccurredAtUtc) - .ThenBy(entry => entry.Id, StringComparer.Ordinal) - .Select(entry => new FactionDecisionLogEntrySnapshot( - entry.Id, - entry.Kind, - entry.Summary, - entry.RelatedEntityId, - entry.PlanCycle, - entry.OccurredAtUtc)) - .ToList(); - - private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player) - { - if (player is null) + private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player) { - return null; + if (player is null) + { + return null; + } + + return new PlayerFactionSnapshot( + player.Id, + player.Label, + player.SovereignFactionId, + player.Status, + player.CreatedAtUtc, + player.UpdatedAtUtc, + new PlayerAssetRegistrySnapshot( + player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()), + new PlayerStrategicIntentSnapshot( + player.StrategicIntent.StrategicPosture, + player.StrategicIntent.EconomicPosture, + player.StrategicIntent.MilitaryPosture, + player.StrategicIntent.LogisticsPosture, + player.StrategicIntent.DesiredReserveRatio, + player.StrategicIntent.AllowDelegatedCombatAutomation, + player.StrategicIntent.AllowDelegatedEconomicAutomation, + player.StrategicIntent.Notes), + player.Fleets.Select(fleet => new PlayerFleetSnapshot( + fleet.Id, + fleet.Label, + fleet.Status, + fleet.Role, + fleet.CommanderId, + fleet.FrontId, + fleet.HomeSystemId, + fleet.HomeStationId, + fleet.PolicyId, + fleet.AutomationPolicyId, + fleet.ReinforcementPolicyId, + fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.UpdatedAtUtc)).ToList(), + player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot( + taskForce.Id, + taskForce.Label, + taskForce.Status, + taskForce.Role, + taskForce.FleetId, + taskForce.CommanderId, + taskForce.FrontId, + taskForce.PolicyId, + taskForce.AutomationPolicyId, + taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.UpdatedAtUtc)).ToList(), + player.StationGroups.Select(group => new PlayerStationGroupSnapshot( + group.Id, + group.Label, + group.Status, + group.Role, + group.EconomicRegionId, + group.PolicyId, + group.AutomationPolicyId, + group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.UpdatedAtUtc)).ToList(), + player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot( + region.Id, + region.Label, + region.Status, + region.Role, + region.SharedEconomicRegionId, + region.PolicyId, + region.AutomationPolicyId, + region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.UpdatedAtUtc)).ToList(), + player.Fronts.Select(front => new PlayerFrontSnapshot( + front.Id, + front.Label, + front.Status, + front.Priority, + front.Posture, + front.SharedFrontLineId, + front.TargetFactionId, + front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.UpdatedAtUtc)).ToList(), + player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot( + reserve.Id, + reserve.Label, + reserve.Status, + reserve.ReserveKind, + reserve.HomeSystemId, + reserve.PolicyId, + reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.UpdatedAtUtc)).ToList(), + player.Policies.Select(policy => new PlayerFactionPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.PolicySetId, + policy.AllowDelegatedCombat, + policy.AllowDelegatedTrade, + policy.ReserveCreditsRatio, + policy.ReserveMilitaryRatio, + policy.TradeAccessPolicy, + policy.DockingAccessPolicy, + policy.ConstructionAccessPolicy, + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.Enabled, + policy.BehaviorKind, + policy.UseOrders, + policy.StagingOrderKind, + policy.MaxSystemRange, + policy.KnownStationsOnly, + policy.Radius, + policy.WaitSeconds, + policy.PreferredItemId, + policy.Notes, + policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + policy.UpdatedAtUtc)).ToList(), + player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.ShipKind, + policy.DesiredAssetCount, + policy.MinimumReserveCount, + policy.AutoTransferReserves, + policy.AutoQueueProduction, + policy.SourceReserveId, + policy.TargetFrontId, + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot( + program.Id, + program.Label, + program.Status, + program.Kind, + program.TargetShipKind, + program.TargetModuleId, + program.TargetItemId, + program.TargetCount, + program.CurrentCount, + program.StationGroupId, + program.ReinforcementPolicyId, + program.Notes, + program.UpdatedAtUtc)).ToList(), + player.Directives.Select(directive => new PlayerDirectiveSnapshot( + directive.Id, + directive.Label, + directive.Status, + directive.Kind, + directive.ScopeKind, + directive.ScopeId, + directive.TargetEntityId, + directive.TargetSystemId, + directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value), + directive.HomeSystemId, + directive.HomeStationId, + directive.SourceStationId, + directive.DestinationStationId, + directive.BehaviorKind, + directive.UseOrders, + directive.StagingOrderKind, + directive.ItemId, + directive.PreferredNodeId, + directive.PreferredConstructionSiteId, + directive.PreferredModuleId, + directive.Priority, + directive.Radius, + directive.WaitSeconds, + directive.MaxSystemRange, + directive.KnownStationsOnly, + directive.PatrolPoints.Select(ToDto).ToList(), + directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + directive.PolicyId, + directive.AutomationPolicyId, + directive.Notes, + directive.CreatedAtUtc, + directive.UpdatedAtUtc)).ToList(), + player.Assignments.Select(assignment => new PlayerAssignmentSnapshot( + assignment.Id, + assignment.AssetKind, + assignment.AssetId, + assignment.FleetId, + assignment.TaskForceId, + assignment.StationGroupId, + assignment.EconomicRegionId, + assignment.FrontId, + assignment.ReserveId, + assignment.DirectiveId, + assignment.PolicyId, + assignment.AutomationPolicyId, + assignment.Role, + assignment.Status, + assignment.UpdatedAtUtc)).ToList(), + player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedEntityKind, + entry.RelatedEntityId, + entry.OccurredAtUtc)).ToList(), + player.Alerts.Select(alert => new PlayerAlertSnapshot( + alert.Id, + alert.Kind, + alert.Severity, + alert.Summary, + alert.AssetKind, + alert.AssetId, + alert.RelatedDirectiveId, + alert.Status, + alert.CreatedAtUtc)).ToList()); } - return new PlayerFactionSnapshot( - player.Id, - player.Label, - player.SovereignFactionId, - player.Status, - player.CreatedAtUtc, - player.UpdatedAtUtc, - new PlayerAssetRegistrySnapshot( - player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()), - new PlayerStrategicIntentSnapshot( - player.StrategicIntent.StrategicPosture, - player.StrategicIntent.EconomicPosture, - player.StrategicIntent.MilitaryPosture, - player.StrategicIntent.LogisticsPosture, - player.StrategicIntent.DesiredReserveRatio, - player.StrategicIntent.AllowDelegatedCombatAutomation, - player.StrategicIntent.AllowDelegatedEconomicAutomation, - player.StrategicIntent.Notes), - player.Fleets.Select(fleet => new PlayerFleetSnapshot( - fleet.Id, - fleet.Label, - fleet.Status, - fleet.Role, - fleet.CommanderId, - fleet.FrontId, - fleet.HomeSystemId, - fleet.HomeStationId, - fleet.PolicyId, - fleet.AutomationPolicyId, - fleet.ReinforcementPolicyId, - fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - fleet.UpdatedAtUtc)).ToList(), - player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot( - taskForce.Id, - taskForce.Label, - taskForce.Status, - taskForce.Role, - taskForce.FleetId, - taskForce.CommanderId, - taskForce.FrontId, - taskForce.PolicyId, - taskForce.AutomationPolicyId, - taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - taskForce.UpdatedAtUtc)).ToList(), - player.StationGroups.Select(group => new PlayerStationGroupSnapshot( - group.Id, - group.Label, - group.Status, - group.Role, - group.EconomicRegionId, - group.PolicyId, - group.AutomationPolicyId, - group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - group.UpdatedAtUtc)).ToList(), - player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot( - region.Id, - region.Label, - region.Status, - region.Role, - region.SharedEconomicRegionId, - region.PolicyId, - region.AutomationPolicyId, - region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.UpdatedAtUtc)).ToList(), - player.Fronts.Select(front => new PlayerFrontSnapshot( - front.Id, - front.Label, - front.Status, - front.Priority, - front.Posture, - front.SharedFrontLineId, - front.TargetFactionId, - front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.UpdatedAtUtc)).ToList(), - player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot( - reserve.Id, - reserve.Label, - reserve.Status, - reserve.ReserveKind, - reserve.HomeSystemId, - reserve.PolicyId, - reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - reserve.UpdatedAtUtc)).ToList(), - player.Policies.Select(policy => new PlayerFactionPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.PolicySetId, - policy.AllowDelegatedCombat, - policy.AllowDelegatedTrade, - policy.ReserveCreditsRatio, - policy.ReserveMilitaryRatio, - policy.TradeAccessPolicy, - policy.DockingAccessPolicy, - policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy, - policy.CombatEngagementPolicy, - policy.AvoidHostileSystems, - policy.FleeHullRatio, - policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - policy.Notes, - policy.UpdatedAtUtc)).ToList(), - player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.Enabled, - policy.BehaviorKind, - policy.UseOrders, - policy.StagingOrderKind, - policy.MaxSystemRange, - policy.KnownStationsOnly, - policy.Radius, - policy.WaitSeconds, - policy.PreferredItemId, - policy.Notes, - policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), - policy.UpdatedAtUtc)).ToList(), - player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot( - policy.Id, - policy.Label, - policy.ScopeKind, - policy.ScopeId, - policy.ShipKind, - policy.DesiredAssetCount, - policy.MinimumReserveCount, - policy.AutoTransferReserves, - policy.AutoQueueProduction, - policy.SourceReserveId, - policy.TargetFrontId, - policy.Notes, - policy.UpdatedAtUtc)).ToList(), - player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot( - program.Id, - program.Label, - program.Status, - program.Kind, - program.TargetShipKind, - program.TargetModuleId, - program.TargetItemId, - program.TargetCount, - program.CurrentCount, - program.StationGroupId, - program.ReinforcementPolicyId, - program.Notes, - program.UpdatedAtUtc)).ToList(), - player.Directives.Select(directive => new PlayerDirectiveSnapshot( - directive.Id, - directive.Label, - directive.Status, - directive.Kind, - directive.ScopeKind, - directive.ScopeId, - directive.TargetEntityId, - directive.TargetSystemId, - directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value), - directive.HomeSystemId, - directive.HomeStationId, - directive.SourceStationId, - directive.DestinationStationId, - directive.BehaviorKind, - directive.UseOrders, - directive.StagingOrderKind, - directive.ItemId, - directive.PreferredNodeId, - directive.PreferredConstructionSiteId, - directive.PreferredModuleId, - directive.Priority, - directive.Radius, - directive.WaitSeconds, - directive.MaxSystemRange, - directive.KnownStationsOnly, - directive.PatrolPoints.Select(ToDto).ToList(), - directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), - directive.PolicyId, - directive.AutomationPolicyId, - directive.Notes, - directive.CreatedAtUtc, - directive.UpdatedAtUtc)).ToList(), - player.Assignments.Select(assignment => new PlayerAssignmentSnapshot( - assignment.Id, - assignment.AssetKind, - assignment.AssetId, - assignment.FleetId, - assignment.TaskForceId, - assignment.StationGroupId, - assignment.EconomicRegionId, - assignment.FrontId, - assignment.ReserveId, - assignment.DirectiveId, - assignment.PolicyId, - assignment.AutomationPolicyId, - assignment.Role, - assignment.Status, - assignment.UpdatedAtUtc)).ToList(), - player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot( - entry.Id, - entry.Kind, - entry.Summary, - entry.RelatedEntityKind, - entry.RelatedEntityId, - entry.OccurredAtUtc)).ToList(), - player.Alerts.Select(alert => new PlayerAlertSnapshot( - alert.Id, - alert.Kind, - alert.Severity, - alert.Summary, - alert.AssetKind, - alert.AssetId, - alert.RelatedDirectiveId, - alert.Status, - alert.CreatedAtUtc)).ToList()); - } - - private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state) - { - if (state is null) + private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state) { - return null; + if (state is null) + { + return null; + } + + return new GeopoliticalStateSnapshot( + state.Cycle, + state.UpdatedAtUtc, + state.Routes.Select(route => new SystemRouteLinkSnapshot( + route.Id, + route.SourceSystemId, + route.DestinationSystemId, + route.Distance, + route.IsPrimaryLane)).ToList(), + new DiplomaticStateSnapshot( + state.Diplomacy.Relations.Select(relation => new DiplomaticRelationSnapshot( + relation.Id, + relation.FactionAId, + relation.FactionBId, + relation.Status, + relation.Posture, + relation.TrustScore, + relation.TensionScore, + relation.GrievanceScore, + relation.TradeAccessPolicy, + relation.MilitaryAccessPolicy, + relation.WarStateId, + relation.CeasefireUntilUtc, + relation.UpdatedAtUtc, + relation.ActiveTreatyIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + relation.ActiveIncidentIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Treaties.Select(treaty => new TreatySnapshot( + treaty.Id, + treaty.Kind, + treaty.Status, + treaty.TradeAccessPolicy, + treaty.MilitaryAccessPolicy, + treaty.Summary, + treaty.CreatedAtUtc, + treaty.UpdatedAtUtc, + treaty.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Incidents.Select(incident => new DiplomaticIncidentSnapshot( + incident.Id, + incident.Kind, + incident.Status, + incident.SourceFactionId, + incident.TargetFactionId, + incident.SystemId, + incident.BorderEdgeId, + incident.Summary, + incident.Severity, + incident.EscalationScore, + incident.CreatedAtUtc, + incident.LastObservedAtUtc)).ToList(), + state.Diplomacy.BorderTensions.Select(tension => new BorderTensionSnapshot( + tension.Id, + tension.RelationId, + tension.BorderEdgeId, + tension.FactionAId, + tension.FactionBId, + tension.Status, + tension.TensionScore, + tension.IncidentScore, + tension.MilitaryPressure, + tension.AccessFriction, + tension.UpdatedAtUtc, + tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Wars.Select(war => new WarStateSnapshot( + war.Id, + war.RelationId, + war.FactionAId, + war.FactionBId, + war.Status, + war.WarGoal, + war.EscalationScore, + war.StartedAtUtc, + war.CeasefireUntilUtc, + war.UpdatedAtUtc, + war.ActiveFrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList()), + new TerritoryStateSnapshot( + state.Territory.Claims.Select(claim => new TerritoryClaimSnapshot( + claim.Id, + claim.SourceClaimId, + claim.FactionId, + claim.SystemId, + claim.CelestialId, + claim.Status, + claim.ClaimKind, + claim.ClaimStrength, + claim.UpdatedAtUtc)).ToList(), + state.Territory.Influences.Select(influence => new TerritoryInfluenceSnapshot( + influence.Id, + influence.SystemId, + influence.FactionId, + influence.ClaimStrength, + influence.AssetStrength, + influence.LogisticsStrength, + influence.TotalInfluence, + influence.IsContesting, + influence.UpdatedAtUtc)).ToList(), + state.Territory.ControlStates.Select(control => new TerritoryControlStateSnapshot( + control.SystemId, + control.ControllerFactionId, + control.PrimaryClaimantFactionId, + control.ControlKind, + control.IsContested, + control.ControlScore, + control.StrategicValue, + control.ClaimantFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + control.InfluencingFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + control.UpdatedAtUtc)).ToList(), + state.Territory.StrategicProfiles.Select(profile => new SectorStrategicProfileSnapshot( + profile.SystemId, + profile.ControllerFactionId, + profile.ZoneKind, + profile.IsContested, + profile.StrategicValue, + profile.SecurityRating, + profile.TerritorialPressure, + profile.LogisticsValue, + profile.EconomicRegionId, + profile.FrontLineId, + profile.UpdatedAtUtc)).ToList(), + state.Territory.BorderEdges.Select(edge => new BorderEdgeSnapshot( + edge.Id, + edge.SourceSystemId, + edge.DestinationSystemId, + edge.SourceFactionId, + edge.DestinationFactionId, + edge.IsContested, + edge.RelationId, + edge.TensionScore, + edge.CorridorImportance, + edge.UpdatedAtUtc)).ToList(), + state.Territory.FrontLines.Select(front => new FrontLineSnapshot( + front.Id, + front.Kind, + front.Status, + front.AnchorSystemId, + front.PressureScore, + front.SupplyRisk, + front.UpdatedAtUtc, + front.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Territory.Zones.Select(zone => new TerritoryZoneSnapshot( + zone.Id, + zone.SystemId, + zone.FactionId, + zone.Kind, + zone.Status, + zone.Reason, + zone.UpdatedAtUtc)).ToList(), + state.Territory.Pressures.Select(pressure => new TerritoryPressureSnapshot( + pressure.Id, + pressure.SystemId, + pressure.FactionId, + pressure.Kind, + pressure.PressureScore, + pressure.SecurityScore, + pressure.HostileInfluence, + pressure.CorridorRisk, + pressure.UpdatedAtUtc)).ToList()), + new EconomyRegionStateSnapshot( + state.EconomyRegions.Regions.Select(region => new EconomicRegionSnapshot( + region.Id, + region.FactionId, + region.Label, + region.Kind, + region.Status, + region.CoreSystemId, + region.UpdatedAtUtc, + region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.FrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.CorridorIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.SupplyNetworks.Select(network => new SupplyNetworkSnapshot( + network.Id, + network.RegionId, + network.ThroughputScore, + network.RiskScore, + network.UpdatedAtUtc, + network.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ProducerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ConsumerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ConstructionItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.Corridors.Select(corridor => new LogisticsCorridorSnapshot( + corridor.Id, + corridor.FactionId, + corridor.Kind, + corridor.Status, + corridor.RiskScore, + corridor.ThroughputScore, + corridor.AccessState, + corridor.UpdatedAtUtc, + corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + corridor.RegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + corridor.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.ProductionProfiles.Select(profile => new RegionalProductionProfileSnapshot( + profile.RegionId, + profile.PrimaryIndustry, + profile.ShipyardCount, + profile.StationCount, + profile.UpdatedAtUtc, + profile.ProducedItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + profile.ScarceItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.TradeBalances.Select(balance => new RegionalTradeBalanceSnapshot( + balance.RegionId, + balance.ImportsRequiredCount, + balance.ExportsSurplusCount, + balance.CriticalShortageCount, + balance.NetTradeScore, + balance.UpdatedAtUtc)).ToList(), + state.EconomyRegions.Bottlenecks.Select(bottleneck => new RegionalBottleneckSnapshot( + bottleneck.Id, + bottleneck.RegionId, + bottleneck.ItemId, + bottleneck.Cause, + bottleneck.Status, + bottleneck.Severity, + bottleneck.UpdatedAtUtc)).ToList(), + state.EconomyRegions.SecurityAssessments.Select(assessment => new RegionalSecurityAssessmentSnapshot( + assessment.RegionId, + assessment.SupplyRisk, + assessment.BorderPressure, + assessment.ActiveWarCount, + assessment.HostileRelationCount, + assessment.AccessFriction, + assessment.UpdatedAtUtc)).ToList(), + state.EconomyRegions.EconomicAssessments.Select(assessment => new RegionalEconomicAssessmentSnapshot( + assessment.RegionId, + assessment.SustainmentScore, + assessment.ProductionDepth, + assessment.ConstructionPressure, + assessment.CorridorDependency, + assessment.UpdatedAtUtc)).ToList())); } - return new GeopoliticalStateSnapshot( - state.Cycle, - state.UpdatedAtUtc, - state.Routes.Select(route => new SystemRouteLinkSnapshot( - route.Id, - route.SourceSystemId, - route.DestinationSystemId, - route.Distance, - route.IsPrimaryLane)).ToList(), - new DiplomaticStateSnapshot( - state.Diplomacy.Relations.Select(relation => new DiplomaticRelationSnapshot( - relation.Id, - relation.FactionAId, - relation.FactionBId, - relation.Status, - relation.Posture, - relation.TrustScore, - relation.TensionScore, - relation.GrievanceScore, - relation.TradeAccessPolicy, - relation.MilitaryAccessPolicy, - relation.WarStateId, - relation.CeasefireUntilUtc, - relation.UpdatedAtUtc, - relation.ActiveTreatyIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - relation.ActiveIncidentIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Diplomacy.Treaties.Select(treaty => new TreatySnapshot( - treaty.Id, - treaty.Kind, - treaty.Status, - treaty.TradeAccessPolicy, - treaty.MilitaryAccessPolicy, - treaty.Summary, - treaty.CreatedAtUtc, - treaty.UpdatedAtUtc, - treaty.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Diplomacy.Incidents.Select(incident => new DiplomaticIncidentSnapshot( - incident.Id, - incident.Kind, - incident.Status, - incident.SourceFactionId, - incident.TargetFactionId, - incident.SystemId, - incident.BorderEdgeId, - incident.Summary, - incident.Severity, - incident.EscalationScore, - incident.CreatedAtUtc, - incident.LastObservedAtUtc)).ToList(), - state.Diplomacy.BorderTensions.Select(tension => new BorderTensionSnapshot( - tension.Id, - tension.RelationId, - tension.BorderEdgeId, - tension.FactionAId, - tension.FactionBId, - tension.Status, - tension.TensionScore, - tension.IncidentScore, - tension.MilitaryPressure, - tension.AccessFriction, - tension.UpdatedAtUtc, - tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Diplomacy.Wars.Select(war => new WarStateSnapshot( - war.Id, - war.RelationId, - war.FactionAId, - war.FactionBId, - war.Status, - war.WarGoal, - war.EscalationScore, - war.StartedAtUtc, - war.CeasefireUntilUtc, - war.UpdatedAtUtc, - war.ActiveFrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList()), - new TerritoryStateSnapshot( - state.Territory.Claims.Select(claim => new TerritoryClaimSnapshot( - claim.Id, - claim.SourceClaimId, - claim.FactionId, - claim.SystemId, - claim.CelestialId, - claim.Status, - claim.ClaimKind, - claim.ClaimStrength, - claim.UpdatedAtUtc)).ToList(), - state.Territory.Influences.Select(influence => new TerritoryInfluenceSnapshot( - influence.Id, - influence.SystemId, - influence.FactionId, - influence.ClaimStrength, - influence.AssetStrength, - influence.LogisticsStrength, - influence.TotalInfluence, - influence.IsContesting, - influence.UpdatedAtUtc)).ToList(), - state.Territory.ControlStates.Select(control => new TerritoryControlStateSnapshot( - control.SystemId, - control.ControllerFactionId, - control.PrimaryClaimantFactionId, - control.ControlKind, - control.IsContested, - control.ControlScore, - control.StrategicValue, - control.ClaimantFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - control.InfluencingFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - control.UpdatedAtUtc)).ToList(), - state.Territory.StrategicProfiles.Select(profile => new SectorStrategicProfileSnapshot( - profile.SystemId, - profile.ControllerFactionId, - profile.ZoneKind, - profile.IsContested, - profile.StrategicValue, - profile.SecurityRating, - profile.TerritorialPressure, - profile.LogisticsValue, - profile.EconomicRegionId, - profile.FrontLineId, - profile.UpdatedAtUtc)).ToList(), - state.Territory.BorderEdges.Select(edge => new BorderEdgeSnapshot( - edge.Id, - edge.SourceSystemId, - edge.DestinationSystemId, - edge.SourceFactionId, - edge.DestinationFactionId, - edge.IsContested, - edge.RelationId, - edge.TensionScore, - edge.CorridorImportance, - edge.UpdatedAtUtc)).ToList(), - state.Territory.FrontLines.Select(front => new FrontLineSnapshot( - front.Id, - front.Kind, - front.Status, - front.AnchorSystemId, - front.PressureScore, - front.SupplyRisk, - front.UpdatedAtUtc, - front.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - front.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.Territory.Zones.Select(zone => new TerritoryZoneSnapshot( - zone.Id, - zone.SystemId, - zone.FactionId, - zone.Kind, - zone.Status, - zone.Reason, - zone.UpdatedAtUtc)).ToList(), - state.Territory.Pressures.Select(pressure => new TerritoryPressureSnapshot( - pressure.Id, - pressure.SystemId, - pressure.FactionId, - pressure.Kind, - pressure.PressureScore, - pressure.SecurityScore, - pressure.HostileInfluence, - pressure.CorridorRisk, - pressure.UpdatedAtUtc)).ToList()), - new EconomyRegionStateSnapshot( - state.EconomyRegions.Regions.Select(region => new EconomicRegionSnapshot( - region.Id, - region.FactionId, - region.Label, - region.Kind, - region.Status, - region.CoreSystemId, - region.UpdatedAtUtc, - region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.FrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - region.CorridorIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.EconomyRegions.SupplyNetworks.Select(network => new SupplyNetworkSnapshot( - network.Id, - network.RegionId, - network.ThroughputScore, - network.RiskScore, - network.UpdatedAtUtc, - network.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - network.ProducerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - network.ConsumerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - network.ConstructionItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.EconomyRegions.Corridors.Select(corridor => new LogisticsCorridorSnapshot( - corridor.Id, - corridor.FactionId, - corridor.Kind, - corridor.Status, - corridor.RiskScore, - corridor.ThroughputScore, - corridor.AccessState, - corridor.UpdatedAtUtc, - corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - corridor.RegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - corridor.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.EconomyRegions.ProductionProfiles.Select(profile => new RegionalProductionProfileSnapshot( - profile.RegionId, - profile.PrimaryIndustry, - profile.ShipyardCount, - profile.StationCount, - profile.UpdatedAtUtc, - profile.ProducedItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), - profile.ScarceItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), - state.EconomyRegions.TradeBalances.Select(balance => new RegionalTradeBalanceSnapshot( - balance.RegionId, - balance.ImportsRequiredCount, - balance.ExportsSurplusCount, - balance.CriticalShortageCount, - balance.NetTradeScore, - balance.UpdatedAtUtc)).ToList(), - state.EconomyRegions.Bottlenecks.Select(bottleneck => new RegionalBottleneckSnapshot( - bottleneck.Id, - bottleneck.RegionId, - bottleneck.ItemId, - bottleneck.Cause, - bottleneck.Status, - bottleneck.Severity, - bottleneck.UpdatedAtUtc)).ToList(), - state.EconomyRegions.SecurityAssessments.Select(assessment => new RegionalSecurityAssessmentSnapshot( - assessment.RegionId, - assessment.SupplyRisk, - assessment.BorderPressure, - assessment.ActiveWarCount, - assessment.HostileRelationCount, - assessment.AccessFriction, - assessment.UpdatedAtUtc)).ToList(), - state.EconomyRegions.EconomicAssessments.Select(assessment => new RegionalEconomicAssessmentSnapshot( - assessment.RegionId, - assessment.SustainmentScore, - assessment.ProductionDepth, - assessment.ConstructionPressure, - assessment.CorridorDependency, - assessment.UpdatedAtUtc)).ToList())); - } + private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( + state.SpaceLayer, + state.CurrentSystemId, + state.CurrentCelestialId, + state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value), + state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value), + state.MovementRegime, + state.DestinationNodeId, + state.Transit is null ? null : new ShipTransitSnapshot( + state.Transit.Regime, + state.Transit.OriginNodeId, + state.Transit.DestinationNodeId, + state.Transit.StartedAtUtc, + state.Transit.ArrivalDueAtUtc, + state.Transit.Progress)); - private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( - state.SpaceLayer, - state.CurrentSystemId, - state.CurrentCelestialId, - state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value), - state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value), - state.MovementRegime, - state.DestinationNodeId, - state.Transit is null ? null : new ShipTransitSnapshot( - state.Transit.Regime, - state.Transit.OriginNodeId, - state.Transit.DestinationNodeId, - state.Transit.StartedAtUtc, - state.Transit.ArrivalDueAtUtc, - state.Transit.Progress)); + private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); - private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); - - private static float NormalizeFiniteFloat(float value) => - float.IsFinite(value) ? value : -1f; + private static float NormalizeFiniteFloat(float value) => + float.IsFinite(value) ? value : -1f; } diff --git a/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs b/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs index c7a303b..31976bf 100644 --- a/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/ConstructionRuntimeModels.cs @@ -2,35 +2,35 @@ namespace SpaceGame.Api.Stations.Runtime; public sealed class ClaimRuntime { - public required string Id { get; init; } - public required string FactionId { get; init; } - public required string SystemId { get; init; } - public required string CelestialId { get; init; } - public string? CommanderId { get; set; } - public DateTimeOffset PlacedAtUtc { get; init; } - public DateTimeOffset ActivatesAtUtc { get; set; } - public string State { get; set; } = ClaimStateKinds.Placed; - public float Health { get; set; } - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string FactionId { get; init; } + public required string SystemId { get; init; } + public required string CelestialId { get; init; } + public string? CommanderId { get; set; } + public DateTimeOffset PlacedAtUtc { get; init; } + public DateTimeOffset ActivatesAtUtc { get; set; } + public string State { get; set; } = ClaimStateKinds.Placed; + public float Health { get; set; } + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class ConstructionSiteRuntime { - public required string Id { get; init; } - public required string FactionId { get; init; } - public required string SystemId { get; init; } - public required string CelestialId { get; init; } - public required string TargetKind { get; init; } - public required string TargetDefinitionId { get; init; } - public string? BlueprintId { get; set; } - public string? ClaimId { get; set; } - public string? StationId { get; set; } - public Dictionary Inventory { get; } = new(StringComparer.Ordinal); - public Dictionary RequiredItems { get; } = new(StringComparer.Ordinal); - public Dictionary DeliveredItems { get; } = new(StringComparer.Ordinal); - public HashSet AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal); - public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); - public float Progress { get; set; } - public string State { get; set; } = ConstructionSiteStateKinds.Planned; - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string FactionId { get; init; } + public required string SystemId { get; init; } + public required string CelestialId { get; init; } + public required string TargetKind { get; init; } + public required string TargetDefinitionId { get; init; } + public string? BlueprintId { get; set; } + public string? ClaimId { get; set; } + public string? StationId { get; set; } + public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public Dictionary RequiredItems { get; } = new(StringComparer.Ordinal); + public Dictionary DeliveredItems { get; } = new(StringComparer.Ordinal); + public HashSet AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal); + public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); + public float Progress { get; set; } + public string State { get; set; } = ConstructionSiteStateKinds.Planned; + public string LastDeltaSignature { get; set; } = string.Empty; } diff --git a/apps/backend/Stations/Runtime/StationRuntimeModels.cs b/apps/backend/Stations/Runtime/StationRuntimeModels.cs index 62728ff..ffb0d35 100644 --- a/apps/backend/Stations/Runtime/StationRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/StationRuntimeModels.cs @@ -2,49 +2,49 @@ namespace SpaceGame.Api.Stations.Runtime; public sealed class StationRuntime { - public required string Id { get; init; } - public required string SystemId { get; init; } - public required string Label { get; set; } - public string Category { get; set; } = "station"; - public string Objective { get; set; } = "general"; - public string Color { get; set; } = "#8df0d2"; - public required Vector3 Position { get; set; } - public float Radius { get; set; } = 24f; - public required string FactionId { get; init; } - public string? CelestialId { get; set; } - public string? CommanderId { get; set; } - public string? PolicySetId { get; set; } - public List Modules { get; } = []; - public float Health { get; set; } = 600f; - public float MaxHealth { get; set; } = 600f; - public IEnumerable InstalledModules => Modules.Select((module) => module.ModuleId); - public Dictionary Inventory { get; } = new(StringComparer.Ordinal); - public Dictionary ProductionLaneTimers { get; } = new(StringComparer.Ordinal); - public Dictionary DockingPadAssignments { get; } = new(); - public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); - public float Population { get; set; } - public float PopulationCapacity { get; set; } - public float WorkforceRequired { get; set; } - public float WorkforceEffectiveRatio { get; set; } = 0.1f; - public float PopulationGrowthProgress { get; set; } - public float ShipProductionProgressSeconds { get; set; } - public HashSet DockedShipIds { get; } = []; - public ModuleConstructionRuntime? ActiveConstruction { get; set; } - public string LastDeltaSignature { get; set; } = string.Empty; + public required string Id { get; init; } + public required string SystemId { get; init; } + public required string Label { get; set; } + public string Category { get; set; } = "station"; + public string Objective { get; set; } = "general"; + public string Color { get; set; } = "#8df0d2"; + public required Vector3 Position { get; set; } + public float Radius { get; set; } = 24f; + public required string FactionId { get; init; } + public string? CelestialId { get; set; } + public string? CommanderId { get; set; } + public string? PolicySetId { get; set; } + public List Modules { get; } = []; + public float Health { get; set; } = 600f; + public float MaxHealth { get; set; } = 600f; + public IEnumerable InstalledModules => Modules.Select((module) => module.ModuleId); + public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public Dictionary ProductionLaneTimers { get; } = new(StringComparer.Ordinal); + public Dictionary DockingPadAssignments { get; } = new(); + public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); + public float Population { get; set; } + public float PopulationCapacity { get; set; } + public float WorkforceRequired { get; set; } + public float WorkforceEffectiveRatio { get; set; } = 0.1f; + public float PopulationGrowthProgress { get; set; } + public float ShipProductionProgressSeconds { get; set; } + public HashSet DockedShipIds { get; } = []; + public ModuleConstructionRuntime? ActiveConstruction { get; set; } + public string LastDeltaSignature { get; set; } = string.Empty; } public sealed class StationModuleRuntime { - public required string Id { get; init; } - public required string ModuleId { get; init; } - public float Health { get; set; } - public float MaxHealth { get; set; } + public required string Id { get; init; } + public required string ModuleId { get; init; } + public float Health { get; set; } + public float MaxHealth { get; set; } } public sealed class ModuleConstructionRuntime { - public required string ModuleId { get; init; } - public float ProgressSeconds { get; set; } - public float RequiredSeconds { get; init; } - public string AssignedConstructorShipId { get; set; } = string.Empty; + public required string ModuleId { get; init; } + public float ProgressSeconds { get; set; } + public float RequiredSeconds { get; init; } + public string AssignedConstructorShipId { get; set; } = string.Empty; } diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index b94625f..568c305 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -4,906 +4,906 @@ namespace SpaceGame.Api.Stations.Simulation; internal sealed class InfrastructureSimulationService { - private const float CommodityTargetLevelSeconds = 240f; - private const float EnergyTargetLevelSeconds = 240f; + private const float CommodityTargetLevelSeconds = 240f; + private const float EnergyTargetLevelSeconds = 240f; - internal void UpdateClaims(SimulationWorld world, ICollection events) - { - foreach (var claim in world.Claims) + internal void UpdateClaims(SimulationWorld world, ICollection events) { - if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f) - { - if (claim.State != ClaimStateKinds.Destroyed) + foreach (var claim in world.Claims) { - claim.State = ClaimStateKinds.Destroyed; - events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc)); + if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f) + { + if (claim.State != ClaimStateKinds.Destroyed) + { + claim.State = ClaimStateKinds.Destroyed; + events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc)); + } + + foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id)) + { + site.State = ConstructionSiteStateKinds.Destroyed; + } + + continue; + } + + if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc) + { + claim.State = ClaimStateKinds.Active; + events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc)); + } + } + } + + internal void UpdateConstructionSites(SimulationWorld world, ICollection events) + { + foreach (var site in world.ConstructionSites) + { + if (site.State == ConstructionSiteStateKinds.Destroyed) + { + continue; + } + + var claim = site.ClaimId is null + ? null + : world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId); + if (claim?.State == ClaimStateKinds.Destroyed) + { + site.State = ConstructionSiteStateKinds.Destroyed; + continue; + } + + if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned) + { + site.State = ConstructionSiteStateKinds.Active; + events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc)); + } + + foreach (var orderId in site.MarketOrderIds) + { + var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); + if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required)) + { + continue; + } + + var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId)); + order.RemainingAmount = remaining; + order.State = remaining <= 0.01f + ? MarketOrderStateKinds.Filled + : remaining < order.Amount + ? MarketOrderStateKinds.PartiallyFilled + : MarketOrderStateKinds.Open; + } + } + } + + internal static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) + { + if (station.ActiveConstruction is not null) + { + return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) + && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); } - foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id)) + if (!CanStartModuleConstruction(station, recipe)) { - site.State = ConstructionSiteStateKinds.Destroyed; + return false; } - continue; - } - - if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc) - { - claim.State = ClaimStateKinds.Active; - events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc)); - } - } - } - - internal void UpdateConstructionSites(SimulationWorld world, ICollection events) - { - foreach (var site in world.ConstructionSites) - { - if (site.State == ConstructionSiteStateKinds.Destroyed) - { - continue; - } - - var claim = site.ClaimId is null - ? null - : world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId); - if (claim?.State == ClaimStateKinds.Destroyed) - { - site.State = ConstructionSiteStateKinds.Destroyed; - continue; - } - - if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned) - { - site.State = ConstructionSiteStateKinds.Active; - events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc)); - } - - foreach (var orderId in site.MarketOrderIds) - { - var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); - if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required)) + foreach (var input in recipe.Inputs) { - continue; + RemoveInventory(station.Inventory, input.ItemId, input.Amount); } - var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId)); - order.RemainingAmount = remaining; - order.State = remaining <= 0.01f - ? MarketOrderStateKinds.Filled - : remaining < order.Amount - ? MarketOrderStateKinds.PartiallyFilled - : MarketOrderStateKinds.Open; - } - } - } - - internal static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) - { - if (station.ActiveConstruction is not null) - { - return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) - && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); - } - - if (!CanStartModuleConstruction(station, recipe)) - { - return false; - } - - foreach (var input in recipe.Inputs) - { - RemoveInventory(station.Inventory, input.ItemId, input.Amount); - } - - station.ActiveConstruction = new ModuleConstructionRuntime - { - ModuleId = recipe.ModuleId, - RequiredSeconds = recipe.Duration, - AssignedConstructorShipId = shipId, - }; - - return true; - } - - internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) - { - var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); - return GetModuleExpansionCandidates(world, station, economy) - .Where(candidate => world.ModuleRecipes.ContainsKey(candidate.ModuleId)) - .OrderByDescending(candidate => candidate.Score) - .Select(candidate => candidate.ModuleId) - .FirstOrDefault(); - } - - private static IReadOnlyList GetModuleExpansionCandidates( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy) - { - var role = StationSimulationService.DetermineStationRole(station); - var candidates = new Dictionary(StringComparer.Ordinal); - var constructionDemandByItem = GetOutstandingConstructionDemand(world, station.FactionId); - var objectiveCommodity = GetObjectiveCommodityId(role); - var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodity); - - if (objectiveModuleId is not null && world.ModuleRecipes.TryGetValue(objectiveModuleId, out var objectiveRecipe)) - { - AddOrRaiseCandidate(candidates, objectiveModuleId, ScoreObjectiveModule(world, station, economy, constructionDemandByItem, objectiveCommodity, objectiveModuleId)); - - foreach (var storageModuleId in GetRequiredStorageModules(world, objectiveRecipe)) - { - if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) + station.ActiveConstruction = new ModuleConstructionRuntime { - AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: true)); + ModuleId = recipe.ModuleId, + RequiredSeconds = recipe.Duration, + AssignedConstructorShipId = shipId, + }; + + return true; + } + + internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) + { + var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); + return GetModuleExpansionCandidates(world, station, economy) + .Where(candidate => world.ModuleRecipes.ContainsKey(candidate.ModuleId)) + .OrderByDescending(candidate => candidate.Score) + .Select(candidate => candidate.ModuleId) + .FirstOrDefault(); + } + + private static IReadOnlyList GetModuleExpansionCandidates( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy) + { + var role = StationSimulationService.DetermineStationRole(station); + var candidates = new Dictionary(StringComparer.Ordinal); + var constructionDemandByItem = GetOutstandingConstructionDemand(world, station.FactionId); + var objectiveCommodity = GetObjectiveCommodityId(role); + var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodity); + + if (objectiveModuleId is not null && world.ModuleRecipes.TryGetValue(objectiveModuleId, out var objectiveRecipe)) + { + AddOrRaiseCandidate(candidates, objectiveModuleId, ScoreObjectiveModule(world, station, economy, constructionDemandByItem, objectiveCommodity, objectiveModuleId)); + + foreach (var storageModuleId in GetRequiredStorageModules(world, objectiveRecipe)) + { + if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) + { + AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: true)); + } + } + + if (objectiveCommodity is not null + && world.ProductionGraph.GetImmediateInputs(objectiveCommodity).Contains("energycells", StringComparer.Ordinal)) + { + AddOrRaiseCandidate(candidates, "module_gen_prod_energycells_01", ScoreEnergySupportModule(world, station, economy, constructionDemandByItem)); + } } - } - if (objectiveCommodity is not null - && world.ProductionGraph.GetImmediateInputs(objectiveCommodity).Contains("energycells", StringComparer.Ordinal)) - { - AddOrRaiseCandidate(candidates, "module_gen_prod_energycells_01", ScoreEnergySupportModule(world, station, economy, constructionDemandByItem)); - } + AddOrRaiseCandidate(candidates, "module_arg_dock_m_01_lowtech", ScoreDockModule(station)); + AddOrRaiseCandidate(candidates, "module_arg_hab_m_01", ScoreHabitationModule(station, world, economy)); + + foreach (var storageModuleId in GetStoragePressureCandidates(world, station)) + { + AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: false)); + } + + return candidates + .Where(entry => entry.Value > 0.01f) + .Select(entry => new ModuleExpansionCandidate(entry.Key, entry.Value)) + .ToList(); } - AddOrRaiseCandidate(candidates, "module_arg_dock_m_01_lowtech", ScoreDockModule(station)); - AddOrRaiseCandidate(candidates, "module_arg_hab_m_01", ScoreHabitationModule(station, world, economy)); - - foreach (var storageModuleId in GetStoragePressureCandidates(world, station)) - { - AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: false)); - } - - return candidates - .Where(entry => entry.Value > 0.01f) - .Select(entry => new ModuleExpansionCandidate(entry.Key, entry.Value)) - .ToList(); - } - - private static IEnumerable GetStoragePressureCandidates(SimulationWorld world, StationRuntime station) - { - foreach (var (storageClass, moduleId) in new[] + private static IEnumerable GetStoragePressureCandidates(SimulationWorld world, StationRuntime station) { + foreach (var (storageClass, moduleId) in new[] + { ("solid", "module_arg_stor_solid_m_01"), ("liquid", "module_arg_stor_liquid_m_01"), ("container", "module_arg_stor_container_m_01"), }) - { - var capacity = GetStationStorageCapacity(station, storageClass); - if (capacity <= 0.01f) - { - continue; - } - - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) - .Sum(entry => entry.Value); - if (used / capacity >= 0.65f) - { - yield return moduleId; - } - } - } - - private static IEnumerable GetRequiredStorageModules(SimulationWorld world, ModuleRecipeDefinition recipe) - { - var itemIds = recipe.Inputs.Select(input => input.ItemId); - foreach (var itemId in itemIds) - { - if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - continue; - } - - if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) - { - yield return storageModuleId; - } - else - { - yield return "module_arg_stor_container_m_01"; - } - } - - if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition)) - { - foreach (var productItemId in moduleDefinition.Products) - { - if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition)) { - continue; + var capacity = GetStationStorageCapacity(station, storageClass); + if (capacity <= 0.01f) + { + continue; + } + + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Sum(entry => entry.Value); + if (used / capacity >= 0.65f) + { + yield return moduleId; + } + } + } + + private static IEnumerable GetRequiredStorageModules(SimulationWorld world, ModuleRecipeDefinition recipe) + { + var itemIds = recipe.Inputs.Select(input => input.ItemId); + foreach (var itemId in itemIds) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + continue; + } + + if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + { + yield return storageModuleId; + } + else + { + yield return "module_arg_stor_container_m_01"; + } } - if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition)) { - yield return storageModuleId; + foreach (var productItemId in moduleDefinition.Products) + { + if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition)) + { + continue; + } + + if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + { + yield return storageModuleId; + } + else + { + yield return "module_arg_stor_container_m_01"; + } + } } - else + } + + private static string? GetObjectiveCommodityId(string role) => + role switch { - yield return "module_arg_stor_container_m_01"; - } - } - } - } - - private static string? GetObjectiveCommodityId(string role) => - role switch - { - "power" => "energycells", - "refinery" => "refinedmetals", - "water" => "water", - "graphene" => "graphene", - "siliconwafers" => "siliconwafers", - "hullparts" => "hullparts", - "claytronics" => "claytronics", - "quantumtubes" => "quantumtubes", - "antimattercells" => "antimattercells", - "superfluidcoolant" => "superfluidcoolant", - _ => null, - }; - - private static string? GetObjectiveModuleId(SimulationWorld world, string role, string? objectiveCommodityId) => - role switch - { - "shipyard" => "module_gen_build_l_01", - _ => objectiveCommodityId is null ? null : world.ProductionGraph.GetPrimaryProducerModule(objectiveCommodityId), - }; - - private static float ScoreObjectiveModule( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - IReadOnlyDictionary constructionDemandByItem, - string? objectiveCommodityId, - string objectiveModuleId) - { - if (string.IsNullOrWhiteSpace(objectiveCommodityId)) - { - var hasShipyard = CountModules(station.InstalledModules, objectiveModuleId); - return hasShipyard == 0 ? 240f : 0f; - } - - var commodity = economy.GetCommodity(objectiveCommodityId); - var currentCount = CountModules(station.InstalledModules, objectiveModuleId); - var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId); - var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem); - var score = 90f - + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(objectiveCommodityId)) - + (marginalOutputRate * 900f) - + constructionImpact; - - if (currentCount == 0) - { - score += 80f; - } - - if (commodity.LevelSeconds < GetTargetLevelSeconds(objectiveCommodityId)) - { - score += MathF.Max(0f, GetTargetLevelSeconds(objectiveCommodityId) - commodity.LevelSeconds) * 0.3f; - } - - score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId); - score *= EstimateProducerReadiness(world, station, economy, objectiveModuleId, objectiveCommodityId); - score += EstimateImmediateProducerActivationScore(world, station, economy, objectiveModuleId, objectiveCommodityId); - return score - (currentCount * 35f); - } - - private static float ScoreEnergySupportModule( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - IReadOnlyDictionary constructionDemandByItem) - { - var energy = economy.GetCommodity("energycells"); - var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01"); - var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem); - var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01"); - var score = 40f - + CommodityOperationalSignal.ComputeNeedScore(energy, EnergyTargetLevelSeconds) * 0.5f - + constructionImpact - + readinessUnlock; - - if (currentCount == 0) - { - score += 70f; - } - - if (energy.LevelSeconds < EnergyTargetLevelSeconds) - { - score += MathF.Max(0f, EnergyTargetLevelSeconds - energy.LevelSeconds) * 0.2f; - } - - return score - (currentCount * 40f); - } - - private static float ScoreStorageModule( - SimulationWorld world, - StationRuntime station, - string storageModuleId, - string? objectiveModuleId, - string? objectiveCommodityId, - bool requiredByObjective) - { - var storageClass = storageModuleId switch - { - "module_arg_stor_solid_m_01" => "solid", - "module_arg_stor_liquid_m_01" => "liquid", - _ => "container", - }; - - var capacity = GetStationStorageCapacity(station, storageClass); - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) - .Sum(entry => entry.Value); - var utilization = capacity <= 0.01f ? 0f : used / capacity; - - var score = requiredByObjective ? 140f : 0f; - score += MathF.Max(0f, utilization - 0.6f) * 240f; - - if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId)) - { - var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) - || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass); - if (objectiveUsesStorage) - { - score += 35f; - score += EstimateSupportUnlockScore(world, station, economy: null, supportModuleId: storageModuleId); - } - } - - return score; - } - - private static float ScoreDockModule(StationRuntime station) - { - var dockingPads = GetDockingPadCount(station); - var dockedShips = station.DockedShipIds.Count; - if (dockingPads <= 0) - { - return 150f; - } - - return dockedShips >= dockingPads ? 80f : dockingPads < 4 ? 25f : 0f; - } - - private static float ScoreHabitationModule(StationRuntime station) - { - if (station.WorkforceRequired <= 0.01f) - { - return 0f; - } - - return station.WorkforceEffectiveRatio < 0.75f - ? 30f - : station.WorkforceEffectiveRatio < 0.95f - ? 10f - : 0f; - } - - private static float ScoreHabitationModule(StationRuntime station, SimulationWorld world, FactionEconomySnapshot economy) - { - return ScoreHabitationModule(station) + EstimateSupportUnlockScore(world, station, economy, "module_arg_hab_m_01"); - } - - private static void AddOrRaiseCandidate(IDictionary candidates, string moduleId, float score) - { - if (score <= 0.01f) - { - return; - } - - if (!candidates.TryGetValue(moduleId, out var existing) || score > existing) - { - candidates[moduleId] = score; - } - } - - private static float EstimateMarginalOutputRate( - SimulationWorld world, - StationRuntime station, - string moduleId, - string commodityId) - { - var recipe = world.Recipes.Values - .Where(recipe => - string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) - && StationSimulationService.RecipeAppliesToStation(station, recipe)) - .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) - .OrderByDescending(recipe => recipe.Priority) - .FirstOrDefault(); - - if (recipe is null) - { - return 0f; - } - - var amount = recipe.Outputs - .Where(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)) - .Sum(output => output.Amount); - return amount * station.WorkforceEffectiveRatio / MathF.Max(recipe.Duration, 0.01f); - } - - private static float EstimateObjectiveExpansionFeasibility( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - string moduleId, - string commodityId) - { - var recipes = world.Recipes.Values - .Where(recipe => - string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) - && StationSimulationService.RecipeAppliesToStation(station, recipe) - && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) - .ToList(); - if (recipes.Count == 0) - { - return 1f; - } - - var feasibility = 1f; - foreach (var recipe in recipes) - { - foreach (var input in recipe.Inputs) - { - var inputCommodity = economy.GetCommodity(input.ItemId); - feasibility *= CommodityOperationalSignal.ComputeFeasibilityFactor( - inputCommodity, - GetTargetLevelSeconds(input.ItemId)); - } - } - - return Math.Clamp(feasibility, 0.35f, 1.15f); - } - - private static float EstimateProducerReadiness( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - string moduleId, - string commodityId) - { - var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); - return analysis.Readiness; - } - - private static ProducerLaneAnalysis AnalyzeProducerLane( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - string moduleId, - string commodityId) - { - var recipe = world.Recipes.Values - .Where(recipe => - string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) - && StationSimulationService.RecipeAppliesToStation(station, recipe) - && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) - .OrderByDescending(recipe => recipe.Priority) - .FirstOrDefault(); - if (recipe is null) - { - return new ProducerLaneAnalysis(1f, 1f, false, false, false, false); - } - - var workforceFactor = station.WorkforceEffectiveRatio < 0.45f - ? 0.75f - : station.WorkforceEffectiveRatio < 0.75f - ? 0.88f - : 1f; - var inputFactor = 1f; - var missingLocalInputs = false; - var missingFactionInputs = false; - - foreach (var input in recipe.Inputs) - { - var localAmount = GetInventoryAmount(station.Inventory, input.ItemId); - var commodity = economy.GetCommodity(input.ItemId); - if (localAmount + 0.001f >= input.Amount) - { - continue; - } - - missingLocalInputs = true; - var shortage = input.Amount - localAmount; - var availableStockRatio = commodity.AvailableStock <= 0.01f ? 0f : MathF.Min(1f, commodity.AvailableStock / MathF.Max(input.Amount, 0.01f)); - if (commodity.AvailableStock >= shortage) - { - inputFactor *= 0.95f + (availableStockRatio * 0.05f); - } - else if (commodity.ProjectedProductionRatePerSecond > 0.01f - && commodity.Level is not CommodityLevelKind.Critical) - { - inputFactor *= 0.82f + (availableStockRatio * 0.08f); - } - else - { - inputFactor *= 0.55f + (availableStockRatio * 0.15f); - missingFactionInputs = true; - } - } - - var outputReady = true; - foreach (var output in recipe.Outputs) - { - if (!CanStationAcceptStationOutputSoon(world, station, output.ItemId, output.Amount)) - { - outputReady = false; - } - } - - var readiness = Math.Clamp(workforceFactor * inputFactor * (outputReady ? 1f : 0.72f), 0.4f, 1.1f); - return new ProducerLaneAnalysis( - readiness, - workforceFactor, - missingLocalInputs, - missingFactionInputs, - !outputReady, - outputReady && inputFactor >= 0.9f); - } - - private static float EstimateSupportUnlockScore( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot? economy, - string supportModuleId) - { - var role = StationSimulationService.DetermineStationRole(station); - var objectiveCommodityId = GetObjectiveCommodityId(role); - var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodityId); - if (string.IsNullOrWhiteSpace(objectiveCommodityId) || string.IsNullOrWhiteSpace(objectiveModuleId)) - { - return 0f; - } - - var analysis = economy is null - ? new ProducerLaneAnalysis(0.75f, 1f, false, false, false, false) - : AnalyzeProducerLane(world, station, economy, objectiveModuleId, objectiveCommodityId); - - var unlockScore = 0f; - switch (supportModuleId) - { - case "module_arg_hab_m_01" when analysis.WorkforceFactor < 0.9f - && !analysis.HasMissingFactionInputs - && !analysis.HasMissingOutputStorage: - unlockScore += (1f - analysis.WorkforceFactor) * 150f; - break; - case "module_gen_prod_energycells_01": - if (ObjectiveNeedsEnergy(world, objectiveCommodityId) - && analysis.HasMissingLocalInputs - && (economy?.GetCommodity("energycells").AvailableStock ?? 0f) < 120f) - { - unlockScore += 90f; - } - break; - 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 - { - "module_arg_stor_solid_m_01" => "solid", - "module_arg_stor_liquid_m_01" => "liquid", - _ => "container", + "power" => "energycells", + "refinery" => "refinedmetals", + "water" => "water", + "graphene" => "graphene", + "siliconwafers" => "siliconwafers", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + "quantumtubes" => "quantumtubes", + "antimattercells" => "antimattercells", + "superfluidcoolant" => "superfluidcoolant", + _ => null, }; - if (analysis.HasMissingOutputStorage - && (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) - || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass))) + + private static string? GetObjectiveModuleId(SimulationWorld world, string role, string? objectiveCommodityId) => + role switch { - unlockScore += 70f; - } - break; - } + "shipyard" => "module_gen_build_l_01", + _ => objectiveCommodityId is null ? null : world.ProductionGraph.GetPrimaryProducerModule(objectiveCommodityId), + }; - return unlockScore * MathF.Max(0.4f, 1f - analysis.Readiness); - } - - private static float EstimateImmediateProducerActivationScore( - SimulationWorld world, - StationRuntime station, - FactionEconomySnapshot economy, - string moduleId, - string commodityId) - { - var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); - if (analysis.CanRunSoon) + private static float ScoreObjectiveModule( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + IReadOnlyDictionary constructionDemandByItem, + string? objectiveCommodityId, + string objectiveModuleId) { - return 110f; - } - - if (!analysis.HasMissingFactionInputs && !analysis.HasMissingOutputStorage) - { - return 45f * MathF.Max(0.6f, analysis.WorkforceFactor); - } - - return 0f; - } - - private static float EstimateConstructionBottleneckImpact( - SimulationWorld world, - string moduleId, - IReadOnlyDictionary constructionDemandByItem) - { - if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) - { - return 0f; - } - - var score = 0f; - foreach (var productItemId in moduleDefinition.Products) - { - if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f) - { - continue; - } - - var outputRate = EstimateModuleOutputRate(world, moduleId, productItemId); - if (outputRate <= 0.0001f) - { - continue; - } - - score += MathF.Min(outstandingDemand, outputRate * 900f) * 0.8f; - } - - return score; - } - - private static float EstimateModuleOutputRate(SimulationWorld world, string moduleId, string itemId) - { - var recipe = world.Recipes.Values - .Where(recipe => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)) - .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))) - .OrderByDescending(recipe => recipe.Priority) - .FirstOrDefault(); - if (recipe is null) - { - return 0f; - } - - return recipe.Outputs - .Where(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal)) - .Sum(output => output.Amount) / MathF.Max(recipe.Duration, 0.01f); - } - - private static IReadOnlyDictionary GetOutstandingConstructionDemand(SimulationWorld world, string factionId) - { - var demand = new Dictionary(StringComparer.Ordinal); - - 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)) - { - foreach (var required in site.RequiredItems) - { - var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)); - if (remaining <= 0.01f) + if (string.IsNullOrWhiteSpace(objectiveCommodityId)) { - continue; + var hasShipyard = CountModules(station.InstalledModules, objectiveModuleId); + return hasShipyard == 0 ? 240f : 0f; } - demand[required.Key] = demand.GetValueOrDefault(required.Key) + remaining; - } + var commodity = economy.GetCommodity(objectiveCommodityId); + var currentCount = CountModules(station.InstalledModules, objectiveModuleId); + var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId); + var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem); + var score = 90f + + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(objectiveCommodityId)) + + (marginalOutputRate * 900f) + + constructionImpact; + + if (currentCount == 0) + { + score += 80f; + } + + if (commodity.LevelSeconds < GetTargetLevelSeconds(objectiveCommodityId)) + { + score += MathF.Max(0f, GetTargetLevelSeconds(objectiveCommodityId) - commodity.LevelSeconds) * 0.3f; + } + + score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId); + score *= EstimateProducerReadiness(world, station, economy, objectiveModuleId, objectiveCommodityId); + score += EstimateImmediateProducerActivationScore(world, station, economy, objectiveModuleId, objectiveCommodityId); + return score - (currentCount * 35f); } - return demand; - } - - private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass) - { - if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) + private static float ScoreEnergySupportModule( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + IReadOnlyDictionary constructionDemandByItem) { - return false; + var energy = economy.GetCommodity("energycells"); + var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01"); + var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem); + var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01"); + var score = 40f + + CommodityOperationalSignal.ComputeNeedScore(energy, EnergyTargetLevelSeconds) * 0.5f + + constructionImpact + + readinessUnlock; + + if (currentCount == 0) + { + score += 70f; + } + + if (energy.LevelSeconds < EnergyTargetLevelSeconds) + { + score += MathF.Max(0f, EnergyTargetLevelSeconds - energy.LevelSeconds) * 0.2f; + } + + return score - (currentCount * 40f); } - return recipe.Inputs.Any(input => - world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition) - && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal)); - } - - private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) => - world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition) - && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal); - - private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount) - { - if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + private static float ScoreStorageModule( + SimulationWorld world, + StationRuntime station, + string storageModuleId, + string? objectiveModuleId, + string? objectiveCommodityId, + bool requiredByObjective) { - return false; + var storageClass = storageModuleId switch + { + "module_arg_stor_solid_m_01" => "solid", + "module_arg_stor_liquid_m_01" => "liquid", + _ => "container", + }; + + var capacity = GetStationStorageCapacity(station, storageClass); + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Sum(entry => entry.Value); + var utilization = capacity <= 0.01f ? 0f : used / capacity; + + var score = requiredByObjective ? 140f : 0f; + score += MathF.Max(0f, utilization - 0.6f) * 240f; + + if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId)) + { + var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass); + if (objectiveUsesStorage) + { + score += 35f; + score += EstimateSupportUnlockScore(world, station, economy: null, supportModuleId: storageModuleId); + } + } + + return score; } - var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); - if (capacity <= 0.01f) + private static float ScoreDockModule(StationRuntime station) { - return false; + var dockingPads = GetDockingPadCount(station); + var dockedShips = station.DockedShipIds.Count; + if (dockingPads <= 0) + { + return 150f; + } + + return dockedShips >= dockingPads ? 80f : dockingPads < 4 ? 25f : 0f; } - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && string.Equals(definition.CargoKind, itemDefinition.CargoKind, StringComparison.Ordinal)) - .Sum(entry => entry.Value); - return used + amount <= capacity * 0.95f; - } - - private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) => - world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal); - - private static float GetTargetLevelSeconds(string commodityId) => - string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : - string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : - string.Equals(commodityId, "graphene", StringComparison.Ordinal) ? 240f : - string.Equals(commodityId, "siliconwafers", StringComparison.Ordinal) ? 240f : - string.Equals(commodityId, "quantumtubes", StringComparison.Ordinal) ? 240f : - string.Equals(commodityId, "antimattercells", StringComparison.Ordinal) ? 240f : - string.Equals(commodityId, "superfluidcoolant", StringComparison.Ordinal) ? 240f : - CommodityTargetLevelSeconds; - - internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) - { - var nextModuleId = GetNextStationModuleToBuild(station, world); - foreach (var orderId in site.MarketOrderIds) + private static float ScoreHabitationModule(StationRuntime station) { - var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); - if (order is not null) - { - order.State = MarketOrderStateKinds.Cancelled; - order.RemainingAmount = 0f; - world.MarketOrders.Remove(order); - } + if (station.WorkforceRequired <= 0.01f) + { + return 0f; + } - station.MarketOrderIds.Remove(orderId); + return station.WorkforceEffectiveRatio < 0.75f + ? 30f + : station.WorkforceEffectiveRatio < 0.95f + ? 10f + : 0f; } - site.MarketOrderIds.Clear(); - site.Inventory.Clear(); - site.DeliveredItems.Clear(); - site.RequiredItems.Clear(); - site.AssignedConstructorShipIds.Clear(); - site.Progress = 0f; - - if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe)) + private static float ScoreHabitationModule(StationRuntime station, SimulationWorld world, FactionEconomySnapshot economy) { - site.State = ConstructionSiteStateKinds.Completed; - site.BlueprintId = null; - return; + return ScoreHabitationModule(station) + EstimateSupportUnlockScore(world, station, economy, "module_arg_hab_m_01"); } - site.BlueprintId = nextModuleId; - site.State = ConstructionSiteStateKinds.Active; - foreach (var input in recipe.Inputs) + private static void AddOrRaiseCandidate(IDictionary candidates, string moduleId, float score) { - site.RequiredItems[input.ItemId] = input.Amount; - site.DeliveredItems[input.ItemId] = 0f; - var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}"; - site.MarketOrderIds.Add(orderId); - station.MarketOrderIds.Add(orderId); - world.MarketOrders.Add(new MarketOrderRuntime - { - Id = orderId, - FactionId = station.FactionId, - StationId = station.Id, - ConstructionSiteId = site.Id, - Kind = MarketOrderKinds.Buy, - ItemId = input.ItemId, - Amount = input.Amount, - RemainingAmount = input.Amount, - Valuation = 1f, - State = MarketOrderStateKinds.Open, - }); + if (score <= 0.01f) + { + return; + } + + if (!candidates.TryGetValue(moduleId, out var existing) || score > existing) + { + candidates[moduleId] = score; + } } - } - private sealed record ModuleExpansionCandidate(string ModuleId, float Score); - - private sealed record ProducerLaneAnalysis( - float Readiness, - float WorkforceFactor, - bool HasMissingLocalInputs, - bool HasMissingFactionInputs, - bool HasMissingOutputStorage, - bool CanRunSoon); - - internal static int GetDockingPadCount(StationRuntime station) => - CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2; - - internal static int? ReserveDockingPad(StationRuntime station, string shipId) - { - if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing - && !string.IsNullOrEmpty(existing.Value)) + private static float EstimateMarginalOutputRate( + SimulationWorld world, + StationRuntime station, + string moduleId, + string commodityId) { - return existing.Key; + var recipe = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe)) + .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + + if (recipe is null) + { + return 0f; + } + + var amount = recipe.Outputs + .Where(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)) + .Sum(output => output.Amount); + return amount * station.WorkforceEffectiveRatio / MathF.Max(recipe.Duration, 0.01f); } - var padCount = GetDockingPadCount(station); - for (var padIndex = 0; padIndex < padCount; padIndex += 1) + private static float EstimateObjectiveExpansionFeasibility( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) { - if (station.DockingPadAssignments.ContainsKey(padIndex)) - { - continue; - } + var recipes = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .ToList(); + if (recipes.Count == 0) + { + return 1f; + } - station.DockingPadAssignments[padIndex] = shipId; - return padIndex; + var feasibility = 1f; + foreach (var recipe in recipes) + { + foreach (var input in recipe.Inputs) + { + var inputCommodity = economy.GetCommodity(input.ItemId); + feasibility *= CommodityOperationalSignal.ComputeFeasibilityFactor( + inputCommodity, + GetTargetLevelSeconds(input.ItemId)); + } + } + + return Math.Clamp(feasibility, 0.35f, 1.15f); } - return null; - } - - internal static void ReleaseDockingPad(StationRuntime station, string shipId) - { - var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); - if (!string.IsNullOrEmpty(assignment.Value)) + private static float EstimateProducerReadiness( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) { - station.DockingPadAssignments.Remove(assignment.Key); + var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); + return analysis.Readiness; } - } - internal static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) - { - var padCount = Math.Max(1, GetDockingPadCount(station)); - var angle = ((MathF.PI * 2f) / padCount) * padIndex; - var radius = station.Radius + 18f; - return new Vector3( - station.Position.X + (MathF.Cos(angle) * radius), - station.Position.Y, - station.Position.Z + (MathF.Sin(angle) * radius)); - } - - internal static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) - { - var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); - var angle = (hash % 360) * (MathF.PI / 180f); - var radius = station.Radius + 24f; - return new Vector3( - station.Position.X + (MathF.Cos(angle) * radius), - station.Position.Y, - station.Position.Z + (MathF.Sin(angle) * radius)); - } - - internal static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) - { - if (padIndex is null) + private static ProducerLaneAnalysis AnalyzeProducerLane( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) { - return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + var recipe = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + if (recipe is null) + { + return new ProducerLaneAnalysis(1f, 1f, false, false, false, false); + } + + var workforceFactor = station.WorkforceEffectiveRatio < 0.45f + ? 0.75f + : station.WorkforceEffectiveRatio < 0.75f + ? 0.88f + : 1f; + var inputFactor = 1f; + var missingLocalInputs = false; + var missingFactionInputs = false; + + foreach (var input in recipe.Inputs) + { + var localAmount = GetInventoryAmount(station.Inventory, input.ItemId); + var commodity = economy.GetCommodity(input.ItemId); + if (localAmount + 0.001f >= input.Amount) + { + continue; + } + + missingLocalInputs = true; + var shortage = input.Amount - localAmount; + var availableStockRatio = commodity.AvailableStock <= 0.01f ? 0f : MathF.Min(1f, commodity.AvailableStock / MathF.Max(input.Amount, 0.01f)); + if (commodity.AvailableStock >= shortage) + { + inputFactor *= 0.95f + (availableStockRatio * 0.05f); + } + else if (commodity.ProjectedProductionRatePerSecond > 0.01f + && commodity.Level is not CommodityLevelKind.Critical) + { + inputFactor *= 0.82f + (availableStockRatio * 0.08f); + } + else + { + inputFactor *= 0.55f + (availableStockRatio * 0.15f); + missingFactionInputs = true; + } + } + + var outputReady = true; + foreach (var output in recipe.Outputs) + { + if (!CanStationAcceptStationOutputSoon(world, station, output.ItemId, output.Amount)) + { + outputReady = false; + } + } + + var readiness = Math.Clamp(workforceFactor * inputFactor * (outputReady ? 1f : 0.72f), 0.4f, 1.1f); + return new ProducerLaneAnalysis( + readiness, + workforceFactor, + missingLocalInputs, + missingFactionInputs, + !outputReady, + outputReady && inputFactor >= 0.9f); } - var pad = GetDockingPadPosition(station, padIndex.Value); - var dx = pad.X - station.Position.X; - var dz = pad.Z - station.Position.Z; - var length = MathF.Sqrt((dx * dx) + (dz * dz)); - if (length <= 0.001f) + private static float EstimateSupportUnlockScore( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot? economy, + string supportModuleId) { - return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + var role = StationSimulationService.DetermineStationRole(station); + var objectiveCommodityId = GetObjectiveCommodityId(role); + var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodityId); + if (string.IsNullOrWhiteSpace(objectiveCommodityId) || string.IsNullOrWhiteSpace(objectiveModuleId)) + { + return 0f; + } + + var analysis = economy is null + ? new ProducerLaneAnalysis(0.75f, 1f, false, false, false, false) + : AnalyzeProducerLane(world, station, economy, objectiveModuleId, objectiveCommodityId); + + var unlockScore = 0f; + switch (supportModuleId) + { + case "module_arg_hab_m_01" when analysis.WorkforceFactor < 0.9f + && !analysis.HasMissingFactionInputs + && !analysis.HasMissingOutputStorage: + unlockScore += (1f - analysis.WorkforceFactor) * 150f; + break; + case "module_gen_prod_energycells_01": + if (ObjectiveNeedsEnergy(world, objectiveCommodityId) + && analysis.HasMissingLocalInputs + && (economy?.GetCommodity("energycells").AvailableStock ?? 0f) < 120f) + { + unlockScore += 90f; + } + break; + 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 + { + "module_arg_stor_solid_m_01" => "solid", + "module_arg_stor_liquid_m_01" => "liquid", + _ => "container", + }; + if (analysis.HasMissingOutputStorage + && (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass))) + { + unlockScore += 70f; + } + break; + } + + return unlockScore * MathF.Max(0.4f, 1f - analysis.Readiness); } - var scale = distance / length; - return new Vector3( - pad.X + (dx * scale), - station.Position.Y, - pad.Z + (dz * scale)); - } + private static float EstimateImmediateProducerActivationScore( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) + { + var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); + if (analysis.CanRunSoon) + { + return 110f; + } - internal static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => - ship.AssignedDockingPadIndex is int padIndex - ? GetDockingPadPosition(station, padIndex) - : station.Position; + if (!analysis.HasMissingFactionInputs && !analysis.HasMissingOutputStorage) + { + return 45f * MathF.Max(0.6f, analysis.WorkforceFactor); + } - internal static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId) - { - var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); - var angle = (hash % 360) * (MathF.PI / 180f); - var radius = station.Radius + 78f; - return new Vector3( - station.Position.X + (MathF.Cos(angle) * radius), - station.Position.Y, - station.Position.Z + (MathF.Sin(angle) * radius)); - } + return 0f; + } - internal static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius) - { - var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); - var angle = (hash % 360) * (MathF.PI / 180f); - return new Vector3( - nodePosition.X + (MathF.Cos(angle) * radius), - nodePosition.Y, - nodePosition.Z + (MathF.Sin(angle) * radius)); - } + private static float EstimateConstructionBottleneckImpact( + SimulationWorld world, + string moduleId, + IReadOnlyDictionary constructionDemandByItem) + { + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + return 0f; + } + + var score = 0f; + foreach (var productItemId in moduleDefinition.Products) + { + if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f) + { + continue; + } + + var outputRate = EstimateModuleOutputRate(world, moduleId, productItemId); + if (outputRate <= 0.0001f) + { + continue; + } + + score += MathF.Min(outstandingDemand, outputRate * 900f) * 0.8f; + } + + return score; + } + + private static float EstimateModuleOutputRate(SimulationWorld world, string moduleId, string itemId) + { + var recipe = world.Recipes.Values + .Where(recipe => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)) + .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + if (recipe is null) + { + return 0f; + } + + return recipe.Outputs + .Where(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal)) + .Sum(output => output.Amount) / MathF.Max(recipe.Duration, 0.01f); + } + + private static IReadOnlyDictionary GetOutstandingConstructionDemand(SimulationWorld world, string factionId) + { + var demand = new Dictionary(StringComparer.Ordinal); + + 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)) + { + foreach (var required in site.RequiredItems) + { + var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)); + if (remaining <= 0.01f) + { + continue; + } + + demand[required.Key] = demand.GetValueOrDefault(required.Key) + remaining; + } + } + + return demand; + } + + private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass) + { + if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) + { + return false; + } + + return recipe.Inputs.Any(input => + world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition) + && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal)); + } + + private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) => + world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition) + && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal); + + private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + return false; + } + + var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); + 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)) + .Sum(entry => entry.Value); + return used + amount <= capacity * 0.95f; + } + + private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) => + world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal); + + private static float GetTargetLevelSeconds(string commodityId) => + string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : + string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : + string.Equals(commodityId, "graphene", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "siliconwafers", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "quantumtubes", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "antimattercells", StringComparison.Ordinal) ? 240f : + string.Equals(commodityId, "superfluidcoolant", StringComparison.Ordinal) ? 240f : + CommodityTargetLevelSeconds; + + internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) + { + var nextModuleId = GetNextStationModuleToBuild(station, world); + foreach (var orderId in site.MarketOrderIds) + { + var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); + if (order is not null) + { + order.State = MarketOrderStateKinds.Cancelled; + order.RemainingAmount = 0f; + world.MarketOrders.Remove(order); + } + + station.MarketOrderIds.Remove(orderId); + } + + site.MarketOrderIds.Clear(); + site.Inventory.Clear(); + site.DeliveredItems.Clear(); + site.RequiredItems.Clear(); + site.AssignedConstructorShipIds.Clear(); + site.Progress = 0f; + + if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe)) + { + site.State = ConstructionSiteStateKinds.Completed; + site.BlueprintId = null; + return; + } + + site.BlueprintId = nextModuleId; + site.State = ConstructionSiteStateKinds.Active; + foreach (var input in recipe.Inputs) + { + site.RequiredItems[input.ItemId] = input.Amount; + site.DeliveredItems[input.ItemId] = 0f; + var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}"; + site.MarketOrderIds.Add(orderId); + station.MarketOrderIds.Add(orderId); + world.MarketOrders.Add(new MarketOrderRuntime + { + Id = orderId, + FactionId = station.FactionId, + StationId = station.Id, + ConstructionSiteId = site.Id, + Kind = MarketOrderKinds.Buy, + ItemId = input.ItemId, + Amount = input.Amount, + RemainingAmount = input.Amount, + Valuation = 1f, + State = MarketOrderStateKinds.Open, + }); + } + } + + private sealed record ModuleExpansionCandidate(string ModuleId, float Score); + + private sealed record ProducerLaneAnalysis( + float Readiness, + float WorkforceFactor, + bool HasMissingLocalInputs, + bool HasMissingFactionInputs, + bool HasMissingOutputStorage, + bool CanRunSoon); + + internal static int GetDockingPadCount(StationRuntime station) => + CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2; + + internal static int? ReserveDockingPad(StationRuntime station, string shipId) + { + if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing + && !string.IsNullOrEmpty(existing.Value)) + { + return existing.Key; + } + + var padCount = GetDockingPadCount(station); + for (var padIndex = 0; padIndex < padCount; padIndex += 1) + { + if (station.DockingPadAssignments.ContainsKey(padIndex)) + { + continue; + } + + station.DockingPadAssignments[padIndex] = shipId; + return padIndex; + } + + return null; + } + + internal static void ReleaseDockingPad(StationRuntime station, string shipId) + { + var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); + if (!string.IsNullOrEmpty(assignment.Value)) + { + station.DockingPadAssignments.Remove(assignment.Key); + } + } + + internal static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) + { + var padCount = Math.Max(1, GetDockingPadCount(station)); + var angle = ((MathF.PI * 2f) / padCount) * padIndex; + var radius = station.Radius + 18f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + internal static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + var radius = station.Radius + 24f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + internal static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) + { + if (padIndex is null) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var pad = GetDockingPadPosition(station, padIndex.Value); + var dx = pad.X - station.Position.X; + var dz = pad.Z - station.Position.Z; + var length = MathF.Sqrt((dx * dx) + (dz * dz)); + if (length <= 0.001f) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var scale = distance / length; + return new Vector3( + pad.X + (dx * scale), + station.Position.Y, + pad.Z + (dz * scale)); + } + + internal static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => + ship.AssignedDockingPadIndex is int padIndex + ? GetDockingPadPosition(station, padIndex) + : station.Position; + + internal static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + var radius = station.Radius + 78f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + internal static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + return new Vector3( + nodePosition.X + (MathF.Cos(angle) * radius), + nodePosition.Y, + nodePosition.Z + (MathF.Sin(angle) * radius)); + } } diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index c1351fe..7ff361c 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -4,208 +4,208 @@ namespace SpaceGame.Api.Stations.Simulation; internal sealed class StationLifecycleService { - private const float WaterConsumptionPerWorkerPerSecond = 0.004f; - private const float PopulationGrowthPerSecond = 0.012f; - private const float PopulationAttritionPerSecond = 0.018f; - private readonly StationSimulationService _stationSimulation; + private const float WaterConsumptionPerWorkerPerSecond = 0.004f; + private const float PopulationGrowthPerSecond = 0.012f; + private const float PopulationAttritionPerSecond = 0.018f; + private readonly StationSimulationService _stationSimulation; - internal StationLifecycleService(StationSimulationService stationSimulation) - { - _stationSimulation = stationSimulation; - } - - internal void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) - { - var factionPopulation = new Dictionary(StringComparer.Ordinal); - foreach (var station in world.Stations) + internal StationLifecycleService(StationSimulationService stationSimulation) { - UpdateStationPopulation(station, deltaSeconds, events); - _stationSimulation.ReviewStationMarketOrders(world, station); - _stationSimulation.RunStationProduction(world, station, deltaSeconds, events); - factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population; + _stationSimulation = stationSimulation; } - foreach (var faction in world.Factions) + internal void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) { - faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id); - } - } + var factionPopulation = new Dictionary(StringComparer.Ordinal); + foreach (var station in world.Stations) + { + UpdateStationPopulation(station, deltaSeconds, events); + _stationSimulation.ReviewStationMarketOrders(world, station); + _stationSimulation.RunStationProduction(world, station, deltaSeconds, events); + factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population; + } - private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection events) - { - station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); - - 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, "module_arg_hab_m_01"); - station.PopulationCapacity = 40f + (habitatModules * 220f); - - if (waterSatisfied) - { - if (habitatModules > 0 && station.Population < station.PopulationCapacity) - { - station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); - } - } - else if (station.Population > 0f) - { - var previous = station.Population; - station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds)); - if (MathF.Floor(previous) > MathF.Floor(station.Population)) - { - events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); - } + foreach (var faction in world.Factions) + { + faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id); + } } - station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); - } - - internal static float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection events) - { - if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition)) + private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection events) { - return 0f; + station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); + + 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, "module_arg_hab_m_01"); + station.PopulationCapacity = 40f + (habitatModules * 220f); + + if (waterSatisfied) + { + if (habitatModules > 0 && station.Population < station.PopulationCapacity) + { + station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); + } + } + else if (station.Population > 0f) + { + var previous = station.Population; + station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds)); + if (MathF.Floor(previous) > MathF.Floor(station.Population)) + { + events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); + } + } + + station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); } - var spawnPosition = new Vector3(station.Position.X + GetStationRadius(world, station) + 32f, station.Position.Y, station.Position.Z); - var ship = new ShipRuntime + internal static float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection events) { - Id = $"ship-{world.Ships.Count + 1}", - SystemId = station.SystemId, - Definition = definition, - FactionId = station.FactionId, - Position = spawnPosition, - TargetPosition = spawnPosition, - SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), - DefaultBehavior = CreateSpawnedShipBehavior(definition, station), - Skills = WorldSeedingService.CreateSkills(definition), - Health = definition.MaxHealth, + if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition)) + { + return 0f; + } + + var spawnPosition = new Vector3(station.Position.X + GetStationRadius(world, station) + 32f, station.Position.Y, station.Position.Z); + var ship = new ShipRuntime + { + Id = $"ship-{world.Ships.Count + 1}", + SystemId = station.SystemId, + Definition = definition, + FactionId = station.FactionId, + Position = spawnPosition, + TargetPosition = spawnPosition, + SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), + DefaultBehavior = CreateSpawnedShipBehavior(definition, station), + Skills = WorldSeedingService.CreateSkills(definition), + Health = definition.MaxHealth, + }; + + world.Ships.Add(ship); + EnsureSpawnedShipCommander(world, station, ship); + if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) + { + faction.ShipsBuilt += 1; + } + + events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow)); + return 1f; + } + + private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new() + { + CurrentSystemId = station.SystemId, + SpaceLayer = SpaceLayerKinds.LocalSpace, + CurrentCelestialId = station.CelestialId, + LocalPosition = position, + SystemPosition = position, + MovementRegime = MovementRegimeKinds.LocalFlight, }; - world.Ships.Add(ship); - EnsureSpawnedShipCommander(world, station, ship); - if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) + private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station) { - faction.ShipsBuilt += 1; - } + if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle", + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0, + }; + } - events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow)); - return 1f; - } - - private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new() - { - CurrentSystemId = station.SystemId, - SpaceLayer = SpaceLayerKinds.LocalSpace, - CurrentCelestialId = station.CelestialId, - LocalPosition = position, - SystemPosition = position, - MovementRegime = MovementRegimeKinds.LocalFlight, - }; - - private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station) - { - if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) - { - return new DefaultBehaviorRuntime - { - Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle", - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0, - }; - } - - var patrolRadius = station.Radius + 90f; - return new DefaultBehaviorRuntime - { - Kind = "patrol", - HomeSystemId = station.SystemId, - HomeStationId = station.Id, - AreaSystemId = station.SystemId, - PatrolPoints = - [ - new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), + var patrolRadius = station.Radius + 90f; + return new DefaultBehaviorRuntime + { + Kind = "patrol", + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + AreaSystemId = station.SystemId, + PatrolPoints = + [ + new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius), new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z), new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius), ], - }; - } - - internal static void EnsureStationCommander(SimulationWorld world, StationRuntime station) - { - if (!string.IsNullOrWhiteSpace(station.CommanderId)) - { - return; + }; } - var factionCommander = world.Commanders.FirstOrDefault(candidate => - string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) - && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); - var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); - if (factionCommander is null || faction is null) + internal static void EnsureStationCommander(SimulationWorld world, StationRuntime station) { - return; + if (!string.IsNullOrWhiteSpace(station.CommanderId)) + { + return; + } + + var factionCommander = world.Commanders.FirstOrDefault(candidate => + string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) + && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); + var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); + if (factionCommander is null || faction is null) + { + return; + } + + var commander = new CommanderRuntime + { + Id = $"commander-station-{station.Id}", + Kind = CommanderKind.Station, + FactionId = station.FactionId, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = station.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "station-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = 3, + Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), + Strategy = 3, + }, + }; + + station.CommanderId = commander.Id; + station.PolicySetId = factionCommander.PolicySetId; + factionCommander.SubordinateCommanderIds.Add(commander.Id); + faction.CommanderIds.Add(commander.Id); + world.Commanders.Add(commander); } - var commander = new CommanderRuntime + private static void EnsureSpawnedShipCommander(SimulationWorld world, StationRuntime station, ShipRuntime ship) { - Id = $"commander-station-{station.Id}", - Kind = CommanderKind.Station, - FactionId = station.FactionId, - ParentCommanderId = factionCommander.Id, - ControlledEntityId = station.Id, - PolicySetId = factionCommander.PolicySetId, - Doctrine = "station-control", - Skills = new CommanderSkillProfileRuntime - { - Leadership = 3, - Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), - Strategy = 3, - }, - }; + var factionCommander = world.Commanders.FirstOrDefault(candidate => + string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) + && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); + var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); + if (factionCommander is null || faction is null) + { + return; + } - station.CommanderId = commander.Id; - station.PolicySetId = factionCommander.PolicySetId; - factionCommander.SubordinateCommanderIds.Add(commander.Id); - faction.CommanderIds.Add(commander.Id); - world.Commanders.Add(commander); - } + var commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = ship.FactionId, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = ship.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "ship-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), + Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), + Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), + }, + }; - private static void EnsureSpawnedShipCommander(SimulationWorld world, StationRuntime station, ShipRuntime ship) - { - var factionCommander = world.Commanders.FirstOrDefault(candidate => - string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) - && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); - var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); - if (factionCommander is null || faction is null) - { - return; + ship.CommanderId = commander.Id; + ship.PolicySetId = factionCommander.PolicySetId; + factionCommander.SubordinateCommanderIds.Add(commander.Id); + faction.CommanderIds.Add(commander.Id); + world.Commanders.Add(commander); } - - var commander = new CommanderRuntime - { - Id = $"commander-ship-{ship.Id}", - Kind = CommanderKind.Ship, - FactionId = ship.FactionId, - ParentCommanderId = factionCommander.Id, - ControlledEntityId = ship.Id, - PolicySetId = factionCommander.PolicySetId, - Doctrine = "ship-control", - Skills = new CommanderSkillProfileRuntime - { - Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), - Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), - Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), - }, - }; - - ship.CommanderId = commander.Id; - ship.PolicySetId = factionCommander.PolicySetId; - factionCommander.SubordinateCommanderIds.Add(commander.Id); - faction.CommanderIds.Add(commander.Id); - world.Commanders.Add(commander); - } } diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index 4dd5cc8..e67251d 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -5,729 +5,729 @@ namespace SpaceGame.Api.Stations.Simulation; internal sealed class StationSimulationService { - internal const int StrategicControlTargetSystems = 5; + internal const int StrategicControlTargetSystems = 5; - internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) - { - if (station.CommanderId is null) + internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) { - return; - } - - var desiredOrders = new List(); - var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); - var role = DetermineStationRole(station); - var site = GetConstructionSiteForStation(world, station.Id); - var waterReserve = MathF.Max(30f, station.Population * 3f); - var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); - var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts"); - var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); - var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); - var iceReserve = role == "water" ? 260f : 0f; - var methaneReserve = role == "graphene" ? 320f : 0f; - var hydrogenReserve = role == "antimattercells" ? 320f : 0f; - var heliumReserve = role == "superfluidcoolant" ? 320f : 0f; - var siliconReserve = role == "siliconwafers" ? 240f : 0f; - var grapheneInputReserve = role == "quantumtubes" ? 160f : 0f; - var superfluidCoolantInputReserve = role == "quantumtubes" ? 120f : 0f; - var antimatterCellsInputReserve = role == "claytronics" ? 120f : 0f; - var quantumTubesInputReserve = role == "claytronics" ? 120f : 0f; - var energyReserve = role switch - { - "power" => 120f, - "refinery" => 160f, - "hullparts" => 180f, - "claytronics" => 220f, - "graphene" => 160f, - "siliconwafers" => 160f, - "antimattercells" => 160f, - "superfluidcoolant" => 160f, - "quantumtubes" => 160f, - "water" => 140f, - _ => 60f, - } + constructionEnergyReserve; - var refinedReserve = role switch - { - "hullparts" => 220f, - "shipyard" => 260f, - "refinery" => 80f, - _ => 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 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") - && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f - ? 90f - : 0f; - - AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f)); - AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f)); - AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f)); - AddDemandOrder(desiredOrders, station, "methane", ScaleReserveByEconomy(economy, "methane", methaneReserve), valuationBase: ScaleDemandValuation(economy, "methane", 1.0f)); - AddDemandOrder(desiredOrders, station, "hydrogen", ScaleReserveByEconomy(economy, "hydrogen", hydrogenReserve), valuationBase: ScaleDemandValuation(economy, "hydrogen", 1.0f)); - AddDemandOrder(desiredOrders, station, "helium", ScaleReserveByEconomy(economy, "helium", heliumReserve), valuationBase: ScaleDemandValuation(economy, "helium", 1.0f)); - AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f)); - AddDemandOrder(desiredOrders, station, "silicon", ScaleReserveByEconomy(economy, "silicon", siliconReserve), valuationBase: ScaleDemandValuation(economy, "silicon", 1.0f)); - AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f)); - AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f)); - AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f)); - AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.05f)); - AddDemandOrder(desiredOrders, station, "siliconwafers", ScaleReserveByEconomy(economy, "siliconwafers", siliconWafersReserve), valuationBase: ScaleDemandValuation(economy, "siliconwafers", 1.05f)); - AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.05f)); - AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.05f)); - AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneInputReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.1f)); - AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantInputReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.1f)); - AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsInputReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.1f)); - AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesInputReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.1f)); - AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.05f)); - - AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f)); - AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f)); - AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f)); - AddSupplyOrder(desiredOrders, station, "methane", ScaleSupplyTriggerByEconomy(economy, "methane", methaneReserve * 1.4f), reserveFloor: methaneReserve, valuationBase: ScaleSupplyValuation(economy, "methane", 0.7f)); - AddSupplyOrder(desiredOrders, station, "hydrogen", ScaleSupplyTriggerByEconomy(economy, "hydrogen", hydrogenReserve * 1.4f), reserveFloor: hydrogenReserve, valuationBase: ScaleSupplyValuation(economy, "hydrogen", 0.7f)); - AddSupplyOrder(desiredOrders, station, "helium", ScaleSupplyTriggerByEconomy(economy, "helium", heliumReserve * 1.4f), reserveFloor: heliumReserve, valuationBase: ScaleSupplyValuation(economy, "helium", 0.7f)); - AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f)); - AddSupplyOrder(desiredOrders, station, "silicon", ScaleSupplyTriggerByEconomy(economy, "silicon", siliconReserve * 1.4f), reserveFloor: siliconReserve, valuationBase: ScaleSupplyValuation(economy, "silicon", 0.7f)); - AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f)); - AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f)); - AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f)); - AddSupplyOrder(desiredOrders, station, "graphene", ScaleSupplyTriggerByEconomy(economy, "graphene", MathF.Max(grapheneReserve * 1.35f, grapheneReserve + 30f)), reserveFloor: grapheneReserve, valuationBase: ScaleSupplyValuation(economy, "graphene", 0.9f)); - AddSupplyOrder(desiredOrders, station, "siliconwafers", ScaleSupplyTriggerByEconomy(economy, "siliconwafers", MathF.Max(siliconWafersReserve * 1.35f, siliconWafersReserve + 30f)), reserveFloor: siliconWafersReserve, valuationBase: ScaleSupplyValuation(economy, "siliconwafers", 0.9f)); - AddSupplyOrder(desiredOrders, station, "antimattercells", ScaleSupplyTriggerByEconomy(economy, "antimattercells", MathF.Max(antimatterCellsReserve * 1.35f, antimatterCellsReserve + 30f)), reserveFloor: antimatterCellsReserve, valuationBase: ScaleSupplyValuation(economy, "antimattercells", 0.9f)); - AddSupplyOrder(desiredOrders, station, "superfluidcoolant", ScaleSupplyTriggerByEconomy(economy, "superfluidcoolant", MathF.Max(superfluidCoolantReserve * 1.35f, superfluidCoolantReserve + 30f)), reserveFloor: superfluidCoolantReserve, valuationBase: ScaleSupplyValuation(economy, "superfluidcoolant", 0.9f)); - AddSupplyOrder(desiredOrders, station, "quantumtubes", ScaleSupplyTriggerByEconomy(economy, "quantumtubes", MathF.Max(quantumTubesReserve * 1.35f, quantumTubesReserve + 30f)), reserveFloor: quantumTubesReserve, valuationBase: ScaleSupplyValuation(economy, "quantumtubes", 0.9f)); - - desiredOrders = ApplyRegionalMarketModifiers(world, station, desiredOrders); - ReconcileStationMarketOrders(world, station, desiredOrders); - } - - internal static float GetStationReserveFloor(SimulationWorld world, StationRuntime station, string itemId) - { - var role = DetermineStationRole(station); - var site = GetConstructionSiteForStation(world, station.Id); - var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); - 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") - && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f - ? 90f - : 0f; - - return itemId switch - { - "water" => MathF.Max(30f, station.Population * 3f), - "energycells" => role switch - { - "power" => 120f, - "refinery" => 160f, - "hullparts" => 180f, - "claytronics" => 220f, - "graphene" => 160f, - "siliconwafers" => 160f, - "antimattercells" => 160f, - "superfluidcoolant" => 160f, - "quantumtubes" => 160f, - "water" => 140f, - _ => 60f, - } + constructionEnergyReserve, - "ice" => role == "water" ? 260f : 0f, - "methane" => role == "graphene" ? 320f : 0f, - "hydrogen" => role == "antimattercells" ? 320f : 0f, - "helium" => role == "superfluidcoolant" ? 320f : 0f, - "ore" => role == "refinery" ? 260f : 0f, - "silicon" => role == "siliconwafers" ? 240f : 0f, - "refinedmetals" => MathF.Max(role switch - { - "hullparts" => 220f, - "shipyard" => 260f, - "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), - "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), - "superfluidcoolant" => MathF.Max(role == "superfluidcoolant" ? 120f : 0f, role == "quantumtubes" ? 120f : 0f), - "quantumtubes" => MathF.Max(role == "quantumtubes" ? 120f : 0f, role == "claytronics" ? 120f : 0f), - _ => 0f, - }; - } - - internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) - { - var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); - foreach (var laneKey in GetStationProductionLanes(world, station)) - { - var recipe = SelectProductionRecipe(world, station, laneKey); - if (recipe is null) - { - station.ProductionLaneTimers[laneKey] = 0f; - continue; - } - - var throughput = GetStationProductionThroughput(world, station, recipe); - - var produced = 0f; - station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput); - while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe)) - { - station.ProductionLaneTimers[laneKey] -= recipe.Duration; - foreach (var input in recipe.Inputs) + if (station.CommanderId is null) { - RemoveInventory(station.Inventory, input.ItemId, input.Amount); + return; } + var desiredOrders = new List(); + var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); + var role = DetermineStationRole(station); + var site = GetConstructionSiteForStation(world, station.Id); + var waterReserve = MathF.Max(30f, station.Population * 3f); + var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); + var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts"); + var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); + var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); + var iceReserve = role == "water" ? 260f : 0f; + var methaneReserve = role == "graphene" ? 320f : 0f; + var hydrogenReserve = role == "antimattercells" ? 320f : 0f; + var heliumReserve = role == "superfluidcoolant" ? 320f : 0f; + var siliconReserve = role == "siliconwafers" ? 240f : 0f; + var grapheneInputReserve = role == "quantumtubes" ? 160f : 0f; + var superfluidCoolantInputReserve = role == "quantumtubes" ? 120f : 0f; + var antimatterCellsInputReserve = role == "claytronics" ? 120f : 0f; + var quantumTubesInputReserve = role == "claytronics" ? 120f : 0f; + var energyReserve = role switch + { + "power" => 120f, + "refinery" => 160f, + "hullparts" => 180f, + "claytronics" => 220f, + "graphene" => 160f, + "siliconwafers" => 160f, + "antimattercells" => 160f, + "superfluidcoolant" => 160f, + "quantumtubes" => 160f, + "water" => 140f, + _ => 60f, + } + constructionEnergyReserve; + var refinedReserve = role switch + { + "hullparts" => 220f, + "shipyard" => 260f, + "refinery" => 80f, + _ => 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 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") + && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f + ? 90f + : 0f; + + AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f)); + AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f)); + AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f)); + AddDemandOrder(desiredOrders, station, "methane", ScaleReserveByEconomy(economy, "methane", methaneReserve), valuationBase: ScaleDemandValuation(economy, "methane", 1.0f)); + AddDemandOrder(desiredOrders, station, "hydrogen", ScaleReserveByEconomy(economy, "hydrogen", hydrogenReserve), valuationBase: ScaleDemandValuation(economy, "hydrogen", 1.0f)); + AddDemandOrder(desiredOrders, station, "helium", ScaleReserveByEconomy(economy, "helium", heliumReserve), valuationBase: ScaleDemandValuation(economy, "helium", 1.0f)); + AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f)); + AddDemandOrder(desiredOrders, station, "silicon", ScaleReserveByEconomy(economy, "silicon", siliconReserve), valuationBase: ScaleDemandValuation(economy, "silicon", 1.0f)); + AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f)); + AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f)); + AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f)); + AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.05f)); + AddDemandOrder(desiredOrders, station, "siliconwafers", ScaleReserveByEconomy(economy, "siliconwafers", siliconWafersReserve), valuationBase: ScaleDemandValuation(economy, "siliconwafers", 1.05f)); + AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.05f)); + AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.05f)); + AddDemandOrder(desiredOrders, station, "graphene", ScaleReserveByEconomy(economy, "graphene", grapheneInputReserve), valuationBase: ScaleDemandValuation(economy, "graphene", 1.1f)); + AddDemandOrder(desiredOrders, station, "superfluidcoolant", ScaleReserveByEconomy(economy, "superfluidcoolant", superfluidCoolantInputReserve), valuationBase: ScaleDemandValuation(economy, "superfluidcoolant", 1.1f)); + AddDemandOrder(desiredOrders, station, "antimattercells", ScaleReserveByEconomy(economy, "antimattercells", antimatterCellsInputReserve), valuationBase: ScaleDemandValuation(economy, "antimattercells", 1.1f)); + AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesInputReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.1f)); + AddDemandOrder(desiredOrders, station, "quantumtubes", ScaleReserveByEconomy(economy, "quantumtubes", quantumTubesReserve), valuationBase: ScaleDemandValuation(economy, "quantumtubes", 1.05f)); + + AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f)); + AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f)); + AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f)); + AddSupplyOrder(desiredOrders, station, "methane", ScaleSupplyTriggerByEconomy(economy, "methane", methaneReserve * 1.4f), reserveFloor: methaneReserve, valuationBase: ScaleSupplyValuation(economy, "methane", 0.7f)); + AddSupplyOrder(desiredOrders, station, "hydrogen", ScaleSupplyTriggerByEconomy(economy, "hydrogen", hydrogenReserve * 1.4f), reserveFloor: hydrogenReserve, valuationBase: ScaleSupplyValuation(economy, "hydrogen", 0.7f)); + AddSupplyOrder(desiredOrders, station, "helium", ScaleSupplyTriggerByEconomy(economy, "helium", heliumReserve * 1.4f), reserveFloor: heliumReserve, valuationBase: ScaleSupplyValuation(economy, "helium", 0.7f)); + AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f)); + AddSupplyOrder(desiredOrders, station, "silicon", ScaleSupplyTriggerByEconomy(economy, "silicon", siliconReserve * 1.4f), reserveFloor: siliconReserve, valuationBase: ScaleSupplyValuation(economy, "silicon", 0.7f)); + AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f)); + AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f)); + AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f)); + AddSupplyOrder(desiredOrders, station, "graphene", ScaleSupplyTriggerByEconomy(economy, "graphene", MathF.Max(grapheneReserve * 1.35f, grapheneReserve + 30f)), reserveFloor: grapheneReserve, valuationBase: ScaleSupplyValuation(economy, "graphene", 0.9f)); + AddSupplyOrder(desiredOrders, station, "siliconwafers", ScaleSupplyTriggerByEconomy(economy, "siliconwafers", MathF.Max(siliconWafersReserve * 1.35f, siliconWafersReserve + 30f)), reserveFloor: siliconWafersReserve, valuationBase: ScaleSupplyValuation(economy, "siliconwafers", 0.9f)); + AddSupplyOrder(desiredOrders, station, "antimattercells", ScaleSupplyTriggerByEconomy(economy, "antimattercells", MathF.Max(antimatterCellsReserve * 1.35f, antimatterCellsReserve + 30f)), reserveFloor: antimatterCellsReserve, valuationBase: ScaleSupplyValuation(economy, "antimattercells", 0.9f)); + AddSupplyOrder(desiredOrders, station, "superfluidcoolant", ScaleSupplyTriggerByEconomy(economy, "superfluidcoolant", MathF.Max(superfluidCoolantReserve * 1.35f, superfluidCoolantReserve + 30f)), reserveFloor: superfluidCoolantReserve, valuationBase: ScaleSupplyValuation(economy, "superfluidcoolant", 0.9f)); + AddSupplyOrder(desiredOrders, station, "quantumtubes", ScaleSupplyTriggerByEconomy(economy, "quantumtubes", MathF.Max(quantumTubesReserve * 1.35f, quantumTubesReserve + 30f)), reserveFloor: quantumTubesReserve, valuationBase: ScaleSupplyValuation(economy, "quantumtubes", 0.9f)); + + desiredOrders = ApplyRegionalMarketModifiers(world, station, desiredOrders); + ReconcileStationMarketOrders(world, station, desiredOrders); + } + + internal static float GetStationReserveFloor(SimulationWorld world, StationRuntime station, string itemId) + { + var role = DetermineStationRole(station); + var site = GetConstructionSiteForStation(world, station.Id); + var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); + 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") + && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f + ? 90f + : 0f; + + return itemId switch + { + "water" => MathF.Max(30f, station.Population * 3f), + "energycells" => role switch + { + "power" => 120f, + "refinery" => 160f, + "hullparts" => 180f, + "claytronics" => 220f, + "graphene" => 160f, + "siliconwafers" => 160f, + "antimattercells" => 160f, + "superfluidcoolant" => 160f, + "quantumtubes" => 160f, + "water" => 140f, + _ => 60f, + } + constructionEnergyReserve, + "ice" => role == "water" ? 260f : 0f, + "methane" => role == "graphene" ? 320f : 0f, + "hydrogen" => role == "antimattercells" ? 320f : 0f, + "helium" => role == "superfluidcoolant" ? 320f : 0f, + "ore" => role == "refinery" ? 260f : 0f, + "silicon" => role == "siliconwafers" ? 240f : 0f, + "refinedmetals" => MathF.Max(role switch + { + "hullparts" => 220f, + "shipyard" => 260f, + "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), + "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), + "superfluidcoolant" => MathF.Max(role == "superfluidcoolant" ? 120f : 0f, role == "quantumtubes" ? 120f : 0f), + "quantumtubes" => MathF.Max(role == "quantumtubes" ? 120f : 0f, role == "claytronics" ? 120f : 0f), + _ => 0f, + }; + } + + internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) + { + var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); + foreach (var laneKey in GetStationProductionLanes(world, station)) + { + var recipe = SelectProductionRecipe(world, station, laneKey); + if (recipe is null) + { + station.ProductionLaneTimers[laneKey] = 0f; + continue; + } + + var throughput = GetStationProductionThroughput(world, station, recipe); + + var produced = 0f; + station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput); + while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe)) + { + station.ProductionLaneTimers[laneKey] -= recipe.Duration; + foreach (var input in recipe.Inputs) + { + RemoveInventory(station.Inventory, input.ItemId, input.Amount); + } + + if (recipe.ShipOutputId is not null) + { + produced += StationLifecycleService.CompleteShipRecipe(world, station, recipe, events); + continue; + } + + foreach (var output in recipe.Outputs) + { + produced += TryAddStationInventory(world, station, output.ItemId, output.Amount); + } + } + + if (produced <= 0.01f) + { + continue; + } + + events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow)); + if (faction is not null) + { + faction.GoodsProduced += produced; + } + } + } + + internal static IEnumerable GetStationProductionLanes(SimulationWorld world, StationRuntime station) + { + 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) + { + continue; + } + + yield return moduleId; + } + } + + internal static float GetStationProductionTimer(StationRuntime station, string laneKey) => + station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f; + + internal static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) => + world.Recipes.Values + .Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal)) + .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) + .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); + + internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) => + recipe.RequiredModules.FirstOrDefault(moduleId => + world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode)); + + internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + var laneModuleId = GetStationProductionLaneKey(world, recipe); + if (laneModuleId is null) + { + return 1f; + } + + return Math.Max(1, CountModules(station.InstalledModules, laneModuleId)); + } + + private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + var priority = (float)recipe.Priority; + + var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); + var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military"); + priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure); + priority += GetStrategicRecipeBias(world, station, recipe); + + return priority; + } + + private static float GetStationRecipePriorityAdjustment(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure) + { + if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) + { + var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind); + return shipDefinition.Kind switch + { + "military" => recipe.Id switch + { + "frigate-construction" => 320f * shipPressure, + "destroyer-construction" => 200f * shipPressure, + "cruiser-construction" => 120f * shipPressure, + _ => 160f * shipPressure, + }, + "construction" => 260f * shipPressure, + "mining" => 250f * shipPressure, + "transport" => 230f * shipPressure, + _ => 0f, + }; + } + + var outputItemIds = recipe.Outputs + .Select(output => output.ItemId) + .ToHashSet(StringComparer.Ordinal); + + if (outputItemIds.Contains("hullparts")) + { + return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") + ? -140f * MathF.Max(expansionPressure, fleetPressure) + : 280f * MathF.Max(expansionPressure, fleetPressure); + } + + if (outputItemIds.Contains("refinedmetals")) + { + return 180f * expansionPressure; + } + + if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"])) + { + return 170f * expansionPressure; + } + + if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"])) + { + return 160f * expansionPressure; + } + + return recipe.Id switch + { + "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" + => 220f * MathF.Max(expansionPressure, fleetPressure), + "ammo-fabrication" => -80f * expansionPressure, + "trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly" + => -120f * expansionPressure, + _ => 0f, + }; + } + + private static float GetStrategicRecipeBias(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + var commander = station.CommanderId is null + ? null + : world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); + var assignment = commander?.Assignment; + if (assignment is null) + { + return 0f; + } + + var outputItemIds = recipe.Outputs + .Select(output => output.ItemId) + .ToHashSet(StringComparer.Ordinal); + + if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal) + && recipe.ShipOutputId is not null + && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition) + && string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)) + { + return 260f; + } + + if (string.Equals(assignment.Kind, "commodity-focus", StringComparison.Ordinal) + && assignment.ItemId is not null + && outputItemIds.Contains(assignment.ItemId)) + { + return 220f; + } + + if (string.Equals(assignment.Kind, "expansion-support", StringComparison.Ordinal) + && outputItemIds.Overlaps(["energycells", "refinedmetals", "hullparts", "claytronics"])) + { + return 180f; + } + + if (string.Equals(assignment.Kind, "station-oversight", StringComparison.Ordinal) + && assignment.ItemId is not null + && outputItemIds.Contains(assignment.ItemId)) + { + return 90f; + } + + return 0f; + } + + internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) + { + var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) + || string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal) + || string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal); + return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); + } + + private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { if (recipe.ShipOutputId is not null) { - produced += StationLifecycleService.CompleteShipRecipe(world, station, recipe, events); - continue; + if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) + { + return false; + } + + if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f) + { + return false; + } } - foreach (var output in recipe.Outputs) + if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount)) { - produced += TryAddStationInventory(world, station, output.ItemId, output.Amount); + return false; } - } - if (produced <= 0.01f) - { - continue; - } - - events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow)); - if (faction is not null) - { - faction.GoodsProduced += produced; - } - } - } - - internal static IEnumerable GetStationProductionLanes(SimulationWorld world, StationRuntime station) - { - 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) - { - continue; - } - - yield return moduleId; - } - } - - internal static float GetStationProductionTimer(StationRuntime station, string laneKey) => - station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f; - - internal static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) => - world.Recipes.Values - .Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal)) - .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) - .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); - - internal static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) => - recipe.RequiredModules.FirstOrDefault(moduleId => - world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode)); - - internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) - { - var laneModuleId = GetStationProductionLaneKey(world, recipe); - if (laneModuleId is null) - { - return 1f; + return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount)); } - return Math.Max(1, CountModules(station.InstalledModules, laneModuleId)); - } - - private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) - { - var priority = (float)recipe.Priority; - - var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); - var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military"); - priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure); - priority += GetStrategicRecipeBias(world, station, recipe); - - return priority; - } - - private static float GetStationRecipePriorityAdjustment(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure) - { - if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) + private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) { - var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind); - return shipDefinition.Kind switch - { - "military" => recipe.Id switch + if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { - "frigate-construction" => 320f * shipPressure, - "destroyer-construction" => 200f * shipPressure, - "cruiser-construction" => 120f * shipPressure, - _ => 160f * shipPressure, - }, - "construction" => 260f * shipPressure, - "mining" => 250f * shipPressure, - "transport" => 230f * shipPressure, - _ => 0f, - }; - } + return false; + } - var outputItemIds = recipe.Outputs - .Select(output => output.ItemId) - .ToHashSet(StringComparer.Ordinal); - - if (outputItemIds.Contains("hullparts")) - { - return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") - ? -140f * MathF.Max(expansionPressure, fleetPressure) - : 280f * MathF.Max(expansionPressure, fleetPressure); - } - - if (outputItemIds.Contains("refinedmetals")) - { - return 180f * expansionPressure; - } - - if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"])) - { - return 170f * expansionPressure; - } - - if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"])) - { - return 160f * expansionPressure; - } - - return recipe.Id switch - { - "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" - => 220f * MathF.Max(expansionPressure, fleetPressure), - "ammo-fabrication" => -80f * expansionPressure, - "trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly" - => -120f * expansionPressure, - _ => 0f, - }; - } - - private static float GetStrategicRecipeBias(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) - { - var commander = station.CommanderId is null - ? null - : world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); - var assignment = commander?.Assignment; - if (assignment is null) - { - return 0f; - } - - var outputItemIds = recipe.Outputs - .Select(output => output.ItemId) - .ToHashSet(StringComparer.Ordinal); - - if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal) - && recipe.ShipOutputId is not null - && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition) - && string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)) - { - return 260f; - } - - if (string.Equals(assignment.Kind, "commodity-focus", StringComparison.Ordinal) - && assignment.ItemId is not null - && outputItemIds.Contains(assignment.ItemId)) - { - return 220f; - } - - if (string.Equals(assignment.Kind, "expansion-support", StringComparison.Ordinal) - && outputItemIds.Overlaps(["energycells", "refinedmetals", "hullparts", "claytronics"])) - { - return 180f; - } - - if (string.Equals(assignment.Kind, "station-oversight", StringComparison.Ordinal) - && assignment.ItemId is not null - && outputItemIds.Contains(assignment.ItemId)) - { - return 90f; - } - - return 0f; - } - - internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) - { - var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) - || string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal) - || string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal); - return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); - } - - private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) - { - if (recipe.ShipOutputId is not null) - { - if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) - { - return false; - } - - if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f) - { - return false; - } - } - - if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount)) - { - return false; - } - - return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount)); - } - - private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) - { - if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - return false; - } - - var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) - { - return false; - } - - var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); - if (capacity <= 0.01f) - { - return false; - } - - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind) - .Sum(entry => entry.Value); - return used + amount <= capacity + 0.001f; - } - - private static bool HasRefineryCapability(StationRuntime station) => - HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01"); - - internal static string NormalizeStationObjective(string? objective) - { - return objective?.Trim().ToLowerInvariant() switch - { - "power" or "energy" or "energycells" => "power", - "water" or "ice-refinery" => "water", - "refinery" or "refinedmetals" => "refinery", - "hullparts" or "hull" => "hullparts", - "claytronics" or "clay" => "claytronics", - "graphene" => "graphene", - "siliconwafers" or "silicon-wafers" or "silicon" => "siliconwafers", - "antimattercells" or "antimatter-cells" => "antimattercells", - "superfluidcoolant" or "superfluid-coolant" => "superfluidcoolant", - "quantumtubes" or "quantum-tubes" => "quantumtubes", - "shipyard" or "ship-production" => "shipyard", - _ => "general", - }; - } - - internal static string DetermineStationRole(StationRuntime station) - { - var objective = NormalizeStationObjective(station.Objective); - if (!string.Equals(objective, "general", StringComparison.Ordinal)) - { - return objective; - } - - if (HasStationModules(station, "module_gen_build_l_01")) - { - return "shipyard"; - } - - if (HasStationModules(station, "module_gen_prod_water_01")) - { - return "water"; - } - - if (HasStationModules(station, "module_gen_prod_superfluidcoolant_01")) - { - return "superfluidcoolant"; - } - - if (HasStationModules(station, "module_gen_prod_quantumtubes_01")) - { - return "quantumtubes"; - } - - if (HasStationModules(station, "module_gen_prod_antimattercells_01")) - { - return "antimattercells"; - } - - if (HasStationModules(station, "module_gen_prod_siliconwafers_01")) - { - return "siliconwafers"; - } - - if (HasStationModules(station, "module_gen_prod_graphene_01")) - { - return "graphene"; - } - - if (HasStationModules(station, "module_gen_prod_claytronics_01")) - { - return "claytronics"; - } - - if (HasStationModules(station, "module_gen_prod_hullparts_01")) - { - return "hullparts"; - } - - if (HasStationModules(station, "module_gen_prod_refinedmetals_01")) - { - return "refinery"; - } - - if (HasStationModules(station, "module_gen_prod_energycells_01")) - { - return "power"; - } - - return "general"; - } - - private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId) - { - if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required)) - { - return 0f; - } - - return MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, itemId)); - } - - private static void AddDemandOrder(ICollection desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase) - { - var current = GetInventoryAmount(station.Inventory, itemId); - if (current >= targetAmount - 0.01f) - { - return; - } - - var deficit = targetAmount - current; - var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount); - desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null)); - } - - private static void AddSupplyOrder(ICollection desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase) - { - var current = GetInventoryAmount(station.Inventory, itemId); - if (current <= triggerAmount + 0.01f) - { - return; - } - - var surplus = current - reserveFloor; - if (surplus <= 0.01f) - { - return; - } - - var surplusRatio = triggerAmount <= 0.01f ? 1f : MathF.Min(1f, surplus / triggerAmount); - var liquidationValuation = MathF.Max(0.05f, valuationBase * (1f - (0.85f * surplusRatio))); - desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, liquidationValuation, reserveFloor)); - } - - private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) - { - var existingOrders = world.MarketOrders - .Where(order => order.StationId == station.Id && order.ConstructionSiteId is null) - .ToList(); - - foreach (var desired in desiredOrders) - { - var order = existingOrders.FirstOrDefault(candidate => - candidate.Kind == desired.Kind && - candidate.ItemId == desired.ItemId && - candidate.ConstructionSiteId is null); - - if (order is null) - { - order = new MarketOrderRuntime + var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) { - Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}", - FactionId = station.FactionId, - StationId = station.Id, - Kind = desired.Kind, - ItemId = desired.ItemId, - Amount = desired.Amount, - RemainingAmount = desired.Amount, - Valuation = desired.Valuation, - ReserveThreshold = desired.ReserveThreshold, - State = MarketOrderStateKinds.Open, + return false; + } + + var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); + if (capacity <= 0.01f) + { + return false; + } + + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind) + .Sum(entry => entry.Value); + return used + amount <= capacity + 0.001f; + } + + private static bool HasRefineryCapability(StationRuntime station) => + HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01"); + + internal static string NormalizeStationObjective(string? objective) + { + return objective?.Trim().ToLowerInvariant() switch + { + "power" or "energy" or "energycells" => "power", + "water" or "ice-refinery" => "water", + "refinery" or "refinedmetals" => "refinery", + "hullparts" or "hull" => "hullparts", + "claytronics" or "clay" => "claytronics", + "graphene" => "graphene", + "siliconwafers" or "silicon-wafers" or "silicon" => "siliconwafers", + "antimattercells" or "antimatter-cells" => "antimattercells", + "superfluidcoolant" or "superfluid-coolant" => "superfluidcoolant", + "quantumtubes" or "quantum-tubes" => "quantumtubes", + "shipyard" or "ship-production" => "shipyard", + _ => "general", }; - world.MarketOrders.Add(order); - station.MarketOrderIds.Add(order.Id); - existingOrders.Add(order); - continue; - } - - order.RemainingAmount = desired.Amount; - order.Valuation = desired.Valuation; - order.ReserveThreshold = desired.ReserveThreshold; - order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open; } - foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId))) + internal static string DetermineStationRole(StationRuntime station) { - order.RemainingAmount = 0f; - order.State = MarketOrderStateKinds.Cancelled; - } - } + var objective = NormalizeStationObjective(station.Objective); + if (!string.Equals(objective, "general", StringComparison.Ordinal)) + { + return objective; + } - internal static float GetFactionExpansionPressure(SimulationWorld world, string factionId) - { - var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); - var controlledSystems = GetFactionControlledSystemsCount(world, factionId); - var deficit = Math.Max(0, targetSystems - controlledSystems); - var contestedSystems = world.Geopolitics?.Territory.ControlStates.Count(state => - state.IsContested - && (string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal) - || string.Equals(state.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) - || state.ClaimantFactionIds.Contains(factionId, StringComparer.Ordinal))) ?? 0; - var frontierSystems = world.Geopolitics?.Territory.Zones.Count(zone => - string.Equals(zone.FactionId, factionId, StringComparison.Ordinal) - && zone.Kind is "frontier" or "corridor" or "contested") ?? 0; - return Math.Clamp((deficit / (float)targetSystems) + (contestedSystems * 0.12f) + (frontierSystems * 0.04f), 0f, 1f); - } + if (HasStationModules(station, "module_gen_build_l_01")) + { + return "shipyard"; + } - internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) - { - return GeopoliticalSimulationService.GetControlledSystems(world, factionId).Count; - } + if (HasStationModules(station, "module_gen_prod_water_01")) + { + return "water"; + } - private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve) - { - var commodity = economy.GetCommodity(itemId); - if (commodity.Level == CommodityLevelKind.Critical) - { - return baseReserve * 1.6f; + if (HasStationModules(station, "module_gen_prod_superfluidcoolant_01")) + { + return "superfluidcoolant"; + } + + if (HasStationModules(station, "module_gen_prod_quantumtubes_01")) + { + return "quantumtubes"; + } + + if (HasStationModules(station, "module_gen_prod_antimattercells_01")) + { + return "antimattercells"; + } + + if (HasStationModules(station, "module_gen_prod_siliconwafers_01")) + { + return "siliconwafers"; + } + + if (HasStationModules(station, "module_gen_prod_graphene_01")) + { + return "graphene"; + } + + if (HasStationModules(station, "module_gen_prod_claytronics_01")) + { + return "claytronics"; + } + + if (HasStationModules(station, "module_gen_prod_hullparts_01")) + { + return "hullparts"; + } + + if (HasStationModules(station, "module_gen_prod_refinedmetals_01")) + { + return "refinery"; + } + + if (HasStationModules(station, "module_gen_prod_energycells_01")) + { + return "power"; + } + + return "general"; } - return commodity.Level switch + private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId) { - CommodityLevelKind.Low => baseReserve * 1.25f, - CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseReserve * 1.1f, - _ => MathF.Max(0f, baseReserve), - }; - } + if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required)) + { + return 0f; + } - private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger) - { - var commodity = economy.GetCommodity(itemId); - return commodity.NetRatePerSecond < -0.01f ? baseTrigger * 1.2f : baseTrigger; - } - - private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) - { - var commodity = economy.GetCommodity(itemId); - return commodity.Level switch - { - CommodityLevelKind.Critical => baseValuation * 1.6f, - CommodityLevelKind.Low => baseValuation * 1.3f, - CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseValuation * 1.15f, - CommodityLevelKind.Surplus when commodity.ProjectedNetRatePerSecond > 0.01f => baseValuation * 0.9f, - _ => commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.15f, - }; - } - - private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) - { - var commodity = economy.GetCommodity(itemId); - return commodity.Level == CommodityLevelKind.Surplus && commodity.NetRatePerSecond > 0.01f - ? baseValuation * 0.75f - : commodity.Level == CommodityLevelKind.Critical - ? baseValuation * 1.15f - : baseValuation; - } - - private static List ApplyRegionalMarketModifiers(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) - { - var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, station.FactionId, station.SystemId); - if (region is null) - { - return desiredOrders.ToList(); + return MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, itemId)); } - var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); - var economic = world.Geopolitics?.EconomyRegions.EconomicAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); - var bottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks - .Where(bottleneck => string.Equals(bottleneck.RegionId, region.Id, StringComparison.Ordinal)) - .ToDictionary(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); - var riskMultiplier = 1f + ((security?.SupplyRisk ?? 0f) * 0.3f) + ((security?.AccessFriction ?? 0f) * 0.2f); - var sustainmentFloor = 1f + MathF.Max(0f, 0.55f - (economic?.SustainmentScore ?? 1f)); - - return desiredOrders - .Select(order => - { - bottlenecks.TryGetValue(order.ItemId, out var bottleneck); - var severity = bottleneck?.Severity ?? 0f; - var buyBias = order.Kind == MarketOrderKinds.Buy ? 1f + (severity * 0.08f) : 1f; - var sellBias = order.Kind == MarketOrderKinds.Sell && severity > 0f ? MathF.Max(0.35f, 1f - (severity * 0.07f)) : 1f; - var amount = order.Amount * (order.Kind == MarketOrderKinds.Buy ? riskMultiplier * buyBias * sustainmentFloor : sellBias); - var valuation = order.Valuation * (order.Kind == MarketOrderKinds.Buy - ? 1f + (severity * 0.06f) + ((security?.SupplyRisk ?? 0f) * 0.18f) - : 1f + (severity * 0.04f)); - float? reserveThreshold = order.ReserveThreshold.HasValue - ? order.ReserveThreshold.Value * (1f + ((security?.SupplyRisk ?? 0f) * 0.15f)) - : null; - return new DesiredMarketOrder(order.Kind, order.ItemId, amount, valuation, reserveThreshold); - }) - .ToList(); - } - - private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind) - { - var economic = FindFactionEconomicAssessment(world, factionId); - var threat = FindFactionThreatAssessment(world, factionId); - if (economic is null || threat is null) + private static void AddDemandOrder(ICollection desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase) { - return 0f; + var current = GetInventoryAmount(station.Inventory, itemId); + if (current >= targetAmount - 0.01f) + { + return; + } + + var deficit = targetAmount - current; + var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount); + desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null)); } - return shipKind switch + private static void AddSupplyOrder(ICollection desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase) { - "military" => threat.EnemyFactionCount > 0 - ? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f - : 0.1f, - "construction" => economic.PrimaryExpansionSiteId is not null - ? economic.ConstructorShipCount < 1 ? 1f : 0.35f - : economic.ConstructorShipCount < 1 ? 0.5f : 0f, - "transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f, - _ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f, - _ => 0.15f, - }; - } + var current = GetInventoryAmount(station.Inventory, itemId); + if (current <= triggerAmount + 0.01f) + { + return; + } - private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) - => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); + var surplus = current - reserveFloor; + if (surplus <= 0.01f) + { + return; + } - private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); + var surplusRatio = triggerAmount <= 0.01f ? 1f : MathF.Min(1f, surplus / triggerAmount); + var liquidationValuation = MathF.Max(0.05f, valuationBase * (1f - (0.85f * surplusRatio))); + desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, liquidationValuation, reserveFloor)); + } + + private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) + { + var existingOrders = world.MarketOrders + .Where(order => order.StationId == station.Id && order.ConstructionSiteId is null) + .ToList(); + + foreach (var desired in desiredOrders) + { + var order = existingOrders.FirstOrDefault(candidate => + candidate.Kind == desired.Kind && + candidate.ItemId == desired.ItemId && + candidate.ConstructionSiteId is null); + + if (order is null) + { + order = new MarketOrderRuntime + { + Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}", + FactionId = station.FactionId, + StationId = station.Id, + Kind = desired.Kind, + ItemId = desired.ItemId, + Amount = desired.Amount, + RemainingAmount = desired.Amount, + Valuation = desired.Valuation, + ReserveThreshold = desired.ReserveThreshold, + State = MarketOrderStateKinds.Open, + }; + world.MarketOrders.Add(order); + station.MarketOrderIds.Add(order.Id); + existingOrders.Add(order); + continue; + } + + order.RemainingAmount = desired.Amount; + order.Valuation = desired.Valuation; + order.ReserveThreshold = desired.ReserveThreshold; + order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open; + } + + foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId))) + { + order.RemainingAmount = 0f; + order.State = MarketOrderStateKinds.Cancelled; + } + } + + internal static float GetFactionExpansionPressure(SimulationWorld world, string factionId) + { + var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); + var controlledSystems = GetFactionControlledSystemsCount(world, factionId); + var deficit = Math.Max(0, targetSystems - controlledSystems); + var contestedSystems = world.Geopolitics?.Territory.ControlStates.Count(state => + state.IsContested + && (string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal) + || string.Equals(state.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) + || state.ClaimantFactionIds.Contains(factionId, StringComparer.Ordinal))) ?? 0; + var frontierSystems = world.Geopolitics?.Territory.Zones.Count(zone => + string.Equals(zone.FactionId, factionId, StringComparison.Ordinal) + && zone.Kind is "frontier" or "corridor" or "contested") ?? 0; + return Math.Clamp((deficit / (float)targetSystems) + (contestedSystems * 0.12f) + (frontierSystems * 0.04f), 0f, 1f); + } + + internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) + { + return GeopoliticalSimulationService.GetControlledSystems(world, factionId).Count; + } + + private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve) + { + var commodity = economy.GetCommodity(itemId); + if (commodity.Level == CommodityLevelKind.Critical) + { + return baseReserve * 1.6f; + } + + return commodity.Level switch + { + CommodityLevelKind.Low => baseReserve * 1.25f, + CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseReserve * 1.1f, + _ => MathF.Max(0f, baseReserve), + }; + } + + private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger) + { + var commodity = economy.GetCommodity(itemId); + return commodity.NetRatePerSecond < -0.01f ? baseTrigger * 1.2f : baseTrigger; + } + + private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) + { + var commodity = economy.GetCommodity(itemId); + return commodity.Level switch + { + CommodityLevelKind.Critical => baseValuation * 1.6f, + CommodityLevelKind.Low => baseValuation * 1.3f, + CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseValuation * 1.15f, + CommodityLevelKind.Surplus when commodity.ProjectedNetRatePerSecond > 0.01f => baseValuation * 0.9f, + _ => commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.15f, + }; + } + + private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) + { + var commodity = economy.GetCommodity(itemId); + return commodity.Level == CommodityLevelKind.Surplus && commodity.NetRatePerSecond > 0.01f + ? baseValuation * 0.75f + : commodity.Level == CommodityLevelKind.Critical + ? baseValuation * 1.15f + : baseValuation; + } + + private static List ApplyRegionalMarketModifiers(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) + { + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, station.FactionId, station.SystemId); + if (region is null) + { + return desiredOrders.ToList(); + } + + var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); + var economic = world.Geopolitics?.EconomyRegions.EconomicAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); + var bottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.RegionId, region.Id, StringComparison.Ordinal)) + .ToDictionary(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); + var riskMultiplier = 1f + ((security?.SupplyRisk ?? 0f) * 0.3f) + ((security?.AccessFriction ?? 0f) * 0.2f); + var sustainmentFloor = 1f + MathF.Max(0f, 0.55f - (economic?.SustainmentScore ?? 1f)); + + return desiredOrders + .Select(order => + { + bottlenecks.TryGetValue(order.ItemId, out var bottleneck); + var severity = bottleneck?.Severity ?? 0f; + var buyBias = order.Kind == MarketOrderKinds.Buy ? 1f + (severity * 0.08f) : 1f; + var sellBias = order.Kind == MarketOrderKinds.Sell && severity > 0f ? MathF.Max(0.35f, 1f - (severity * 0.07f)) : 1f; + var amount = order.Amount * (order.Kind == MarketOrderKinds.Buy ? riskMultiplier * buyBias * sustainmentFloor : sellBias); + var valuation = order.Valuation * (order.Kind == MarketOrderKinds.Buy + ? 1f + (severity * 0.06f) + ((security?.SupplyRisk ?? 0f) * 0.18f) + : 1f + (severity * 0.04f)); + float? reserveThreshold = order.ReserveThreshold.HasValue + ? order.ReserveThreshold.Value * (1f + ((security?.SupplyRisk ?? 0f) * 0.15f)) + : null; + return new DesiredMarketOrder(order.Kind, order.ItemId, amount, valuation, reserveThreshold); + }) + .ToList(); + } + + private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind) + { + var economic = FindFactionEconomicAssessment(world, factionId); + var threat = FindFactionThreatAssessment(world, factionId); + if (economic is null || threat is null) + { + return 0f; + } + + return shipKind switch + { + "military" => threat.EnemyFactionCount > 0 + ? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f + : 0.1f, + "construction" => economic.PrimaryExpansionSiteId is not null + ? economic.ConstructorShipCount < 1 ? 1f : 0.35f + : economic.ConstructorShipCount < 1 ? 0.5f : 0f, + "transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f, + _ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f, + _ => 0.15f, + }; + } + + private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) + => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); + + private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); } diff --git a/apps/backend/Universe/Api/GetBalanceHandler.cs b/apps/backend/Universe/Api/GetBalanceHandler.cs index 90abbe0..d49d856 100644 --- a/apps/backend/Universe/Api/GetBalanceHandler.cs +++ b/apps/backend/Universe/Api/GetBalanceHandler.cs @@ -5,12 +5,12 @@ namespace SpaceGame.Api.Universe.Api; public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Get("/api/balance"); - AllowAnonymous(); - } + public override void Configure() + { + Get("/api/balance"); + AllowAnonymous(); + } - public override Task HandleAsync(CancellationToken cancellationToken) => - SendOkAsync(worldService.GetBalance(), cancellationToken); + public override Task HandleAsync(CancellationToken cancellationToken) => + SendOkAsync(worldService.GetBalance(), cancellationToken); } diff --git a/apps/backend/Universe/Api/GetTelemetryHandler.cs b/apps/backend/Universe/Api/GetTelemetryHandler.cs index 52afc8f..5aebea8 100644 --- a/apps/backend/Universe/Api/GetTelemetryHandler.cs +++ b/apps/backend/Universe/Api/GetTelemetryHandler.cs @@ -6,44 +6,44 @@ namespace SpaceGame.Api.Universe.Api; public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Get("/api/telemetry"); - AllowAnonymous(); - } - - public override Task HandleAsync(CancellationToken cancellationToken) - { - var status = worldService.GetStatus(); - var connections = worldService.GetConnectionStats(); - var uptime = telemetry.Uptime; - - return SendOkAsync(new + public override void Configure() { - process = new - { - uptimeSeconds = uptime.TotalSeconds, - cpuPercent = Math.Round(telemetry.CpuPercent, 1), - workingSetMb = Math.Round(telemetry.WorkingSetBytes / 1_048_576.0, 1), - gcMemoryMb = Math.Round(telemetry.GcMemoryBytes / 1_048_576.0, 1), - threadCount = telemetry.ThreadCount, - processorCount = Environment.ProcessorCount, - }, - simulation = new - { - sequence = status.Sequence, - connectedClients = connections.ConnectedClients, - deltaHistoryCount = connections.DeltaHistoryCount, - tickIntervalMs = 200, - }, - runtime = new - { - frameworkDescription = RuntimeInformation.FrameworkDescription, - osDescription = RuntimeInformation.OSDescription, - gcGen0 = GC.CollectionCount(0), - gcGen1 = GC.CollectionCount(1), - gcGen2 = GC.CollectionCount(2), - }, - }, cancellationToken); - } + Get("/api/telemetry"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken cancellationToken) + { + var status = worldService.GetStatus(); + var connections = worldService.GetConnectionStats(); + var uptime = telemetry.Uptime; + + return SendOkAsync(new + { + process = new + { + uptimeSeconds = uptime.TotalSeconds, + cpuPercent = Math.Round(telemetry.CpuPercent, 1), + workingSetMb = Math.Round(telemetry.WorkingSetBytes / 1_048_576.0, 1), + gcMemoryMb = Math.Round(telemetry.GcMemoryBytes / 1_048_576.0, 1), + threadCount = telemetry.ThreadCount, + processorCount = Environment.ProcessorCount, + }, + simulation = new + { + sequence = status.Sequence, + connectedClients = connections.ConnectedClients, + deltaHistoryCount = connections.DeltaHistoryCount, + tickIntervalMs = 200, + }, + runtime = new + { + frameworkDescription = RuntimeInformation.FrameworkDescription, + osDescription = RuntimeInformation.OSDescription, + gcGen0 = GC.CollectionCount(0), + gcGen1 = GC.CollectionCount(1), + gcGen2 = GC.CollectionCount(2), + }, + }, cancellationToken); + } } diff --git a/apps/backend/Universe/Api/GetWorldHandler.cs b/apps/backend/Universe/Api/GetWorldHandler.cs index 12589a5..2d01e8e 100644 --- a/apps/backend/Universe/Api/GetWorldHandler.cs +++ b/apps/backend/Universe/Api/GetWorldHandler.cs @@ -4,12 +4,12 @@ namespace SpaceGame.Api.Universe.Api; public sealed class GetWorldHandler(WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Get("/api/world"); - AllowAnonymous(); - } + public override void Configure() + { + Get("/api/world"); + AllowAnonymous(); + } - public override Task HandleAsync(CancellationToken cancellationToken) => - SendOkAsync(worldService.GetSnapshot(), cancellationToken); + public override Task HandleAsync(CancellationToken cancellationToken) => + SendOkAsync(worldService.GetSnapshot(), cancellationToken); } diff --git a/apps/backend/Universe/Api/GetWorldHealthHandler.cs b/apps/backend/Universe/Api/GetWorldHealthHandler.cs index 93be569..a5429b5 100644 --- a/apps/backend/Universe/Api/GetWorldHealthHandler.cs +++ b/apps/backend/Universe/Api/GetWorldHealthHandler.cs @@ -4,20 +4,20 @@ namespace SpaceGame.Api.Universe.Api; public sealed class GetWorldHealthHandler(WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Get("/api/world/health"); - AllowAnonymous(); - } - - public override Task HandleAsync(CancellationToken cancellationToken) - { - var status = worldService.GetStatus(); - return SendOkAsync(new + public override void Configure() { - ok = true, - sequence = status.Sequence, - generatedAtUtc = status.GeneratedAtUtc, - }, cancellationToken); - } + Get("/api/world/health"); + AllowAnonymous(); + } + + public override Task HandleAsync(CancellationToken cancellationToken) + { + var status = worldService.GetStatus(); + return SendOkAsync(new + { + ok = true, + sequence = status.Sequence, + generatedAtUtc = status.GeneratedAtUtc, + }, cancellationToken); + } } diff --git a/apps/backend/Universe/Api/ResetWorldHandler.cs b/apps/backend/Universe/Api/ResetWorldHandler.cs index 4e84b05..f5ea4e0 100644 --- a/apps/backend/Universe/Api/ResetWorldHandler.cs +++ b/apps/backend/Universe/Api/ResetWorldHandler.cs @@ -4,12 +4,12 @@ namespace SpaceGame.Api.Universe.Api; public sealed class ResetWorldHandler(WorldService worldService) : EndpointWithoutRequest { - public override void Configure() - { - Post("/api/world/reset"); - AllowAnonymous(); - } + public override void Configure() + { + Post("/api/world/reset"); + AllowAnonymous(); + } - public override Task HandleAsync(CancellationToken cancellationToken) => - SendOkAsync(worldService.Reset(), cancellationToken); + public override Task HandleAsync(CancellationToken cancellationToken) => + SendOkAsync(worldService.Reset(), cancellationToken); } diff --git a/apps/backend/Universe/Api/RootRedirectHandler.cs b/apps/backend/Universe/Api/RootRedirectHandler.cs index ebc6604..5324967 100644 --- a/apps/backend/Universe/Api/RootRedirectHandler.cs +++ b/apps/backend/Universe/Api/RootRedirectHandler.cs @@ -4,15 +4,15 @@ namespace SpaceGame.Api.Universe.Api; public sealed class RootRedirectHandler : EndpointWithoutRequest { - public override void Configure() - { - Get("/"); - AllowAnonymous(); - } + public override void Configure() + { + Get("/"); + AllowAnonymous(); + } - public override Task HandleAsync(CancellationToken cancellationToken) - { - HttpContext.Response.Redirect("/api/world"); - return Task.CompletedTask; - } + public override Task HandleAsync(CancellationToken cancellationToken) + { + HttpContext.Response.Redirect("/api/world"); + return Task.CompletedTask; + } } diff --git a/apps/backend/Universe/Api/StreamWorldHandler.cs b/apps/backend/Universe/Api/StreamWorldHandler.cs index a91ca5a..45da9bf 100644 --- a/apps/backend/Universe/Api/StreamWorldHandler.cs +++ b/apps/backend/Universe/Api/StreamWorldHandler.cs @@ -5,49 +5,49 @@ namespace SpaceGame.Api.Universe.Api; public sealed class StreamWorldHandler(WorldService worldService) : EndpointWithoutRequest { - private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web); + private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web); - public override void Configure() - { - Get("/api/world/stream"); - AllowAnonymous(); - } - - public override async Task HandleAsync(CancellationToken cancellationToken) - { - HttpContext.Response.Headers.Append("Cache-Control", "no-cache"); - HttpContext.Response.Headers.Append("Content-Type", "text/event-stream"); - - var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString(); - _ = long.TryParse(afterSequenceRaw, out var afterSequence); - - var scopeKind = HttpContext.Request.Query["scopeKind"].ToString(); - if (string.IsNullOrWhiteSpace(scopeKind)) + public override void Configure() { - scopeKind = HttpContext.Request.Query["scope"].ToString(); + Get("/api/world/stream"); + AllowAnonymous(); } - if (string.IsNullOrWhiteSpace(scopeKind)) + public override async Task HandleAsync(CancellationToken cancellationToken) { - scopeKind = "universe"; + HttpContext.Response.Headers.Append("Cache-Control", "no-cache"); + HttpContext.Response.Headers.Append("Content-Type", "text/event-stream"); + + var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString(); + _ = long.TryParse(afterSequenceRaw, out var afterSequence); + + var scopeKind = HttpContext.Request.Query["scopeKind"].ToString(); + if (string.IsNullOrWhiteSpace(scopeKind)) + { + scopeKind = HttpContext.Request.Query["scope"].ToString(); + } + + if (string.IsNullOrWhiteSpace(scopeKind)) + { + scopeKind = "universe"; + } + + var systemId = HttpContext.Request.Query["systemId"].ToString(); + var bubbleId = HttpContext.Request.Query["bubbleId"].ToString(); + var scope = new ObserverScope( + scopeKind, + string.IsNullOrWhiteSpace(systemId) ? null : systemId, + string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId); + var stream = worldService.Subscribe(scope, afterSequence, cancellationToken); + + await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken); + await HttpContext.Response.Body.FlushAsync(cancellationToken); + + await foreach (var delta in stream.ReadAllAsync(cancellationToken)) + { + var payload = JsonSerializer.Serialize(delta, SseJsonOptions); + await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken); + await HttpContext.Response.Body.FlushAsync(cancellationToken); + } } - - var systemId = HttpContext.Request.Query["systemId"].ToString(); - var bubbleId = HttpContext.Request.Query["bubbleId"].ToString(); - var scope = new ObserverScope( - scopeKind, - string.IsNullOrWhiteSpace(systemId) ? null : systemId, - string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId); - var stream = worldService.Subscribe(scope, afterSequence, cancellationToken); - - await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken); - await HttpContext.Response.Body.FlushAsync(cancellationToken); - - await foreach (var delta in stream.ReadAllAsync(cancellationToken)) - { - var payload = JsonSerializer.Serialize(delta, SseJsonOptions); - await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken); - await HttpContext.Response.Body.FlushAsync(cancellationToken); - } - } } diff --git a/apps/backend/Universe/Api/UpdateBalanceHandler.cs b/apps/backend/Universe/Api/UpdateBalanceHandler.cs index 710be95..85d46dc 100644 --- a/apps/backend/Universe/Api/UpdateBalanceHandler.cs +++ b/apps/backend/Universe/Api/UpdateBalanceHandler.cs @@ -6,15 +6,15 @@ namespace SpaceGame.Api.Universe.Api; public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint { - public override void Configure() - { - Put("/api/balance"); - AllowAnonymous(); - } + public override void Configure() + { + Put("/api/balance"); + AllowAnonymous(); + } - public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken) - { - var applied = worldService.UpdateBalance(req); - return SendOkAsync(applied, cancellationToken); - } + public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken) + { + var applied = worldService.UpdateBalance(req); + return SendOkAsync(applied, cancellationToken); + } } diff --git a/apps/backend/Universe/Scenario/DataCatalogLoader.cs b/apps/backend/Universe/Scenario/DataCatalogLoader.cs index 1af0a2d..7d13beb 100644 --- a/apps/backend/Universe/Scenario/DataCatalogLoader.cs +++ b/apps/backend/Universe/Scenario/DataCatalogLoader.cs @@ -5,292 +5,292 @@ namespace SpaceGame.Api.Universe.Scenario; internal sealed class DataCatalogLoader(string dataRoot) { - private readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - - internal ScenarioCatalog LoadCatalog() - { - var authoredSystems = Read>("systems.json"); - var scenario = Read("scenario.json"); - var modules = NormalizeModules(Read>("modules.json")); - var ships = Read>("ships.json"); - var items = NormalizeItems(Read>("items.json")); - var balance = Read("balance.json"); - var recipes = BuildRecipes(items, ships, modules); - var moduleRecipes = BuildModuleRecipes(modules); - var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); - - return new ScenarioCatalog( - authoredSystems, - scenario, - balance, - modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal), - ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), - items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), - recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal), - moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal), - productionGraph); - } - - internal ScenarioDefinition NormalizeScenarioToAvailableSystems( - ScenarioDefinition scenario, - IReadOnlyList availableSystemIds) - { - if (availableSystemIds.Count == 0) + private readonly JsonSerializerOptions _jsonOptions = new() { - return scenario; + PropertyNameCaseInsensitive = true, + }; + + internal ScenarioCatalog LoadCatalog() + { + var authoredSystems = Read>("systems.json"); + var scenario = Read("scenario.json"); + var modules = NormalizeModules(Read>("modules.json")); + var ships = Read>("ships.json"); + var items = NormalizeItems(Read>("items.json")); + var balance = Read("balance.json"); + var recipes = BuildRecipes(items, ships, modules); + var moduleRecipes = BuildModuleRecipes(modules); + var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); + + return new ScenarioCatalog( + authoredSystems, + scenario, + balance, + modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal), + ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), + items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), + recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal), + moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal), + productionGraph); } - var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal) - ? "sol" - : availableSystemIds[0]; - - string ResolveSystemId(string systemId) => - availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId; - - return new ScenarioDefinition + internal ScenarioDefinition NormalizeScenarioToAvailableSystems( + ScenarioDefinition scenario, + IReadOnlyList availableSystemIds) { - InitialStations = scenario.InitialStations - .Select(station => new InitialStationDefinition + if (availableSystemIds.Count == 0) { - SystemId = ResolveSystemId(station.SystemId), - Label = station.Label, - Color = station.Color, - Objective = station.Objective, - StartingModules = station.StartingModules.ToList(), - FactionId = station.FactionId, - PlanetIndex = station.PlanetIndex, - LagrangeSide = station.LagrangeSide, - Position = station.Position?.ToArray(), - }) - .ToList(), - ShipFormations = scenario.ShipFormations - .Select(formation => new ShipFormationDefinition + return scenario; + } + + var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal) + ? "sol" + : availableSystemIds[0]; + + string ResolveSystemId(string systemId) => + availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId; + + return new ScenarioDefinition { - ShipId = formation.ShipId, - Count = formation.Count, - Center = formation.Center.ToArray(), - SystemId = ResolveSystemId(formation.SystemId), - FactionId = formation.FactionId, - StartingInventory = new Dictionary(formation.StartingInventory, StringComparer.Ordinal), - }) - .ToList(), - PatrolRoutes = scenario.PatrolRoutes - .Select(route => new PatrolRouteDefinition - { - SystemId = ResolveSystemId(route.SystemId), - Points = route.Points.Select(point => point.ToArray()).ToList(), - }) - .ToList(), - MiningDefaults = new MiningDefaultsDefinition - { - NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId), - RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId), - }, - }; - } + InitialStations = scenario.InitialStations + .Select(station => new InitialStationDefinition + { + SystemId = ResolveSystemId(station.SystemId), + Label = station.Label, + Color = station.Color, + Objective = station.Objective, + StartingModules = station.StartingModules.ToList(), + FactionId = station.FactionId, + PlanetIndex = station.PlanetIndex, + LagrangeSide = station.LagrangeSide, + Position = station.Position?.ToArray(), + }) + .ToList(), + ShipFormations = scenario.ShipFormations + .Select(formation => new ShipFormationDefinition + { + ShipId = formation.ShipId, + Count = formation.Count, + Center = formation.Center.ToArray(), + SystemId = ResolveSystemId(formation.SystemId), + FactionId = formation.FactionId, + StartingInventory = new Dictionary(formation.StartingInventory, StringComparer.Ordinal), + }) + .ToList(), + PatrolRoutes = scenario.PatrolRoutes + .Select(route => new PatrolRouteDefinition + { + SystemId = ResolveSystemId(route.SystemId), + Points = route.Points.Select(point => point.ToArray()).ToList(), + }) + .ToList(), + MiningDefaults = new MiningDefaultsDefinition + { + NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId), + RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId), + }, + }; + } - private T Read(string fileName) - { - var path = Path.Combine(dataRoot, fileName); - var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, _jsonOptions) - ?? throw new InvalidOperationException($"Unable to read {fileName}."); - } - - private static List BuildModuleRecipes(IEnumerable modules) => - modules - .Where(module => module.Construction is not null || module.Production.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) - .Select(input => new RecipeInputDefinition - { - ItemId = input.ItemId, - Amount = input.Amount, - }) - .ToList(), - }) - .ToList(); - - private static List BuildRecipes(IEnumerable items, IEnumerable ships, IReadOnlyCollection modules) - { - var recipes = new List(); - var preferredProducerByItemId = modules - .Where(module => module.Products.Count > 0) - .GroupBy(module => module.Products[0], StringComparer.Ordinal) - .ToDictionary( - group => group.Key, - group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id, - StringComparer.Ordinal); - - foreach (var item in items) + private T Read(string fileName) { - if (item.Production.Count > 0) - { - foreach (var production in item.Production) + var path = Path.Combine(dataRoot, fileName); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _jsonOptions) + ?? throw new InvalidOperationException($"Unable to read {fileName}."); + } + + private static List BuildModuleRecipes(IEnumerable modules) => + modules + .Where(module => module.Construction is not null || module.Production.Count > 0) + .Select(module => new ModuleRecipeDefinition { - recipes.Add(new RecipeDefinition - { - Id = $"{item.Id}-{production.Method}-production", - Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})", - FacilityCategory = InferFacilityCategory(item), - Duration = production.Time, - Priority = InferRecipePriority(item), - RequiredModules = InferRequiredModules(item, preferredProducerByItemId), - Inputs = production.Wares - .Select(input => new RecipeInputDefinition - { + ModuleId = module.Id, + Duration = module.Construction?.ProductionTime ?? module.Production[0].Time, + Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares) + .Select(input => new RecipeInputDefinition + { ItemId = input.ItemId, Amount = input.Amount, - }) - .ToList(), - Outputs = - [ - new RecipeOutputDefinition + }) + .ToList(), + }) + .ToList(); + + private static List BuildRecipes(IEnumerable items, IEnumerable ships, IReadOnlyCollection modules) + { + var recipes = new List(); + var preferredProducerByItemId = modules + .Where(module => module.Products.Count > 0) + .GroupBy(module => module.Products[0], StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id, + StringComparer.Ordinal); + + foreach (var item in items) + { + if (item.Production.Count > 0) + { + foreach (var production in item.Production) + { + recipes.Add(new RecipeDefinition + { + Id = $"{item.Id}-{production.Method}-production", + Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})", + FacilityCategory = InferFacilityCategory(item), + Duration = production.Time, + Priority = InferRecipePriority(item), + RequiredModules = InferRequiredModules(item, preferredProducerByItemId), + Inputs = production.Wares + .Select(input => new RecipeInputDefinition + { + ItemId = input.ItemId, + Amount = input.Amount, + }) + .ToList(), + Outputs = + [ + new RecipeOutputDefinition { ItemId = item.Id, Amount = production.Amount, }, ], - }); - } + }); + } - continue; - } + continue; + } - if (item.Construction is null) - { - continue; - } + if (item.Construction is null) + { + continue; + } - recipes.Add(new RecipeDefinition - { - Id = item.Construction.RecipeId ?? $"{item.Id}-production", - Label = item.Name, - FacilityCategory = item.Construction.FacilityCategory, - Duration = item.Construction.CycleTime, - Priority = item.Construction.Priority, - RequiredModules = item.Construction.RequiredModules.ToList(), - Inputs = item.Construction.Requirements - .Select(input => new RecipeInputDefinition - { - ItemId = input.ItemId, - Amount = input.Amount, - }) - .ToList(), - Outputs = - [ - new RecipeOutputDefinition + recipes.Add(new RecipeDefinition + { + Id = item.Construction.RecipeId ?? $"{item.Id}-production", + Label = item.Name, + FacilityCategory = item.Construction.FacilityCategory, + Duration = item.Construction.CycleTime, + Priority = item.Construction.Priority, + RequiredModules = item.Construction.RequiredModules.ToList(), + Inputs = item.Construction.Requirements + .Select(input => new RecipeInputDefinition + { + ItemId = input.ItemId, + Amount = input.Amount, + }) + .ToList(), + Outputs = + [ + new RecipeOutputDefinition { ItemId = item.Id, Amount = item.Construction.BatchSize, }, ], - }); + }); + } + + foreach (var ship in ships) + { + if (ship.Construction is null) + { + continue; + } + + recipes.Add(new RecipeDefinition + { + Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction", + Label = $"{ship.Label} Construction", + FacilityCategory = ship.Construction.FacilityCategory, + Duration = ship.Construction.CycleTime, + Priority = ship.Construction.Priority, + RequiredModules = ship.Construction.RequiredModules.ToList(), + Inputs = ship.Construction.Requirements + .Select(input => new RecipeInputDefinition + { + ItemId = input.ItemId, + Amount = input.Amount, + }) + .ToList(), + ShipOutputId = ship.Id, + }); + } + + return recipes; } - foreach (var ship in ships) - { - if (ship.Construction is null) + private static string InferFacilityCategory(ItemDefinition item) => + item.Group switch { - continue; - } + "agricultural" or "food" or "pharmaceutical" or "water" => "farm", + _ => "station", + }; - recipes.Add(new RecipeDefinition - { - Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction", - Label = $"{ship.Label} Construction", - FacilityCategory = ship.Construction.FacilityCategory, - Duration = ship.Construction.CycleTime, - Priority = ship.Construction.Priority, - RequiredModules = ship.Construction.RequiredModules.ToList(), - Inputs = ship.Construction.Requirements - .Select(input => new RecipeInputDefinition - { - ItemId = input.ItemId, - Amount = input.Amount, - }) - .ToList(), - ShipOutputId = ship.Id, - }); + private static List InferRequiredModules(ItemDefinition item, IReadOnlyDictionary preferredProducerByItemId) + { + if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId)) + { + return [moduleId]; + } + + return []; } - return recipes; - } + private static int InferRecipePriority(ItemDefinition item) => + item.Group switch + { + "energy" => 140, + "water" => 130, + "food" => 120, + "agricultural" => 110, + "refined" => 100, + "hightech" => 90, + "shiptech" => 80, + "pharmaceutical" => 70, + _ => 60, + }; - private static string InferFacilityCategory(ItemDefinition item) => - item.Group switch + private static List NormalizeItems(List items) { - "agricultural" or "food" or "pharmaceutical" or "water" => "farm", - _ => "station", - }; + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.Type)) + { + item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group; + } + } - private static List InferRequiredModules(ItemDefinition item, IReadOnlyDictionary preferredProducerByItemId) - { - if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId)) - { - return [moduleId]; + return items; } - return []; - } - - private static int InferRecipePriority(ItemDefinition item) => - item.Group switch + private static List NormalizeModules(List modules) { - "energy" => 140, - "water" => 130, - "food" => 120, - "agricultural" => 110, - "refined" => 100, - "hightech" => 90, - "shiptech" => 80, - "pharmaceutical" => 70, - _ => 60, - }; + foreach (var module in modules) + { + if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product)) + { + module.Products = [module.Product]; + } - private static List NormalizeItems(List items) - { - foreach (var item in items) - { - if (string.IsNullOrWhiteSpace(item.Type)) - { - item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group; - } + if (string.IsNullOrWhiteSpace(module.ProductionMode)) + { + module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal) + ? "commanded" + : "passive"; + } + + if (module.WorkforceNeeded <= 0f) + { + module.WorkforceNeeded = module.WorkForce?.Max ?? 0f; + } + } + + return modules; } - - return items; - } - - private static List NormalizeModules(List modules) - { - foreach (var module in modules) - { - if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product)) - { - module.Products = [module.Product]; - } - - if (string.IsNullOrWhiteSpace(module.ProductionMode)) - { - module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal) - ? "commanded" - : "passive"; - } - - if (module.WorkforceNeeded <= 0f) - { - module.WorkforceNeeded = module.WorkForce?.Max ?? 0f; - } - } - - return modules; - } } internal sealed record ScenarioCatalog( diff --git a/apps/backend/Universe/Scenario/LoaderSupport.cs b/apps/backend/Universe/Scenario/LoaderSupport.cs index ecb322e..6ed6c16 100644 --- a/apps/backend/Universe/Scenario/LoaderSupport.cs +++ b/apps/backend/Universe/Scenario/LoaderSupport.cs @@ -3,18 +3,18 @@ namespace SpaceGame.Api.Universe.Scenario; internal static class LoaderSupport { - internal const string DefaultFactionId = "sol-dominion"; - internal const int WorldSeed = 1; - internal const float MinimumFactionCredits = 0f; - internal const float MinimumRefineryOre = 0f; - internal const float MinimumRefineryStock = 0f; - internal const float MinimumShipyardStock = 0f; - internal const float MinimumSystemSeparation = 3.2f; - internal const float LocalSpaceRadius = 10_000f; + internal const string DefaultFactionId = "sol-dominion"; + internal const int WorldSeed = 1; + internal const float MinimumFactionCredits = 0f; + internal const float MinimumRefineryOre = 0f; + internal const float MinimumRefineryStock = 0f; + internal const float MinimumShipyardStock = 0f; + internal const float MinimumSystemSeparation = 3.2f; + internal const float LocalSpaceRadius = 10_000f; - internal static readonly string[] GeneratedSystemNames = - [ - "Aquila Verge", + internal static readonly string[] GeneratedSystemNames = + [ + "Aquila Verge", "Orion Fold", "Draco Span", "Lyra Shoal", @@ -48,9 +48,9 @@ internal static class LoaderSupport "Telescopium Strand", ]; - internal static readonly StarProfile[] StarProfiles = - [ - new("main-sequence", "#ffd27a", "#ffb14a", 696340f), + internal static readonly StarProfile[] StarProfiles = + [ + new("main-sequence", "#ffd27a", "#ffb14a", 696340f), new("blue-white", "#9dc6ff", "#66a0ff", 930000f), new("white-dwarf", "#f1f5ff", "#b8caff", 12000f), new("brown-dwarf", "#b97d56", "#8a5438", 70000f), @@ -59,9 +59,9 @@ internal static class LoaderSupport new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f), ]; - internal static readonly PlanetProfile[] PlanetProfiles = - [ - new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false), + internal static readonly PlanetProfile[] PlanetProfiles = + [ + new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false), new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false), new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false), new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false), @@ -71,85 +71,85 @@ internal static class LoaderSupport new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false), ]; - internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); + internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); - internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) - { - var raw = ToVector(values); - var relativeToSystem = new Vector3( - raw.X - system.Position.X, - raw.Y - system.Position.Y, - raw.Z - system.Position.Z); - - return relativeToSystem.LengthSquared() < raw.LengthSquared() - ? relativeToSystem - : raw; - } - - internal static bool HasInstalledModules(StationRuntime station, params string[] modules) => - modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); - - internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) => - capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal)); - - internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary moduleDefinitions, string moduleId) - { - if (!moduleDefinitions.TryGetValue(moduleId, out var definition)) + internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) { - return; + var raw = ToVector(values); + var relativeToSystem = new Vector3( + raw.X - system.Position.X, + raw.Y - system.Position.Y, + raw.Z - system.Position.Z); + + return relativeToSystem.LengthSquared() < raw.LengthSquared() + ? relativeToSystem + : raw; } - station.Modules.Add(new StationModuleRuntime + internal static bool HasInstalledModules(StationRuntime station, params string[] modules) => + modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal))); + + internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) => + capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal)); + + internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary moduleDefinitions, string moduleId) { - Id = $"{station.Id}-module-{station.Modules.Count + 1}", - ModuleId = moduleId, - Health = definition.Hull, - MaxHealth = definition.Hull, - }); - station.Radius = GetStationRadius(moduleDefinitions, station); - } + if (!moduleDefinitions.TryGetValue(moduleId, out var definition)) + { + return; + } - internal static float GetStationRadius(IReadOnlyDictionary moduleDefinitions, StationRuntime station) - { - var totalArea = station.Modules - .Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f) - .Sum(); - return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f))); - } - - internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); - - internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale); - - internal static int CountModules(IEnumerable modules, string moduleId) => - modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); - - internal static float ComputeWorkforceRatio(float population, float workforceRequired) - { - if (workforceRequired <= 0.01f) - { - return 1f; + station.Modules.Add(new StationModuleRuntime + { + Id = $"{station.Id}-module-{station.Modules.Count + 1}", + ModuleId = moduleId, + Health = definition.Hull, + MaxHealth = definition.Hull, + }); + station.Radius = GetStationRadius(moduleDefinitions, station); } - var staffedRatio = MathF.Min(1f, population / workforceRequired); - return 0.1f + (0.9f * staffedRatio); - } - - internal static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => - inventory.TryGetValue(itemId, out var amount) ? amount : 0f; - - internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f); - - internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback) - { - var length = MathF.Sqrt(vector.LengthSquared()); - if (length <= 0.0001f) + internal static float GetStationRadius(IReadOnlyDictionary moduleDefinitions, StationRuntime station) { - return fallback; + var totalArea = station.Modules + .Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f) + .Sum(); + return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f))); } - return vector.Divide(length); - } + internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); + + internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale); + + internal static int CountModules(IEnumerable modules, string moduleId) => + modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); + + internal static float ComputeWorkforceRatio(float population, float workforceRequired) + { + if (workforceRequired <= 0.01f) + { + return 1f; + } + + var staffedRatio = MathF.Min(1f, population / workforceRequired); + return 0.1f + (0.9f * staffedRatio); + } + + internal static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + + internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f); + + internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback) + { + var length = MathF.Sqrt(vector.LengthSquared()); + if (length <= 0.0001f) + { + return fallback; + } + + return vector.Divide(length); + } } internal sealed record StarProfile( @@ -167,5 +167,5 @@ internal sealed record PlanetProfile( int BaseMoonCount, bool CanHaveRing) { - public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f); + public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f); } diff --git a/apps/backend/Universe/Scenario/ScenarioLoader.cs b/apps/backend/Universe/Scenario/ScenarioLoader.cs index 7dbd032..824a05d 100644 --- a/apps/backend/Universe/Scenario/ScenarioLoader.cs +++ b/apps/backend/Universe/Scenario/ScenarioLoader.cs @@ -3,24 +3,24 @@ namespace SpaceGame.Api.Universe.Scenario; public sealed class ScenarioLoader { - private readonly WorldBuilder _worldBuilder; + private readonly WorldBuilder _worldBuilder; - public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null) - { - var generationOptions = worldGeneration ?? new WorldGenerationOptions(); - var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data")); - var dataLoader = new DataCatalogLoader(dataRoot); - var generationService = new SystemGenerationService(); - var spatialBuilder = new SpatialBuilder(); - var seedingService = new WorldSeedingService(); + public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null) + { + var generationOptions = worldGeneration ?? new WorldGenerationOptions(); + var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data")); + var dataLoader = new DataCatalogLoader(dataRoot); + var generationService = new SystemGenerationService(); + var spatialBuilder = new SpatialBuilder(); + var seedingService = new WorldSeedingService(); - _worldBuilder = new WorldBuilder( - generationOptions, - dataLoader, - generationService, - spatialBuilder, - seedingService); - } + _worldBuilder = new WorldBuilder( + generationOptions, + dataLoader, + generationService, + spatialBuilder, + seedingService); + } - public SimulationWorld Load() => _worldBuilder.Build(); + public SimulationWorld Load() => _worldBuilder.Build(); } diff --git a/apps/backend/Universe/Scenario/SpatialBuilder.cs b/apps/backend/Universe/Scenario/SpatialBuilder.cs index e760009..2c82783 100644 --- a/apps/backend/Universe/Scenario/SpatialBuilder.cs +++ b/apps/backend/Universe/Scenario/SpatialBuilder.cs @@ -4,305 +4,305 @@ namespace SpaceGame.Api.Universe.Scenario; internal sealed class SpatialBuilder { - internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems, BalanceDefinition balance) - { - var systemGraphs = systems.ToDictionary( - system => system.Definition.Id, - BuildSystemSpatialGraph, - StringComparer.Ordinal); - var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); - var nodes = new List(); - var nodeIdCounter = 0; - - foreach (var system in systems) + internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems, BalanceDefinition balance) { - var systemGraph = systemGraphs[system.Definition.Id]; - foreach (var node in system.Definition.ResourceNodes) - { - var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); - nodes.Add(new ResourceNodeRuntime + var systemGraphs = systems.ToDictionary( + system => system.Definition.Id, + BuildSystemSpatialGraph, + StringComparer.Ordinal); + var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); + var nodes = new List(); + var nodeIdCounter = 0; + + foreach (var system in systems) { - Id = $"node-{++nodeIdCounter}", - SystemId = system.Definition.Id, - Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane), - SourceKind = node.SourceKind, - ItemId = node.ItemId, - CelestialId = anchorCelestial?.Id, - OrbitRadius = node.RadiusOffset, - OrbitPhase = node.Angle, - OrbitInclination = DegreesToRadians(node.InclinationDegrees), - OreRemaining = node.OreAmount, - MaxOre = node.OreAmount, - }); - } + var systemGraph = systemGraphs[system.Definition.Id]; + foreach (var node in system.Definition.ResourceNodes) + { + var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); + nodes.Add(new ResourceNodeRuntime + { + Id = $"node-{++nodeIdCounter}", + SystemId = system.Definition.Id, + Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane), + SourceKind = node.SourceKind, + ItemId = node.ItemId, + CelestialId = anchorCelestial?.Id, + OrbitRadius = node.RadiusOffset, + OrbitPhase = node.Angle, + OrbitInclination = DegreesToRadians(node.InclinationDegrees), + OreRemaining = node.OreAmount, + MaxOre = node.OreAmount, + }); + } + } + + return new ScenarioSpatialLayout(systemGraphs, celestials, nodes); } - return new ScenarioSpatialLayout(systemGraphs, celestials, nodes); - } - - private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) - { - var celestials = new List(); - var lagrangeNodesByPlanetIndex = new Dictionary>(); - - for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1) + private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) { - AddCelestial( - celestials, - id: $"node-{system.Definition.Id}-star-{starIndex + 1}", - systemId: system.Definition.Id, - kind: SpatialNodeKind.Star, - position: Vector3.Zero, - localSpaceRadius: LocalSpaceRadius); + var celestials = new List(); + var lagrangeNodesByPlanetIndex = new Dictionary>(); + + for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1) + { + AddCelestial( + celestials, + id: $"node-{system.Definition.Id}-star-{starIndex + 1}", + systemId: system.Definition.Id, + kind: SpatialNodeKind.Star, + position: Vector3.Zero, + localSpaceRadius: LocalSpaceRadius); + } + + var primaryStarNodeId = $"node-{system.Definition.Id}-star-1"; + + for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1) + { + var planet = system.Definition.Planets[planetIndex]; + var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}"; + var planetPosition = ComputePlanetPosition(planet); + var planetCelestial = AddCelestial( + celestials, + id: planetNodeId, + systemId: system.Definition.Id, + kind: SpatialNodeKind.Planet, + position: planetPosition, + localSpaceRadius: LocalSpaceRadius, + parentNodeId: primaryStarNodeId); + + var lagrangeNodes = new Dictionary(StringComparer.Ordinal); + foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) + { + var lagrangeCelestial = AddCelestial( + celestials, + id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}", + systemId: system.Definition.Id, + kind: SpatialNodeKind.LagrangePoint, + position: point.Position, + localSpaceRadius: LocalSpaceRadius, + parentNodeId: planetCelestial.Id, + orbitReferenceId: point.Designation); + lagrangeNodes[point.Designation] = lagrangeCelestial; + } + + lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes; + + for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1) + { + var moon = planet.Moons[moonIndex]; + var moonPosition = ComputeMoonPosition(planetPosition, moon); + AddCelestial( + celestials, + id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}", + systemId: system.Definition.Id, + kind: SpatialNodeKind.Moon, + position: moonPosition, + localSpaceRadius: LocalSpaceRadius, + parentNodeId: planetCelestial.Id); + } + } + + return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex); } - var primaryStarNodeId = $"node-{system.Definition.Id}-star-1"; - - for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1) + private static CelestialRuntime AddCelestial( + ICollection celestials, + string id, + string systemId, + SpatialNodeKind kind, + Vector3 position, + float localSpaceRadius, + string? parentNodeId = null, + string? orbitReferenceId = null) { - var planet = system.Definition.Planets[planetIndex]; - var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}"; - var planetPosition = ComputePlanetPosition(planet); - var planetCelestial = AddCelestial( - celestials, - id: planetNodeId, - systemId: system.Definition.Id, - kind: SpatialNodeKind.Planet, - position: planetPosition, - localSpaceRadius: LocalSpaceRadius, - parentNodeId: primaryStarNodeId); + var celestial = new CelestialRuntime + { + Id = id, + SystemId = systemId, + Kind = kind, + Position = position, + LocalSpaceRadius = localSpaceRadius, + ParentNodeId = parentNodeId, + OrbitReferenceId = orbitReferenceId, + }; - var lagrangeNodes = new Dictionary(StringComparer.Ordinal); - foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) - { - var lagrangeCelestial = AddCelestial( - celestials, - id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}", - systemId: system.Definition.Id, - kind: SpatialNodeKind.LagrangePoint, - position: point.Position, - localSpaceRadius: LocalSpaceRadius, - parentNodeId: planetCelestial.Id, - orbitReferenceId: point.Designation); - lagrangeNodes[point.Designation] = lagrangeCelestial; - } - - lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes; - - for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1) - { - var moon = planet.Moons[moonIndex]; - var moonPosition = ComputeMoonPosition(planetPosition, moon); - AddCelestial( - celestials, - id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}", - systemId: system.Definition.Id, - kind: SpatialNodeKind.Moon, - position: moonPosition, - localSpaceRadius: LocalSpaceRadius, - parentNodeId: planetCelestial.Id); - } + celestials.Add(celestial); + return celestial; } - return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex); - } - - private static CelestialRuntime AddCelestial( - ICollection celestials, - string id, - string systemId, - SpatialNodeKind kind, - Vector3 position, - float localSpaceRadius, - string? parentNodeId = null, - string? orbitReferenceId = null) - { - var celestial = new CelestialRuntime + private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet) { - Id = id, - SystemId = systemId, - Kind = kind, - Position = position, - LocalSpaceRadius = localSpaceRadius, - ParentNodeId = parentNodeId, - OrbitReferenceId = orbitReferenceId, + var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); + var tangential = new Vector3(-radial.Z, 0f, radial.X); + var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z); + var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet); + var triangularAngle = MathF.PI / 3f; + + yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset))); + yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset))); + yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm)); + yield return new LagrangePointPlacement( + "L4", + Add( + Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), + Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle)))); + yield return new LagrangePointPlacement( + "L5", + Add( + Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), + Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle)))); + } + + private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet) + { + var planetMassProxy = EstimatePlanetMassRatio(planet); + var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f)); + var minimumOffset = MathF.Max(planet.Size * 4f, 25000f); + return MathF.Max(minimumOffset, hillLikeOffset); + } + + private static float EstimatePlanetMassRatio(PlanetDefinition planet) + { + var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f); + var densityFactor = planet.PlanetType switch + { + "gas-giant" => 0.24f, + "ice-giant" => 0.18f, + "oceanic" => 0.95f, + "ice" => 0.7f, + _ => 1f, + }; + + var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor; + return earthMasses / 332_946f; + } + + internal static StationPlacement ResolveStationPlacement( + InitialStationDefinition plan, + SystemRuntime system, + SystemSpatialGraph graph, + IReadOnlyCollection existingCelestials) + { + if (plan.PlanetIndex is int planetIndex && + graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) + { + var designation = ResolveLagrangeDesignation(plan.LagrangeSide); + if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) + { + return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position); + } + } + + if (plan.Position is { Length: 3 }) + { + var targetPosition = NormalizeScenarioPoint(system, plan.Position); + var preferredCelestial = existingCelestials + .Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) + .OrderBy(c => c.Position.DistanceTo(targetPosition)) + .FirstOrDefault() + ?? existingCelestials + .Where(c => c.SystemId == system.Definition.Id) + .OrderBy(c => c.Position.DistanceTo(targetPosition)) + .First(); + return new StationPlacement(preferredCelestial, preferredCelestial.Position); + } + + var fallbackCelestial = graph.Celestials + .FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) + ?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); + return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); + } + + private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch + { + < 0 => "L4", + > 0 => "L5", + _ => "L1", }; - celestials.Add(celestial); - return celestial; - } - - private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet) - { - var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); - var tangential = new Vector3(-radial.Z, 0f, radial.X); - var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z); - var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet); - var triangularAngle = MathF.PI / 3f; - - yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset))); - yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset))); - yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm)); - yield return new LagrangePointPlacement( - "L4", - Add( - Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), - Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle)))); - yield return new LagrangePointPlacement( - "L5", - Add( - Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), - Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle)))); - } - - private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet) - { - var planetMassProxy = EstimatePlanetMassRatio(planet); - var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f)); - var minimumOffset = MathF.Max(planet.Size * 4f, 25000f); - return MathF.Max(minimumOffset, hillLikeOffset); - } - - private static float EstimatePlanetMassRatio(PlanetDefinition planet) - { - var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f); - var densityFactor = planet.PlanetType switch + private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) { - "gas-giant" => 0.24f, - "ice-giant" => 0.18f, - "oceanic" => 0.95f, - "ice" => 0.7f, - _ => 1f, - }; + if (!string.IsNullOrWhiteSpace(definition.AnchorReference)) + { + var anchorId = definition.AnchorReference.ToLowerInvariant() switch + { + var reference when reference.StartsWith("star-", StringComparison.Ordinal) + => $"node-{graph.SystemId}-{reference}", + var reference when reference.StartsWith("planet-", StringComparison.Ordinal) + => $"node-{graph.SystemId}-{reference}", + _ => null, + }; - var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor; - return earthMasses / 332_946f; - } + if (anchorId is not null) + { + return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal)); + } + } - internal static StationPlacement ResolveStationPlacement( - InitialStationDefinition plan, - SystemRuntime system, - SystemSpatialGraph graph, - IReadOnlyCollection existingCelestials) - { - if (plan.PlanetIndex is int planetIndex && - graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) - { - var designation = ResolveLagrangeDesignation(plan.LagrangeSide); - if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) - { - return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position); - } + if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0) + { + return null; + } + + if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) + { + var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; + return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId); + } + + var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}"; + return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId); } - if (plan.Position is { Length: 3 }) + private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane) { - var targetPosition = NormalizeScenarioPoint(system, plan.Position); - var preferredCelestial = existingCelestials - .Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) - .OrderBy(c => c.Position.DistanceTo(targetPosition)) - .FirstOrDefault() - ?? existingCelestials - .Where(c => c.SystemId == system.Definition.Id) - .OrderBy(c => c.Position.DistanceTo(targetPosition)) - .First(); - return new StationPlacement(preferredCelestial, preferredCelestial.Position); + var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f); + var offset = new Vector3( + MathF.Cos(definition.Angle) * definition.RadiusOffset, + verticalOffset, + MathF.Sin(definition.Angle) * definition.RadiusOffset); + + if (anchorCelestial is null) + { + return new Vector3(offset.X, yPlane + offset.Y, offset.Z); + } + + return Add(anchorCelestial.Position, offset); } - var fallbackCelestial = graph.Celestials - .FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) - ?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); - return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); - } - - private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch - { - < 0 => "L4", - > 0 => "L5", - _ => "L1", - }; - - private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) - { - if (!string.IsNullOrWhiteSpace(definition.AnchorReference)) + private static Vector3 ComputePlanetPosition(PlanetDefinition planet) { - var anchorId = definition.AnchorReference.ToLowerInvariant() switch - { - var reference when reference.StartsWith("star-", StringComparison.Ordinal) - => $"node-{graph.SystemId}-{reference}", - var reference when reference.StartsWith("planet-", StringComparison.Ordinal) - => $"node-{graph.SystemId}-{reference}", - _ => null, - }; - - if (anchorId is not null) - { - return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal)); - } + var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); + var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius); + return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm); } - if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0) + private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon) { - return null; + var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch); + var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius); + return Add(planetPosition, local); } - if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) + internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection celestials) { - var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; - return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId); + var nearestCelestial = celestials + .Where(c => c.SystemId == systemId) + .OrderBy(c => c.Position.DistanceTo(position)) + .FirstOrDefault(); + + return new ShipSpatialStateRuntime + { + CurrentSystemId = systemId, + SpaceLayer = SpaceLayerKinds.LocalSpace, + CurrentCelestialId = nearestCelestial?.Id, + LocalPosition = position, + SystemPosition = position, + MovementRegime = MovementRegimeKinds.LocalFlight, + }; } - - var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}"; - return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId); - } - - private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane) - { - var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f); - var offset = new Vector3( - MathF.Cos(definition.Angle) * definition.RadiusOffset, - verticalOffset, - MathF.Sin(definition.Angle) * definition.RadiusOffset); - - if (anchorCelestial is null) - { - return new Vector3(offset.X, yPlane + offset.Y, offset.Z); - } - - return Add(anchorCelestial.Position, offset); - } - - private static Vector3 ComputePlanetPosition(PlanetDefinition planet) - { - var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); - var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius); - return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm); - } - - private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon) - { - var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch); - var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius); - return Add(planetPosition, local); - } - - internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection celestials) - { - var nearestCelestial = celestials - .Where(c => c.SystemId == systemId) - .OrderBy(c => c.Position.DistanceTo(position)) - .FirstOrDefault(); - - return new ShipSpatialStateRuntime - { - CurrentSystemId = systemId, - SpaceLayer = SpaceLayerKinds.LocalSpace, - CurrentCelestialId = nearestCelestial?.Id, - LocalPosition = position, - SystemPosition = position, - MovementRegime = MovementRegimeKinds.LocalFlight, - }; - } } internal sealed record ScenarioSpatialLayout( diff --git a/apps/backend/Universe/Scenario/SystemGenerationService.cs b/apps/backend/Universe/Scenario/SystemGenerationService.cs index 5dad0e3..ee821a6 100644 --- a/apps/backend/Universe/Scenario/SystemGenerationService.cs +++ b/apps/backend/Universe/Scenario/SystemGenerationService.cs @@ -4,148 +4,148 @@ namespace SpaceGame.Api.Universe.Scenario; internal sealed class SystemGenerationService { - private const string SolSystemId = "sol"; - private const string DevelopmentCompanionSystemId = "helios"; + private const string SolSystemId = "sol"; + private const string DevelopmentCompanionSystemId = "helios"; - internal List InjectSpecialSystems(IReadOnlyList authoredSystems) => - authoredSystems - .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index)) - .ToList(); + internal List InjectSpecialSystems(IReadOnlyList authoredSystems) => + authoredSystems + .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index)) + .ToList(); - internal List ExpandSystems( - IReadOnlyList authoredSystems, - int targetSystemCount) - { - var systems = authoredSystems - .Select(CloneSystemDefinition) - .ToList(); - - if (targetSystemCount <= 0) + internal List ExpandSystems( + IReadOnlyList authoredSystems, + int targetSystemCount) { - return []; + var systems = authoredSystems + .Select(CloneSystemDefinition) + .ToList(); + + if (targetSystemCount <= 0) + { + return []; + } + + if (systems.Count > targetSystemCount) + { + return TrimSystemsToTarget(systems, targetSystemCount); + } + + if (systems.Count >= targetSystemCount || authoredSystems.Count == 0) + { + return systems; + } + + var existingIds = systems + .Select(system => system.Id) + .ToHashSet(StringComparer.Ordinal); + var generatedPositions = BuildGalaxyPositions( + authoredSystems.Select(system => ToVector(system.Position)).ToList(), + targetSystemCount - systems.Count); + + for (var index = systems.Count; index < targetSystemCount; index += 1) + { + var template = authoredSystems[index % authoredSystems.Count]; + var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; + var id = BuildGeneratedSystemId(name, index + 1); + while (!existingIds.Add(id)) + { + id = $"{id}-x"; + } + + systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count])); + } + + return systems; } - if (systems.Count > targetSystemCount) + private static List TrimSystemsToTarget(IReadOnlyList systems, int targetSystemCount) { - return TrimSystemsToTarget(systems, targetSystemCount); + var selected = new List(targetSystemCount); + + void AddById(string systemId) + { + var system = systems.FirstOrDefault(candidate => string.Equals(candidate.Id, systemId, StringComparison.Ordinal)); + if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal))) + { + selected.Add(system); + } + } + + AddById(SolSystemId); + AddById(DevelopmentCompanionSystemId); + + foreach (var system in systems) + { + if (selected.Count >= targetSystemCount) + { + break; + } + + if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal))) + { + continue; + } + + selected.Add(system); + } + + if (selected.Count > 0 && selected.Count <= 4) + { + ApplyCompactGalaxyLayout(selected); + } + + return selected; } - if (systems.Count >= targetSystemCount || authoredSystems.Count == 0) - { - return systems; - } - - var existingIds = systems - .Select(system => system.Id) - .ToHashSet(StringComparer.Ordinal); - var generatedPositions = BuildGalaxyPositions( - authoredSystems.Select(system => ToVector(system.Position)).ToList(), - targetSystemCount - systems.Count); - - for (var index = systems.Count; index < targetSystemCount; index += 1) - { - var template = authoredSystems[index % authoredSystems.Count]; - var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; - var id = BuildGeneratedSystemId(name, index + 1); - while (!existingIds.Add(id)) - { - id = $"{id}-x"; - } - - systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count])); - } - - return systems; - } - - private static List TrimSystemsToTarget(IReadOnlyList systems, int targetSystemCount) - { - var selected = new List(targetSystemCount); - - void AddById(string systemId) - { - var system = systems.FirstOrDefault(candidate => string.Equals(candidate.Id, systemId, StringComparison.Ordinal)); - if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal))) - { - selected.Add(system); - } - } - - AddById(SolSystemId); - AddById(DevelopmentCompanionSystemId); - - foreach (var system in systems) - { - if (selected.Count >= targetSystemCount) - { - break; - } - - if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal))) - { - continue; - } - - selected.Add(system); - } - - if (selected.Count > 0 && selected.Count <= 4) - { - ApplyCompactGalaxyLayout(selected); - } - - return selected; - } - - private static void ApplyCompactGalaxyLayout(IReadOnlyList systems) - { - var compactPositions = new[] + private static void ApplyCompactGalaxyLayout(IReadOnlyList systems) { + var compactPositions = new[] + { new[] { 0f, 0f, 0f }, new[] { 2.6f, 0.02f, -0.42f }, new[] { -2.4f, -0.04f, 0.56f }, new[] { 0.52f, 0.04f, 2.48f }, }; - for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1) - { - systems[index].Position = compactPositions[index]; + for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1) + { + systems[index].Position = compactPositions[index]; + } } - } - private static SolarSystemDefinition CreateGeneratedSystem( - SolarSystemDefinition template, - string label, - string id, - int generatedIndex, - Vector3 position) - { - var starProfile = SelectStarProfile(generatedIndex); - var planets = BuildGeneratedPlanets(template, generatedIndex); - var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) - .Select(node => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - AnchorReference = node.AnchorReference, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - InclinationDegrees = node.InclinationDegrees, - AnchorPlanetIndex = node.AnchorPlanetIndex, - AnchorMoonIndex = node.AnchorMoonIndex, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - }) - .ToList(); - - return EnsureStrategicResourceCoverage(new SolarSystemDefinition + private static SolarSystemDefinition CreateGeneratedSystem( + SolarSystemDefinition template, + string label, + string id, + int generatedIndex, + Vector3 position) { - Id = id, - Label = label, - Position = [position.X, position.Y, position.Z], - Stars = - [ - new StarDefinition + var starProfile = SelectStarProfile(generatedIndex); + var planets = BuildGeneratedPlanets(template, generatedIndex); + var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) + .Select(node => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + }) + .ToList(); + + return EnsureStrategicResourceCoverage(new SolarSystemDefinition + { + Id = id, + Label = label, + Position = [position.X, position.Y, position.Z], + Stars = + [ + new StarDefinition { Kind = starProfile.Kind, Color = starProfile.StarColor, @@ -153,456 +153,456 @@ internal sealed class SystemGenerationService Size = starProfile.BaseSize + ((generatedIndex % 4) * 2f), }, ], - AsteroidField = new AsteroidFieldDefinition - { - DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10), - RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18000f), - RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12000f), - HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4000f), - }, - ResourceNodes = resourceNodes, - Planets = planets, - }, generatedIndex + 1024); - } - - private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) - { - return new SolarSystemDefinition - { - Id = definition.Id, - Label = definition.Label, - Position = definition.Position.ToArray(), - Stars = definition.Stars.Select(s => new StarDefinition { Kind = s.Kind, Color = s.Color, Glow = s.Glow, Size = s.Size, OrbitRadius = s.OrbitRadius, OrbitSpeed = s.OrbitSpeed, OrbitPhaseAtEpoch = s.OrbitPhaseAtEpoch }).ToList(), - AsteroidField = new AsteroidFieldDefinition - { - DecorationCount = definition.AsteroidField.DecorationCount, - RadiusOffset = definition.AsteroidField.RadiusOffset, - RadiusVariance = definition.AsteroidField.RadiusVariance, - HeightVariance = definition.AsteroidField.HeightVariance, - }, - ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - AnchorReference = node.AnchorReference, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - InclinationDegrees = node.InclinationDegrees, - AnchorPlanetIndex = node.AnchorPlanetIndex, - AnchorMoonIndex = node.AnchorMoonIndex, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - }).ToList(), - Planets = definition.Planets.Select(planet => new PlanetDefinition - { - Label = planet.Label, - PlanetType = planet.PlanetType, - Shape = planet.Shape, - Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(), - OrbitRadius = planet.OrbitRadius, - OrbitSpeed = planet.OrbitSpeed, - OrbitEccentricity = planet.OrbitEccentricity, - OrbitInclination = planet.OrbitInclination, - OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, - OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, - OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, - Size = planet.Size, - Color = planet.Color, - Tilt = planet.Tilt, - HasRing = planet.HasRing, - }).ToList(), - }; - } - - private static List BuildProceduralResourceNodes( - SolarSystemDefinition template, - IReadOnlyList planets, - int generatedIndex) - { - var nodes = new List(); - if (template.ResourceNodes.Count > 0) - { - nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - AnchorReference = node.AnchorReference, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - InclinationDegrees = node.InclinationDegrees, - AnchorPlanetIndex = node.AnchorPlanetIndex, - AnchorMoonIndex = node.AnchorMoonIndex, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - })); + AsteroidField = new AsteroidFieldDefinition + { + DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10), + RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18000f), + RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12000f), + HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4000f), + }, + ResourceNodes = resourceNodes, + Planets = planets, + }, generatedIndex + 1024); } - return nodes; - } - - private static SolarSystemDefinition EnsureStrategicResourceCoverage(SolarSystemDefinition system, int seed) - { - for (var index = 0; index < system.ResourceNodes.Count; index += 1) + private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) { - system.ResourceNodes[index] = SanitizeResourceNode(system.ResourceNodes[index], system.Planets, seed, index); - } - - var requiredItems = new[] { "ore", "silicon", "ice", "hydrogen", "helium", "methane" }; - foreach (var itemId in requiredItems) - { - if (system.ResourceNodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal))) - { - continue; - } - - system.ResourceNodes.Add(BuildStrategicResourceNode(itemId, system.Planets, seed, system.ResourceNodes.Count)); - } - - return system; - } - - private static List BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) - { - var allPositions = occupiedPositions.ToList(); - var generated = new List(count); - - for (var index = 0; index < count; index += 1) - { - Vector3? accepted = null; - for (var attempt = 0; attempt < 64; attempt += 1) - { - var candidate = ComputeGeneratedSystemPosition(index, attempt); - if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) + return new SolarSystemDefinition { - accepted = candidate; - break; + Id = definition.Id, + Label = definition.Label, + Position = definition.Position.ToArray(), + Stars = definition.Stars.Select(s => new StarDefinition { Kind = s.Kind, Color = s.Color, Glow = s.Glow, Size = s.Size, OrbitRadius = s.OrbitRadius, OrbitSpeed = s.OrbitSpeed, OrbitPhaseAtEpoch = s.OrbitPhaseAtEpoch }).ToList(), + AsteroidField = new AsteroidFieldDefinition + { + DecorationCount = definition.AsteroidField.DecorationCount, + RadiusOffset = definition.AsteroidField.RadiusOffset, + RadiusVariance = definition.AsteroidField.RadiusVariance, + HeightVariance = definition.AsteroidField.HeightVariance, + }, + ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + }).ToList(), + Planets = definition.Planets.Select(planet => new PlanetDefinition + { + Label = planet.Label, + PlanetType = planet.PlanetType, + Shape = planet.Shape, + Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(), + OrbitRadius = planet.OrbitRadius, + OrbitSpeed = planet.OrbitSpeed, + OrbitEccentricity = planet.OrbitEccentricity, + OrbitInclination = planet.OrbitInclination, + OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, + OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, + OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, + Size = planet.Size, + Color = planet.Color, + Tilt = planet.Tilt, + HasRing = planet.HasRing, + }).ToList(), + }; + } + + private static List BuildProceduralResourceNodes( + SolarSystemDefinition template, + IReadOnlyList planets, + int generatedIndex) + { + var nodes = new List(); + if (template.ResourceNodes.Count > 0) + { + nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + AnchorReference = node.AnchorReference, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + })); } - } - accepted ??= ComputeFallbackGeneratedSystemPosition(index); - generated.Add(accepted.Value); - allPositions.Add(accepted.Value); + return nodes; } - return generated; - } - - private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt) - { - const int armCount = 4; - const float baseInnerRadius = 9f; - const float radiusStep = 0.54f; - const float armOffset = MathF.PI * 2f / armCount; - - var armIndex = (generatedIndex + attempt) % armCount; - var armDepth = generatedIndex / armCount; - var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 0.9f); - var angle = (armIndex * armOffset) + (radius / 8.2f) + Jitter(generatedIndex, 1 + attempt, 0.16f); - var x = MathF.Cos(angle) * radius; - var z = MathF.Sin(angle) * radius * 0.58f; - var y = ComputeSystemHeight(radius, generatedIndex, attempt); - return new Vector3(x, y, z); - } - - private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex) - { - const int ringCount = 5; - const float fallbackRadius = 42f; - var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f; - var radius = fallbackRadius + (generatedIndex / ringCount) * 1.8f; - return new Vector3( - MathF.Cos(angle) * radius, - ComputeSystemHeight(radius, generatedIndex, 99), - MathF.Sin(angle) * radius * 0.6f); - } - - private static string BuildGeneratedSystemId(string label, int ordinal) - { - var slug = string.Concat(label - .ToLowerInvariant() - .Select(character => char.IsLetterOrDigit(character) ? character : '-')) - .Trim('-'); - - return $"gen-{ordinal}-{slug}"; - } - - private static ResourceNodeDefinition BuildStrategicResourceNode( - string itemId, - IReadOnlyList planets, - int seed, - int ordinal) - { - var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); - return new ResourceNodeDefinition + private static SolarSystemDefinition EnsureStrategicResourceCoverage(SolarSystemDefinition system, int seed) { - SourceKind = "local-space", - AnchorReference = ResolveStrategicAnchorReference(itemId, planets, ordinal), - Angle = (MathF.PI * 2f * ((ordinal % 7) / 7f)) + Jitter(seed, 400 + ordinal, 0.35f), - RadiusOffset = 150000f + Jitter(seed, 460 + ordinal, 42000f), - InclinationDegrees = Jitter(seed, 520 + ordinal, 10f), - AnchorPlanetIndex = anchorPlanetIndex, - OreAmount = itemId switch - { - "ore" => 12000f, - "silicon" => 10000f, - "ice" => 9000f, - _ => 8000f, - }, - ItemId = itemId, - ShardCount = itemId switch - { - "ore" or "silicon" or "ice" => 8, - _ => 6, - }, - }; - } + for (var index = 0; index < system.ResourceNodes.Count; index += 1) + { + system.ResourceNodes[index] = SanitizeResourceNode(system.ResourceNodes[index], system.Planets, seed, index); + } - private static ResourceNodeDefinition SanitizeResourceNode( - ResourceNodeDefinition node, - IReadOnlyList planets, - int seed, - int ordinal) - { - node.SourceKind = "local-space"; - node.AnchorReference ??= ResolveLegacyAnchorReference(node, planets, seed, ordinal); - return node; - } + var requiredItems = new[] { "ore", "silicon", "ice", "hydrogen", "helium", "methane" }; + foreach (var itemId in requiredItems) + { + if (system.ResourceNodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal))) + { + continue; + } - private static string ResolveLegacyAnchorReference( - ResourceNodeDefinition node, - IReadOnlyList planets, - int seed, - int ordinal) - { - if (node.AnchorMoonIndex is int moonIndex && node.AnchorPlanetIndex is int planetIndex && planetIndex >= 0) - { - return $"planet-{planetIndex + 1}-moon-{moonIndex + 1}"; + system.ResourceNodes.Add(BuildStrategicResourceNode(itemId, system.Planets, seed, system.ResourceNodes.Count)); + } + + return system; } - if (node.AnchorPlanetIndex is int anchoredPlanetIndex && anchoredPlanetIndex >= 0) + private static List BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) { - return $"planet-{anchoredPlanetIndex + 1}"; + var allPositions = occupiedPositions.ToList(); + var generated = new List(count); + + for (var index = 0; index < count; index += 1) + { + Vector3? accepted = null; + for (var attempt = 0; attempt < 64; attempt += 1) + { + var candidate = ComputeGeneratedSystemPosition(index, attempt); + if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) + { + accepted = candidate; + break; + } + } + + accepted ??= ComputeFallbackGeneratedSystemPosition(index); + generated.Add(accepted.Value); + allPositions.Add(accepted.Value); + } + + return generated; } - return ResolveStrategicAnchorReference(node.ItemId, planets, ordinal + seed); - } - - private static string ResolveStrategicAnchorReference(string itemId, IReadOnlyList planets, int ordinal) - { - if (itemId is "hydrogen" or "helium" or "methane") + private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt) { - var gasGiantIndex = planets - .Select((planet, index) => (planet, index)) - .FirstOrDefault(entry => entry.planet.PlanetType is "gas-giant" or "ice-giant") - .index; - return gasGiantIndex > 0 || (planets.Count > 0 && planets[0].PlanetType is "gas-giant" or "ice-giant") - ? $"planet-{gasGiantIndex + 1}" - : "star-1"; + const int armCount = 4; + const float baseInnerRadius = 9f; + const float radiusStep = 0.54f; + const float armOffset = MathF.PI * 2f / armCount; + + var armIndex = (generatedIndex + attempt) % armCount; + var armDepth = generatedIndex / armCount; + var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 0.9f); + var angle = (armIndex * armOffset) + (radius / 8.2f) + Jitter(generatedIndex, 1 + attempt, 0.16f); + var x = MathF.Cos(angle) * radius; + var z = MathF.Sin(angle) * radius * 0.58f; + var y = ComputeSystemHeight(radius, generatedIndex, attempt); + return new Vector3(x, y, z); } - if (itemId == "ice") + private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex) { - var moonAnchor = planets - .Select((planet, index) => (planet, index)) - .FirstOrDefault(entry => entry.planet.Moons.Count > 0 && entry.planet.PlanetType is "ice" or "ice-giant" or "oceanic"); - if (moonAnchor.planet is not null && moonAnchor.planet.Moons.Count > 0) - { - return $"planet-{moonAnchor.index + 1}-moon-1"; - } + const int ringCount = 5; + const float fallbackRadius = 42f; + var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f; + var radius = fallbackRadius + (generatedIndex / ringCount) * 1.8f; + return new Vector3( + MathF.Cos(angle) * radius, + ComputeSystemHeight(radius, generatedIndex, 99), + MathF.Sin(angle) * radius * 0.6f); } - var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); - var lagrange = (ordinal % 3) switch + private static string BuildGeneratedSystemId(string label, int ordinal) { - 0 => "l1", - 1 => "l4", - _ => "l5", - }; - return $"planet-{anchorPlanetIndex + 1}-{lagrange}"; - } + var slug = string.Concat(label + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-')) + .Trim('-'); - private static int ResolveStrategicResourceAnchorPlanetIndex(string itemId, IReadOnlyList planets) - { - if (planets.Count == 0) - { - return 0; + return $"gen-{ordinal}-{slug}"; } - bool MatchesPlanetType(PlanetDefinition planet) => itemId switch + private static ResourceNodeDefinition BuildStrategicResourceNode( + string itemId, + IReadOnlyList planets, + int seed, + int ordinal) { - "hydrogen" or "helium" or "methane" => planet.PlanetType is "gas-giant" or "ice-giant", - "ice" => planet.PlanetType is "ice" or "ice-giant" or "oceanic", - _ => planet.PlanetType is not "gas-giant" and not "ice-giant", - }; - - for (var index = 0; index < planets.Count; index += 1) - { - if (MatchesPlanetType(planets[index])) - { - return index; - } + var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); + return new ResourceNodeDefinition + { + SourceKind = "local-space", + AnchorReference = ResolveStrategicAnchorReference(itemId, planets, ordinal), + Angle = (MathF.PI * 2f * ((ordinal % 7) / 7f)) + Jitter(seed, 400 + ordinal, 0.35f), + RadiusOffset = 150000f + Jitter(seed, 460 + ordinal, 42000f), + InclinationDegrees = Jitter(seed, 520 + ordinal, 10f), + AnchorPlanetIndex = anchorPlanetIndex, + OreAmount = itemId switch + { + "ore" => 12000f, + "silicon" => 10000f, + "ice" => 9000f, + _ => 8000f, + }, + ItemId = itemId, + ShardCount = itemId switch + { + "ore" or "silicon" or "ice" => 8, + _ => 6, + }, + }; } - return ResolveAsteroidAnchorPlanetIndex(planets); - } - - private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList planets) - { - if (planets.Count == 0) + private static ResourceNodeDefinition SanitizeResourceNode( + ResourceNodeDefinition node, + IReadOnlyList planets, + int seed, + int ordinal) { - return 0; + node.SourceKind = "local-space"; + node.AnchorReference ??= ResolveLegacyAnchorReference(node, planets, seed, ordinal); + return node; } - var gasGiantIndex = -1; - for (var index = 0; index < planets.Count; index += 1) + private static string ResolveLegacyAnchorReference( + ResourceNodeDefinition node, + IReadOnlyList planets, + int seed, + int ordinal) { - if (planets[index].PlanetType is "gas-giant" or "ice-giant") - { - gasGiantIndex = index; - break; - } + if (node.AnchorMoonIndex is int moonIndex && node.AnchorPlanetIndex is int planetIndex && planetIndex >= 0) + { + return $"planet-{planetIndex + 1}-moon-{moonIndex + 1}"; + } + + if (node.AnchorPlanetIndex is int anchoredPlanetIndex && anchoredPlanetIndex >= 0) + { + return $"planet-{anchoredPlanetIndex + 1}"; + } + + return ResolveStrategicAnchorReference(node.ItemId, planets, ordinal + seed); } - if (gasGiantIndex > 0) + private static string ResolveStrategicAnchorReference(string itemId, IReadOnlyList planets, int ordinal) { - return gasGiantIndex - 1; + if (itemId is "hydrogen" or "helium" or "methane") + { + var gasGiantIndex = planets + .Select((planet, index) => (planet, index)) + .FirstOrDefault(entry => entry.planet.PlanetType is "gas-giant" or "ice-giant") + .index; + return gasGiantIndex > 0 || (planets.Count > 0 && planets[0].PlanetType is "gas-giant" or "ice-giant") + ? $"planet-{gasGiantIndex + 1}" + : "star-1"; + } + + if (itemId == "ice") + { + var moonAnchor = planets + .Select((planet, index) => (planet, index)) + .FirstOrDefault(entry => entry.planet.Moons.Count > 0 && entry.planet.PlanetType is "ice" or "ice-giant" or "oceanic"); + if (moonAnchor.planet is not null && moonAnchor.planet.Moons.Count > 0) + { + return $"planet-{moonAnchor.index + 1}-moon-1"; + } + } + + var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets); + var lagrange = (ordinal % 3) switch + { + 0 => "l1", + 1 => "l4", + _ => "l5", + }; + return $"planet-{anchorPlanetIndex + 1}-{lagrange}"; } - return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1); - } - - private static List BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex) - { - var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); - var planets = new List(planetCount); - var orbitRadius = 0.24f + (Hash01(generatedIndex, 3) * 0.12f); - var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null; - - for (var index = 0; index < planetCount; index += 1) + private static int ResolveStrategicResourceAnchorPlanetIndex(string itemId, IReadOnlyList planets) { - var profile = SelectPlanetProfile(generatedIndex, index); - var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0 - ? sourcePlanets[index % sourcePlanets.Count] - : null; + if (planets.Count == 0) + { + return 0; + } - orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin)); - var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f); - var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f); - var moonCount = profile.BaseMoonCount + (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f); - var planetLabel = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}"; + bool MatchesPlanetType(PlanetDefinition planet) => itemId switch + { + "hydrogen" or "helium" or "methane" => planet.PlanetType is "gas-giant" or "ice-giant", + "ice" => planet.PlanetType is "ice" or "ice-giant" or "oceanic", + _ => planet.PlanetType is not "gas-giant" and not "ice-giant", + }; - planets.Add(new PlanetDefinition - { - Label = planetLabel, - PlanetType = profile.Type, - Shape = profile.Shape, - Moons = GenerateMoons(planetLabel, profile.BaseSize, moonCount), - OrbitRadius = orbitRadius, - OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)), - OrbitEccentricity = orbitEccentricity, - OrbitInclination = orbitInclination, - OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f, - OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f, - OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f, - Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * (profile.BaseSize * 0.35f)), - Color = templatePlanet?.Color ?? profile.Color, - Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f), - HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f, - }); + for (var index = 0; index < planets.Count; index += 1) + { + if (MatchesPlanetType(planets[index])) + { + return index; + } + } + + return ResolveAsteroidAnchorPlanetIndex(planets); } - return planets; - } - - private static StarProfile SelectStarProfile(int generatedIndex) - { - var value = Hash01(generatedIndex, 80); - return value switch + private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList planets) { - < 0.32f => StarProfiles[0], - < 0.54f => StarProfiles[1], - < 0.68f => StarProfiles[5], - < 0.8f => StarProfiles[2], - < 0.9f => StarProfiles[3], - < 0.97f => StarProfiles[6], - _ => StarProfiles[4], - }; - } + if (planets.Count == 0) + { + return 0; + } - private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex) - { - var value = Hash01(generatedIndex, 90 + planetIndex); - return value switch - { - < 0.14f => PlanetProfiles[7], - < 0.28f => PlanetProfiles[0], - < 0.46f => PlanetProfiles[3], - < 0.62f => PlanetProfiles[1], - < 0.74f => PlanetProfiles[2], - < 0.86f => PlanetProfiles[4], - < 0.94f => PlanetProfiles[6], - _ => PlanetProfiles[5], - }; - } + var gasGiantIndex = -1; + for (var index = 0; index < planets.Count; index += 1) + { + if (planets[index].PlanetType is "gas-giant" or "ice-giant") + { + gasGiantIndex = index; + break; + } + } - private static string BuildPlanetBaseName(int generatedIndex, int planetIndex) - { - var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length] - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0]; - return source[..Math.Min(source.Length, 6)]; - } + if (gasGiantIndex > 0) + { + return gasGiantIndex - 1; + } - private static float ComputeSystemHeight(float radius, int generatedIndex, int salt) - { - var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8f) / 28f)); - var band = 0.22f + (normalized * 0.76f); - return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band; - } - - private static float Jitter(int index, int salt, float amplitude) => - (Hash01(index, salt) * 2f - 1f) * amplitude; - - private static float Hash01(int index, int salt) - { - uint value = (uint)(index + 1); - value ^= (uint)(salt + 0x9e3779b9); - value *= 0x85ebca6b; - value ^= value >> 13; - value *= 0xc2b2ae35; - value ^= value >> 16; - return (value & 0x00ffffff) / 16777215f; - } - - private static List GenerateMoons(string planetLabel, float planetSize, int moonCount) - { - var seed = planetLabel.Aggregate(0, (acc, c) => acc * 31 + c); - var moons = new List(moonCount); - for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1) - { - var spacing = planetSize * 1.4f; - var radiusVariance = Hash01(seed, 10 + moonIndex) * planetSize * 0.9f; - var orbitRadius = (planetSize * 1.8f) + (moonIndex * spacing) + radiusVariance; - var orbitSpeed = 0.9f / MathF.Sqrt(MathF.Max(orbitRadius, 1f)) + (moonIndex * 0.003f); - var phase = Hash01(seed, 20 + moonIndex) * 360f; - var inclination = (Hash01(seed, 30 + moonIndex) - 0.5f) * 28f; - var ascendingNode = Hash01(seed, 40 + moonIndex) * 360f; - var sizeBase = MathF.Max(2.2f, planetSize * 0.11f); - var sizeVariance = Hash01(seed, 50 + moonIndex) * MathF.Max(planetSize * 0.16f, 2.5f); - var size = MathF.Min(sizeBase + sizeVariance, planetSize * 0.42f); - - moons.Add(new MoonDefinition - { - Label = $"{planetLabel}-m{moonIndex + 1}", - Size = size, - Color = "#c8c4bc", - OrbitRadius = orbitRadius, - OrbitSpeed = orbitSpeed, - OrbitPhaseAtEpoch = phase, - OrbitInclination = inclination, - OrbitLongitudeOfAscendingNode = ascendingNode, - }); + return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1); } - return moons; - } + private static List BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex) + { + var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); + var planets = new List(planetCount); + var orbitRadius = 0.24f + (Hash01(generatedIndex, 3) * 0.12f); + var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null; + + for (var index = 0; index < planetCount; index += 1) + { + var profile = SelectPlanetProfile(generatedIndex, index); + var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0 + ? sourcePlanets[index % sourcePlanets.Count] + : null; + + orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin)); + var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f); + var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f); + var moonCount = profile.BaseMoonCount + (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f); + var planetLabel = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}"; + + planets.Add(new PlanetDefinition + { + Label = planetLabel, + PlanetType = profile.Type, + Shape = profile.Shape, + Moons = GenerateMoons(planetLabel, profile.BaseSize, moonCount), + OrbitRadius = orbitRadius, + OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)), + OrbitEccentricity = orbitEccentricity, + OrbitInclination = orbitInclination, + OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f, + OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f, + OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f, + Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * (profile.BaseSize * 0.35f)), + Color = templatePlanet?.Color ?? profile.Color, + Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f), + HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f, + }); + } + + return planets; + } + + private static StarProfile SelectStarProfile(int generatedIndex) + { + var value = Hash01(generatedIndex, 80); + return value switch + { + < 0.32f => StarProfiles[0], + < 0.54f => StarProfiles[1], + < 0.68f => StarProfiles[5], + < 0.8f => StarProfiles[2], + < 0.9f => StarProfiles[3], + < 0.97f => StarProfiles[6], + _ => StarProfiles[4], + }; + } + + private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex) + { + var value = Hash01(generatedIndex, 90 + planetIndex); + return value switch + { + < 0.14f => PlanetProfiles[7], + < 0.28f => PlanetProfiles[0], + < 0.46f => PlanetProfiles[3], + < 0.62f => PlanetProfiles[1], + < 0.74f => PlanetProfiles[2], + < 0.86f => PlanetProfiles[4], + < 0.94f => PlanetProfiles[6], + _ => PlanetProfiles[5], + }; + } + + private static string BuildPlanetBaseName(int generatedIndex, int planetIndex) + { + var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length] + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0]; + return source[..Math.Min(source.Length, 6)]; + } + + private static float ComputeSystemHeight(float radius, int generatedIndex, int salt) + { + var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8f) / 28f)); + var band = 0.22f + (normalized * 0.76f); + return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band; + } + + private static float Jitter(int index, int salt, float amplitude) => + (Hash01(index, salt) * 2f - 1f) * amplitude; + + private static float Hash01(int index, int salt) + { + uint value = (uint)(index + 1); + value ^= (uint)(salt + 0x9e3779b9); + value *= 0x85ebca6b; + value ^= value >> 13; + value *= 0xc2b2ae35; + value ^= value >> 16; + return (value & 0x00ffffff) / 16777215f; + } + + private static List GenerateMoons(string planetLabel, float planetSize, int moonCount) + { + var seed = planetLabel.Aggregate(0, (acc, c) => acc * 31 + c); + var moons = new List(moonCount); + for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1) + { + var spacing = planetSize * 1.4f; + var radiusVariance = Hash01(seed, 10 + moonIndex) * planetSize * 0.9f; + var orbitRadius = (planetSize * 1.8f) + (moonIndex * spacing) + radiusVariance; + var orbitSpeed = 0.9f / MathF.Sqrt(MathF.Max(orbitRadius, 1f)) + (moonIndex * 0.003f); + var phase = Hash01(seed, 20 + moonIndex) * 360f; + var inclination = (Hash01(seed, 30 + moonIndex) - 0.5f) * 28f; + var ascendingNode = Hash01(seed, 40 + moonIndex) * 360f; + var sizeBase = MathF.Max(2.2f, planetSize * 0.11f); + var sizeVariance = Hash01(seed, 50 + moonIndex) * MathF.Max(planetSize * 0.16f, 2.5f); + var size = MathF.Min(sizeBase + sizeVariance, planetSize * 0.42f); + + moons.Add(new MoonDefinition + { + Label = $"{planetLabel}-m{moonIndex + 1}", + Size = size, + Color = "#c8c4bc", + OrbitRadius = orbitRadius, + OrbitSpeed = orbitSpeed, + OrbitPhaseAtEpoch = phase, + OrbitInclination = inclination, + OrbitLongitudeOfAscendingNode = ascendingNode, + }); + } + + return moons; + } } diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 26f9d95..afe1a91 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -9,335 +9,335 @@ internal sealed class WorldBuilder( SpatialBuilder spatialBuilder, WorldSeedingService seedingService) { - internal SimulationWorld Build() - { - var catalog = dataLoader.LoadCatalog(); - var systems = generationService.ExpandSystems( - generationService.InjectSpecialSystems(catalog.AuthoredSystems), - worldGeneration.TargetSystemCount); - - Console.WriteLine("TEST"); - Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); - - var scenario = dataLoader.NormalizeScenarioToAvailableSystems( - catalog.Scenario, - systems.Select(system => system.Id).ToList()); - - Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); - - var systemRuntimes = systems - .Select(definition => new SystemRuntime - { - Definition = definition, - Position = ToVector(definition.Position), - }) - .ToList(); - var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal); - var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance); - - var stations = CreateStations( - scenario, - systemsById, - spatialLayout.SystemGraphs, - spatialLayout.Celestials, - catalog.ModuleDefinitions, - catalog.ItemDefinitions); - - 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); - - if (worldGeneration.AiControllerFactionCount < int.MaxValue) + internal SimulationWorld Build() { - var aiFactionIds = stations - .Select(s => s.FactionId) - .Concat(ships.Select(s => s.FactionId)) - .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal)) - .Distinct(StringComparer.Ordinal) - .OrderBy(id => id, StringComparer.Ordinal) - .Take(worldGeneration.AiControllerFactionCount) - .ToHashSet(StringComparer.Ordinal); - aiFactionIds.Add(DefaultFactionId); - stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); - ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); - } + var catalog = dataLoader.LoadCatalog(); + var systems = generationService.ExpandSystems( + generationService.InjectSpecialSystems(catalog.AuthoredSystems), + worldGeneration.TargetSystemCount); - var factions = seedingService.CreateFactions(stations, ships); - seedingService.BootstrapFactionEconomy(factions, stations); - var policies = seedingService.CreatePolicies(factions); - var commanders = seedingService.CreateCommanders(factions, stations, ships); - var nowUtc = DateTimeOffset.UtcNow; - var playerFaction = worldGeneration.GeneratePlayerFaction - ? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc) - : null; - var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); - var bootstrapWorld = new SimulationWorld - { - Label = "Split Viewer / Bootstrap World", - Seed = WorldSeed, - Balance = catalog.Balance, - Systems = systemRuntimes, - Celestials = spatialLayout.Celestials, - Nodes = spatialLayout.Nodes, - Wrecks = [], - Stations = stations, - Ships = ships, - Factions = factions, - PlayerFaction = playerFaction, - Commanders = commanders, - Claims = claims, - ConstructionSites = [], - MarketOrders = [], - Policies = policies, - ShipDefinitions = new Dictionary(catalog.ShipDefinitions, StringComparer.Ordinal), - ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), - ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), - ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), - Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), - ProductionGraph = catalog.ProductionGraph, - OrbitalTimeSeconds = WorldSeed * 97d, - GeneratedAtUtc = nowUtc, - }; - var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld); + Console.WriteLine("TEST"); + Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); - var world = new SimulationWorld - { - Label = "Split Viewer / Simulation World", - Seed = WorldSeed, - Balance = catalog.Balance, - Systems = systemRuntimes, - Celestials = spatialLayout.Celestials, - Nodes = spatialLayout.Nodes, - Wrecks = [], - Stations = stations, - Ships = ships, - Factions = factions, - PlayerFaction = playerFaction, - Geopolitics = null, - Commanders = commanders, - Claims = claims, - ConstructionSites = constructionSites, - MarketOrders = marketOrders, - Policies = policies, - ShipDefinitions = new Dictionary(catalog.ShipDefinitions, StringComparer.Ordinal), - ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), - ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), - ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), - Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), - ProductionGraph = catalog.ProductionGraph, - OrbitalTimeSeconds = WorldSeed * 97d, - GeneratedAtUtc = DateTimeOffset.UtcNow, - }; + var scenario = dataLoader.NormalizeScenarioToAvailableSystems( + catalog.Scenario, + systems.Select(system => system.Id).ToList()); - var geopolitics = new GeopoliticalSimulationService(); - geopolitics.Update(world, 0f, []); - return world; - } + Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); - private static List CreateStations( - ScenarioDefinition scenario, - IReadOnlyDictionary systemsById, - IReadOnlyDictionary systemGraphs, - IReadOnlyCollection celestials, - IReadOnlyDictionary moduleDefinitions, - IReadOnlyDictionary itemDefinitions) - { - var stations = new List(); - var stationIdCounter = 0; - - foreach (var plan in scenario.InitialStations) - { - if (!systemsById.TryGetValue(plan.SystemId, out var system)) - { - continue; - } - - var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials); - var station = new StationRuntime - { - Id = $"station-{++stationIdCounter}", - SystemId = system.Definition.Id, - Label = plan.Label, - Color = plan.Color, - Objective = StationSimulationService.NormalizeStationObjective(plan.Objective), - Position = placement.Position, - FactionId = plan.FactionId ?? DefaultFactionId, - CelestialId = placement.AnchorCelestial.Id, - Health = 600f, - MaxHealth = 600f, - }; - - stations.Add(station); - placement.AnchorCelestial.OccupyingStructureId = station.Id; - - var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions); - - foreach (var moduleId in startingModules) - { - AddStationModule(station, moduleDefinitions, moduleId); - } - } - - return stations; - } - - private static IReadOnlyList BuildStartingModules( - InitialStationDefinition plan, - IReadOnlyDictionary moduleDefinitions, - IReadOnlyDictionary itemDefinitions) - { - var startingModules = new List(plan.StartingModules.Count > 0 - ? plan.StartingModules - : ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]); - - EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech"); - - var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective); - if (!string.IsNullOrWhiteSpace(objectiveModuleId)) - { - EnsureStartingModule(startingModules, objectiveModuleId); - - if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) - { - EnsureStartingModule(startingModules, "module_gen_prod_energycells_01"); - } - - foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions)) - { - EnsureStartingModule(startingModules, storageModuleId); - } - } - - return startingModules; - } - - private static string? GetObjectiveStartingModuleId(string? objective) => - StationSimulationService.NormalizeStationObjective(objective) switch - { - "power" => "module_gen_prod_energycells_01", - "refinery" => "module_gen_ref_ore_01", - "graphene" => "module_gen_prod_graphene_01", - "siliconwafers" => "module_gen_prod_siliconwafers_01", - "hullparts" => "module_gen_prod_hullparts_01", - "claytronics" => "module_gen_prod_claytronics_01", - "quantumtubes" => "module_gen_prod_quantumtubes_01", - "antimattercells" => "module_gen_prod_antimattercells_01", - "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", - "water" => "module_gen_prod_water_01", - _ => null, - }; - - private static IEnumerable GetRequiredStartingStorageModules( - string moduleId, - IReadOnlyDictionary moduleDefinitions, - IReadOnlyDictionary itemDefinitions) - { - if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) - { - yield break; - } - - foreach (var wareId in moduleDefinition.Production - .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) - .Concat(moduleDefinition.Products) - .Distinct(StringComparer.Ordinal)) - { - if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition)) - { - continue; - } - - var storageModuleId = itemDefinition.CargoKind switch - { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => "module_arg_stor_container_m_01", - }; - - yield return storageModuleId; - } - } - - private static void EnsureStartingModule(List modules, string moduleId) - { - if (!modules.Contains(moduleId, StringComparer.Ordinal)) - { - modules.Add(moduleId); - } - } - - private static Dictionary> BuildPatrolRoutes( - ScenarioDefinition scenario, - IReadOnlyDictionary systemsById) - { - return scenario.PatrolRoutes - .GroupBy(route => route.SystemId, StringComparer.Ordinal) - .ToDictionary( - group => group.Key, - group => group - .SelectMany(route => route.Points) - .Select(point => NormalizeScenarioPoint(systemsById[group.Key], point)) - .ToList(), - StringComparer.Ordinal); - } - - private static List CreateShips( - ScenarioDefinition scenario, - IReadOnlyDictionary systemsById, - IReadOnlyCollection celestials, - BalanceDefinition balance, - IReadOnlyDictionary shipDefinitions, - IReadOnlyDictionary> patrolRoutes, - IReadOnlyCollection stations, - StationRuntime? refinery) - { - var ships = new List(); - var shipIdCounter = 0; - - foreach (var formation in scenario.ShipFormations) - { - if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition)) - { - continue; - } - - for (var index = 0; index < formation.Count; index += 1) - { - var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); - var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); - - ships.Add(new ShipRuntime - { - Id = $"ship-{++shipIdCounter}", - SystemId = formation.SystemId, - Definition = definition, - FactionId = formation.FactionId ?? DefaultFactionId, - Position = position, - TargetPosition = position, - SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), - DefaultBehavior = WorldSeedingService.CreateBehavior( - definition, - formation.SystemId, - formation.FactionId ?? DefaultFactionId, - scenario, - patrolRoutes, - stations, - refinery), - Skills = WorldSeedingService.CreateSkills(definition), - Health = definition.MaxHealth, - }); - - foreach (var (itemId, amount) in formation.StartingInventory) - { - if (amount > 0f) + var systemRuntimes = systems + .Select(definition => new SystemRuntime { - ships[^1].Inventory[itemId] = amount; - } + Definition = definition, + Position = ToVector(definition.Position), + }) + .ToList(); + var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal); + var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance); + + var stations = CreateStations( + scenario, + systemsById, + spatialLayout.SystemGraphs, + spatialLayout.Celestials, + catalog.ModuleDefinitions, + catalog.ItemDefinitions); + + 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); + + if (worldGeneration.AiControllerFactionCount < int.MaxValue) + { + var aiFactionIds = stations + .Select(s => s.FactionId) + .Concat(ships.Select(s => s.FactionId)) + .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(worldGeneration.AiControllerFactionCount) + .ToHashSet(StringComparer.Ordinal); + aiFactionIds.Add(DefaultFactionId); + stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); + ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); } - } + + var factions = seedingService.CreateFactions(stations, ships); + seedingService.BootstrapFactionEconomy(factions, stations); + var policies = seedingService.CreatePolicies(factions); + var commanders = seedingService.CreateCommanders(factions, stations, ships); + var nowUtc = DateTimeOffset.UtcNow; + var playerFaction = worldGeneration.GeneratePlayerFaction + ? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc) + : null; + var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); + var bootstrapWorld = new SimulationWorld + { + Label = "Split Viewer / Bootstrap World", + Seed = WorldSeed, + Balance = catalog.Balance, + Systems = systemRuntimes, + Celestials = spatialLayout.Celestials, + Nodes = spatialLayout.Nodes, + Wrecks = [], + Stations = stations, + Ships = ships, + Factions = factions, + PlayerFaction = playerFaction, + Commanders = commanders, + Claims = claims, + ConstructionSites = [], + MarketOrders = [], + Policies = policies, + ShipDefinitions = new Dictionary(catalog.ShipDefinitions, StringComparer.Ordinal), + ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), + ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), + ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), + Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), + ProductionGraph = catalog.ProductionGraph, + OrbitalTimeSeconds = WorldSeed * 97d, + GeneratedAtUtc = nowUtc, + }; + var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld); + + var world = new SimulationWorld + { + Label = "Split Viewer / Simulation World", + Seed = WorldSeed, + Balance = catalog.Balance, + Systems = systemRuntimes, + Celestials = spatialLayout.Celestials, + Nodes = spatialLayout.Nodes, + Wrecks = [], + Stations = stations, + Ships = ships, + Factions = factions, + PlayerFaction = playerFaction, + Geopolitics = null, + Commanders = commanders, + Claims = claims, + ConstructionSites = constructionSites, + MarketOrders = marketOrders, + Policies = policies, + ShipDefinitions = new Dictionary(catalog.ShipDefinitions, StringComparer.Ordinal), + ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), + ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), + ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), + Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), + ProductionGraph = catalog.ProductionGraph, + OrbitalTimeSeconds = WorldSeed * 97d, + GeneratedAtUtc = DateTimeOffset.UtcNow, + }; + + var geopolitics = new GeopoliticalSimulationService(); + geopolitics.Update(world, 0f, []); + return world; } - return ships; - } + private static List CreateStations( + ScenarioDefinition scenario, + IReadOnlyDictionary systemsById, + IReadOnlyDictionary systemGraphs, + IReadOnlyCollection celestials, + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) + { + var stations = new List(); + var stationIdCounter = 0; + + foreach (var plan in scenario.InitialStations) + { + if (!systemsById.TryGetValue(plan.SystemId, out var system)) + { + continue; + } + + var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials); + var station = new StationRuntime + { + Id = $"station-{++stationIdCounter}", + SystemId = system.Definition.Id, + Label = plan.Label, + Color = plan.Color, + Objective = StationSimulationService.NormalizeStationObjective(plan.Objective), + Position = placement.Position, + FactionId = plan.FactionId ?? DefaultFactionId, + CelestialId = placement.AnchorCelestial.Id, + Health = 600f, + MaxHealth = 600f, + }; + + stations.Add(station); + placement.AnchorCelestial.OccupyingStructureId = station.Id; + + var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions); + + foreach (var moduleId in startingModules) + { + AddStationModule(station, moduleDefinitions, moduleId); + } + } + + return stations; + } + + private static IReadOnlyList BuildStartingModules( + InitialStationDefinition plan, + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) + { + var startingModules = new List(plan.StartingModules.Count > 0 + ? plan.StartingModules + : ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]); + + EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech"); + + var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective); + if (!string.IsNullOrWhiteSpace(objectiveModuleId)) + { + EnsureStartingModule(startingModules, objectiveModuleId); + + if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + EnsureStartingModule(startingModules, "module_gen_prod_energycells_01"); + } + + foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions)) + { + EnsureStartingModule(startingModules, storageModuleId); + } + } + + return startingModules; + } + + private static string? GetObjectiveStartingModuleId(string? objective) => + StationSimulationService.NormalizeStationObjective(objective) switch + { + "power" => "module_gen_prod_energycells_01", + "refinery" => "module_gen_ref_ore_01", + "graphene" => "module_gen_prod_graphene_01", + "siliconwafers" => "module_gen_prod_siliconwafers_01", + "hullparts" => "module_gen_prod_hullparts_01", + "claytronics" => "module_gen_prod_claytronics_01", + "quantumtubes" => "module_gen_prod_quantumtubes_01", + "antimattercells" => "module_gen_prod_antimattercells_01", + "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", + "water" => "module_gen_prod_water_01", + _ => null, + }; + + private static IEnumerable GetRequiredStartingStorageModules( + string moduleId, + IReadOnlyDictionary moduleDefinitions, + IReadOnlyDictionary itemDefinitions) + { + if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + yield break; + } + + foreach (var wareId in moduleDefinition.Production + .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) + .Concat(moduleDefinition.Products) + .Distinct(StringComparer.Ordinal)) + { + if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition)) + { + continue; + } + + var storageModuleId = itemDefinition.CargoKind switch + { + "solid" => "module_arg_stor_solid_m_01", + "liquid" => "module_arg_stor_liquid_m_01", + _ => "module_arg_stor_container_m_01", + }; + + yield return storageModuleId; + } + } + + private static void EnsureStartingModule(List modules, string moduleId) + { + if (!modules.Contains(moduleId, StringComparer.Ordinal)) + { + modules.Add(moduleId); + } + } + + private static Dictionary> BuildPatrolRoutes( + ScenarioDefinition scenario, + IReadOnlyDictionary systemsById) + { + return scenario.PatrolRoutes + .GroupBy(route => route.SystemId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group + .SelectMany(route => route.Points) + .Select(point => NormalizeScenarioPoint(systemsById[group.Key], point)) + .ToList(), + StringComparer.Ordinal); + } + + private static List CreateShips( + ScenarioDefinition scenario, + IReadOnlyDictionary systemsById, + IReadOnlyCollection celestials, + BalanceDefinition balance, + IReadOnlyDictionary shipDefinitions, + IReadOnlyDictionary> patrolRoutes, + IReadOnlyCollection stations, + StationRuntime? refinery) + { + var ships = new List(); + var shipIdCounter = 0; + + foreach (var formation in scenario.ShipFormations) + { + if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition)) + { + continue; + } + + for (var index = 0; index < formation.Count; index += 1) + { + var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); + var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); + + ships.Add(new ShipRuntime + { + Id = $"ship-{++shipIdCounter}", + SystemId = formation.SystemId, + Definition = definition, + FactionId = formation.FactionId ?? DefaultFactionId, + Position = position, + TargetPosition = position, + SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), + DefaultBehavior = WorldSeedingService.CreateBehavior( + definition, + formation.SystemId, + formation.FactionId ?? DefaultFactionId, + scenario, + patrolRoutes, + stations, + refinery), + Skills = WorldSeedingService.CreateSkills(definition), + Health = definition.MaxHealth, + }); + + foreach (var (itemId, amount) in formation.StartingInventory) + { + if (amount > 0f) + { + ships[^1].Inventory[itemId] = amount; + } + } + } + } + + return ships; + } } diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index e3f0da1..b67c66c 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -4,570 +4,570 @@ namespace SpaceGame.Api.Universe.Scenario; internal sealed class WorldSeedingService { - internal List CreateFactions( - IReadOnlyCollection stations, - IReadOnlyCollection ships) - { - var factionIds = stations - .Select(station => station.FactionId) - .Concat(ships.Select(ship => ship.FactionId)) - .Where(factionId => !string.IsNullOrWhiteSpace(factionId)) - .Distinct(StringComparer.Ordinal) - .OrderBy(factionId => factionId, StringComparer.Ordinal) - .ToList(); - - if (factionIds.Count == 0) + internal List CreateFactions( + IReadOnlyCollection stations, + IReadOnlyCollection ships) { - factionIds.Add(DefaultFactionId); - } + var factionIds = stations + .Select(station => station.FactionId) + .Concat(ships.Select(ship => ship.FactionId)) + .Where(factionId => !string.IsNullOrWhiteSpace(factionId)) + .Distinct(StringComparer.Ordinal) + .OrderBy(factionId => factionId, StringComparer.Ordinal) + .ToList(); - return factionIds.Select(CreateFaction).ToList(); - } - - internal void BootstrapFactionEconomy( - IReadOnlyCollection factions, - IReadOnlyCollection stations) - { - foreach (var faction in factions) - { - faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); - - var ownedStations = stations - .Where(station => station.FactionId == faction.Id) - .ToList(); - - var refineries = ownedStations - .Where(station => string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)) - .ToList(); - - if (refineries.Count > 0) - { - foreach (var refinery in refineries) + if (factionIds.Count == 0) { - refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock); + factionIds.Add(DefaultFactionId); } - if (refineries.All(station => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre)) + return factionIds.Select(CreateFaction).ToList(); + } + + internal void BootstrapFactionEconomy( + IReadOnlyCollection factions, + IReadOnlyCollection stations) + { + foreach (var faction in factions) { - refineries[0].Inventory["ore"] = MinimumRefineryOre; + faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); + + var ownedStations = stations + .Where(station => station.FactionId == faction.Id) + .ToList(); + + var refineries = ownedStations + .Where(station => string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)) + .ToList(); + + if (refineries.Count > 0) + { + foreach (var refinery in refineries) + { + refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock); + } + + if (refineries.All(station => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre)) + { + refineries[0].Inventory["ore"] = MinimumRefineryOre; + } + } + + foreach (var shipyard in ownedStations.Where(station => HasInstalledModules(station, "module_gen_build_l_01"))) + { + shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock); + } } - } - - foreach (var shipyard in ownedStations.Where(station => HasInstalledModules(station, "module_gen_build_l_01"))) - { - shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock); - } - } - } - - internal void InitializeStationStockpiles(IReadOnlyCollection stations) - { - foreach (var station in stations) - { - InitializeStationPopulation(station); - } - } - - internal StationRuntime? SelectRefineryStation(IReadOnlyCollection stations, ScenarioDefinition scenario) - { - return stations.FirstOrDefault(station => - string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) && - station.SystemId == scenario.MiningDefaults.RefinerySystemId) - ?? stations.FirstOrDefault(station => - string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)); - } - - internal List CreateClaims( - IReadOnlyCollection stations, - IReadOnlyCollection celestials, - DateTimeOffset nowUtc) - { - var stationsByCelestialId = stations - .Where(station => station.CelestialId is not null) - .ToDictionary(station => station.CelestialId!, StringComparer.Ordinal); - var claims = new List(); - - foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint)) - { - if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station)) - { - continue; - } - - claims.Add(new ClaimRuntime - { - Id = $"claim-{celestial.Id}", - FactionId = station.FactionId, - SystemId = celestial.SystemId, - CelestialId = celestial.Id, - PlacedAtUtc = nowUtc, - ActivatesAtUtc = nowUtc.AddSeconds(8), - State = ClaimStateKinds.Activating, - Health = 100f, - }); } - return claims; - } - - internal (List ConstructionSites, List MarketOrders) CreateConstructionSites( - SimulationWorld world) - { - var sites = new List(); - var orders = new List(); - - foreach (var station in world.Stations) + internal void InitializeStationStockpiles(IReadOnlyCollection stations) { - if (HasSatisfiedStarterObjectiveLayout(world, station)) - { - continue; - } - - var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); - if (moduleId is null || station.CelestialId is null) - { - continue; - } - - var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); - if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) - { - continue; - } - - var site = new ConstructionSiteRuntime - { - Id = $"site-{station.Id}", - FactionId = station.FactionId, - SystemId = station.SystemId, - CelestialId = station.CelestialId, - TargetKind = "station-module", - TargetDefinitionId = "station", - BlueprintId = moduleId, - ClaimId = claim.Id, - StationId = station.Id, - State = claim.State == ClaimStateKinds.Active ? ConstructionSiteStateKinds.Active : ConstructionSiteStateKinds.Planned, - }; - - foreach (var input in recipe.Inputs) - { - site.RequiredItems[input.ItemId] = input.Amount; - site.DeliveredItems[input.ItemId] = 0f; - - var orderId = $"market-order-{station.Id}-{moduleId}-{input.ItemId}"; - site.MarketOrderIds.Add(orderId); - station.MarketOrderIds.Add(orderId); - orders.Add(new MarketOrderRuntime + foreach (var station in stations) { - Id = orderId, - FactionId = station.FactionId, - StationId = station.Id, - ConstructionSiteId = site.Id, - Kind = MarketOrderKinds.Buy, - ItemId = input.ItemId, - Amount = input.Amount, - RemainingAmount = input.Amount, - Valuation = 1f, - State = MarketOrderStateKinds.Open, + InitializeStationPopulation(station); + } + } + + internal StationRuntime? SelectRefineryStation(IReadOnlyCollection stations, ScenarioDefinition scenario) + { + return stations.FirstOrDefault(station => + string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) && + station.SystemId == scenario.MiningDefaults.RefinerySystemId) + ?? stations.FirstOrDefault(station => + string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)); + } + + internal List CreateClaims( + IReadOnlyCollection stations, + IReadOnlyCollection celestials, + DateTimeOffset nowUtc) + { + var stationsByCelestialId = stations + .Where(station => station.CelestialId is not null) + .ToDictionary(station => station.CelestialId!, StringComparer.Ordinal); + var claims = new List(); + + foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint)) + { + if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station)) + { + continue; + } + + claims.Add(new ClaimRuntime + { + Id = $"claim-{celestial.Id}", + FactionId = station.FactionId, + SystemId = celestial.SystemId, + CelestialId = celestial.Id, + PlacedAtUtc = nowUtc, + ActivatesAtUtc = nowUtc.AddSeconds(8), + State = ClaimStateKinds.Activating, + Health = 100f, + }); + } + + return claims; + } + + internal (List ConstructionSites, List MarketOrders) CreateConstructionSites( + SimulationWorld world) + { + var sites = new List(); + var orders = new List(); + + foreach (var station in world.Stations) + { + if (HasSatisfiedStarterObjectiveLayout(world, station)) + { + continue; + } + + var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); + if (moduleId is null || station.CelestialId is null) + { + continue; + } + + var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); + if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) + { + continue; + } + + var site = new ConstructionSiteRuntime + { + Id = $"site-{station.Id}", + FactionId = station.FactionId, + SystemId = station.SystemId, + CelestialId = station.CelestialId, + TargetKind = "station-module", + TargetDefinitionId = "station", + BlueprintId = moduleId, + ClaimId = claim.Id, + StationId = station.Id, + State = claim.State == ClaimStateKinds.Active ? ConstructionSiteStateKinds.Active : ConstructionSiteStateKinds.Planned, + }; + + foreach (var input in recipe.Inputs) + { + site.RequiredItems[input.ItemId] = input.Amount; + site.DeliveredItems[input.ItemId] = 0f; + + var orderId = $"market-order-{station.Id}-{moduleId}-{input.ItemId}"; + site.MarketOrderIds.Add(orderId); + station.MarketOrderIds.Add(orderId); + orders.Add(new MarketOrderRuntime + { + Id = orderId, + FactionId = station.FactionId, + StationId = station.Id, + ConstructionSiteId = site.Id, + Kind = MarketOrderKinds.Buy, + ItemId = input.ItemId, + Amount = input.Amount, + RemainingAmount = input.Amount, + Valuation = 1f, + State = MarketOrderStateKinds.Open, + }); + } + + sites.Add(site); + } + + return (sites, orders); + } + + private static bool HasSatisfiedStarterObjectiveLayout(SimulationWorld world, StationRuntime station) + { + var role = StationSimulationService.DetermineStationRole(station); + var objectiveModuleId = role switch + { + "power" => "module_gen_prod_energycells_01", + "refinery" => "module_gen_prod_refinedmetals_01", + "graphene" => "module_gen_prod_graphene_01", + "siliconwafers" => "module_gen_prod_siliconwafers_01", + "hullparts" => "module_gen_prod_hullparts_01", + "claytronics" => "module_gen_prod_claytronics_01", + "quantumtubes" => "module_gen_prod_quantumtubes_01", + "antimattercells" => "module_gen_prod_antimattercells_01", + "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", + "water" => "module_gen_prod_water_01", + _ => null, + }; + + if (objectiveModuleId is null) + { + return false; + } + + if (!station.InstalledModules.Contains("module_arg_dock_m_01_lowtech", StringComparer.Ordinal) + || !station.InstalledModules.Contains(objectiveModuleId, StringComparer.Ordinal)) + { + return false; + } + + if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal) + && !station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal)) + { + return false; + } + + foreach (var storageModuleId in GetRequiredStorageModulesForInstalledObjective(world, objectiveModuleId)) + { + if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) + { + return false; + } + } + + return true; + } + + private static IEnumerable GetRequiredStorageModulesForInstalledObjective(SimulationWorld world, string moduleId) + { + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + yield break; + } + + foreach (var wareId in moduleDefinition.Production + .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) + .Concat(moduleDefinition.Products) + .Distinct(StringComparer.Ordinal)) + { + if (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition)) + { + continue; + } + + yield return itemDefinition.CargoKind switch + { + "solid" => "module_arg_stor_solid_m_01", + "liquid" => "module_arg_stor_liquid_m_01", + _ => "module_arg_stor_container_m_01", + }; + } + } + + internal List CreatePolicies(IReadOnlyCollection factions) + { + var policies = new List(factions.Count); + foreach (var faction in factions) + { + var policyId = $"policy-{faction.Id}"; + faction.DefaultPolicySetId = policyId; + policies.Add(new PolicySetRuntime + { + Id = policyId, + OwnerKind = "faction", + OwnerId = faction.Id, + }); + } + + return policies; + } + + internal List CreateCommanders( + IReadOnlyCollection factions, + IReadOnlyCollection stations, + IReadOnlyCollection ships) + { + var commanders = new List(); + var factionCommanders = new Dictionary(StringComparer.Ordinal); + var factionsById = factions.ToDictionary(faction => faction.Id, StringComparer.Ordinal); + + foreach (var faction in factions) + { + var commander = new CommanderRuntime + { + Id = $"commander-faction-{faction.Id}", + Kind = CommanderKind.Faction, + FactionId = faction.Id, + ControlledEntityId = faction.Id, + PolicySetId = faction.DefaultPolicySetId, + Doctrine = "strategic-control", + }; + + commanders.Add(commander); + factionCommanders[faction.Id] = commander; + faction.CommanderIds.Add(commander.Id); + } + + foreach (var station in stations) + { + if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander)) + { + continue; + } + + var commander = new CommanderRuntime + { + Id = $"commander-station-{station.Id}", + Kind = CommanderKind.Station, + FactionId = station.FactionId, + ParentCommanderId = parentCommander.Id, + ControlledEntityId = station.Id, + PolicySetId = parentCommander.PolicySetId, + Doctrine = "station-control", + }; + + station.CommanderId = commander.Id; + station.PolicySetId = parentCommander.PolicySetId; + parentCommander.SubordinateCommanderIds.Add(commander.Id); + factionsById[station.FactionId].CommanderIds.Add(commander.Id); + commanders.Add(commander); + } + + foreach (var ship in ships) + { + if (!factionCommanders.TryGetValue(ship.FactionId, out var parentCommander)) + { + continue; + } + + var commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = ship.FactionId, + ParentCommanderId = parentCommander.Id, + ControlledEntityId = ship.Id, + PolicySetId = parentCommander.PolicySetId, + Doctrine = "ship-control", + }; + + ship.CommanderId = commander.Id; + ship.PolicySetId = parentCommander.PolicySetId; + parentCommander.SubordinateCommanderIds.Add(commander.Id); + factionsById[ship.FactionId].CommanderIds.Add(commander.Id); + commanders.Add(commander); + } + + return commanders; + } + + internal PlayerFactionRuntime CreatePlayerFaction( + IReadOnlyCollection factions, + IReadOnlyCollection stations, + IReadOnlyCollection ships, + IReadOnlyCollection commanders, + IReadOnlyCollection policies, + DateTimeOffset nowUtc) + { + var sovereignFaction = factions.FirstOrDefault(faction => string.Equals(faction.Id, DefaultFactionId, StringComparison.Ordinal)) + ?? factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); + + var player = new PlayerFactionRuntime + { + Id = "player-faction", + Label = $"{sovereignFaction.Label} Command", + SovereignFactionId = sovereignFaction.Id, + CreatedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + + foreach (var shipId in ships.Where(ship => ship.FactionId == sovereignFaction.Id).Select(ship => ship.Id)) + { + player.AssetRegistry.ShipIds.Add(shipId); + } + + foreach (var stationId in stations.Where(station => station.FactionId == sovereignFaction.Id).Select(station => station.Id)) + { + player.AssetRegistry.StationIds.Add(stationId); + } + + foreach (var commanderId in commanders.Where(commander => commander.FactionId == sovereignFaction.Id).Select(commander => commander.Id)) + { + player.AssetRegistry.CommanderIds.Add(commanderId); + } + + foreach (var policy in policies.Where(policy => string.Equals(policy.OwnerId, sovereignFaction.Id, StringComparison.Ordinal))) + { + player.AssetRegistry.PolicySetIds.Add(policy.Id); + } + + player.Policies.Add(new PlayerFactionPolicyRuntime + { + Id = "player-core-policy", + Label = "Core Empire Policy", + ScopeKind = "player-faction", + ScopeId = player.Id, + PolicySetId = sovereignFaction.DefaultPolicySetId, + TradeAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.TradeAccessPolicy ?? "owner-and-allies", + DockingAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.DockingAccessPolicy ?? "owner-and-allies", + ConstructionAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.ConstructionAccessPolicy ?? "owner-only", + OperationalRangePolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.OperationalRangePolicy ?? "unrestricted", + CombatEngagementPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.CombatEngagementPolicy ?? "defensive", + AvoidHostileSystems = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.AvoidHostileSystems ?? true, + FleeHullRatio = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.FleeHullRatio ?? 0.35f, + UpdatedAtUtc = nowUtc, }); - } - sites.Add(site); + if (policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId) is { } defaultPolicy) + { + foreach (var systemId in defaultPolicy.BlacklistedSystemIds) + { + player.Policies[0].BlacklistedSystemIds.Add(systemId); + } + } + + player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime + { + Id = "player-core-automation", + Label = "Core Automation", + ScopeKind = "player-faction", + ScopeId = player.Id, + BehaviorKind = "idle", + UpdatedAtUtc = nowUtc, + }); + + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = "player-core-reserve", + Label = "Strategic Reserve", + ReserveKind = "military", + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.ReserveIds.Add("player-core-reserve"); + + return player; } - return (sites, orders); - } - - private static bool HasSatisfiedStarterObjectiveLayout(SimulationWorld world, StationRuntime station) - { - var role = StationSimulationService.DetermineStationRole(station); - var objectiveModuleId = role switch + internal static DefaultBehaviorRuntime CreateBehavior( + ShipDefinition definition, + string systemId, + string factionId, + ScenarioDefinition scenario, + IReadOnlyDictionary> patrolRoutes, + IReadOnlyCollection stations, + StationRuntime? refinery) { - "power" => "module_gen_prod_energycells_01", - "refinery" => "module_gen_prod_refinedmetals_01", - "graphene" => "module_gen_prod_graphene_01", - "siliconwafers" => "module_gen_prod_siliconwafers_01", - "hullparts" => "module_gen_prod_hullparts_01", - "claytronics" => "module_gen_prod_claytronics_01", - "quantumtubes" => "module_gen_prod_quantumtubes_01", - "antimattercells" => "module_gen_prod_antimattercells_01", - "superfluidcoolant" => "module_gen_prod_superfluidcoolant_01", - "water" => "module_gen_prod_water_01", - _ => null, - }; + var homeStation = stations.FirstOrDefault(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(station.SystemId, systemId, StringComparison.Ordinal)) + ?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) + ?? refinery; - if (objectiveModuleId is null) - { - return false; + if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "construct-station", + HomeSystemId = homeStation.SystemId, + HomeStationId = homeStation.Id, + PreferredConstructionSiteId = null, + }; + } + + if (HasCapabilities(definition, "mining") && homeStation is not null) + { + return new DefaultBehaviorRuntime + { + Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + HomeSystemId = homeStation.SystemId, + HomeStationId = homeStation.Id, + AreaSystemId = scenario.MiningDefaults.NodeSystemId, + MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1, + }; + } + + if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "advanced-auto-trade", + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + MaxSystemRange = 2, + }; + } + + if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route)) + { + return new DefaultBehaviorRuntime + { + Kind = "patrol", + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + AreaSystemId = systemId, + PatrolPoints = route, + PatrolIndex = 0, + }; + } + + return new DefaultBehaviorRuntime + { + Kind = "idle", + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + }; } - if (!station.InstalledModules.Contains("module_arg_dock_m_01_lowtech", StringComparer.Ordinal) - || !station.InstalledModules.Contains(objectiveModuleId, StringComparer.Ordinal)) + internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition) { - return false; + return definition.Kind switch + { + "transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 }, + "construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 }, + "military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 }, + _ when HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 }, + _ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 }, + }; } - if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal) - && !station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal)) + private static FactionRuntime CreateFaction(string factionId) { - return false; + return factionId switch + { + DefaultFactionId => new FactionRuntime + { + Id = factionId, + Label = "Sol Dominion", + Color = "#7ed4ff", + Credits = MinimumFactionCredits, + }, + "asterion-league" => new FactionRuntime + { + Id = factionId, + Label = "Asterion League", + Color = "#ff8f70", + Credits = MinimumFactionCredits, + }, + "nadir-syndicate" => new FactionRuntime + { + Id = factionId, + Label = "Nadir Syndicate", + Color = "#91e6a8", + Credits = MinimumFactionCredits, + }, + _ => new FactionRuntime + { + Id = factionId, + Label = ToFactionLabel(factionId), + Color = "#c7d2e0", + Credits = MinimumFactionCredits, + }, + }; } - foreach (var storageModuleId in GetRequiredStorageModulesForInstalledObjective(world, objectiveModuleId)) + private static void InitializeStationPopulation(StationRuntime station) { - if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) - { - return false; - } + var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01"); + station.PopulationCapacity = 40f + (habitatModules * 220f); + station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); + station.Population = habitatModules > 0 + ? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f) + : MathF.Min(28f, station.PopulationCapacity); + station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); } - return true; - } - - private static IEnumerable GetRequiredStorageModulesForInstalledObjective(SimulationWorld world, string moduleId) - { - if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + private static string ToFactionLabel(string factionId) { - yield break; + return string.Join(" ", + factionId + .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..])); } - foreach (var wareId in moduleDefinition.Production - .SelectMany(production => production.Wares.Select(ware => ware.ItemId)) - .Concat(moduleDefinition.Products) - .Distinct(StringComparer.Ordinal)) - { - if (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition)) - { - continue; - } - - yield return itemDefinition.CargoKind switch - { - "solid" => "module_arg_stor_solid_m_01", - "liquid" => "module_arg_stor_liquid_m_01", - _ => "module_arg_stor_container_m_01", - }; - } - } - - internal List CreatePolicies(IReadOnlyCollection factions) - { - var policies = new List(factions.Count); - foreach (var faction in factions) - { - var policyId = $"policy-{faction.Id}"; - faction.DefaultPolicySetId = policyId; - policies.Add(new PolicySetRuntime - { - Id = policyId, - OwnerKind = "faction", - OwnerId = faction.Id, - }); - } - - return policies; - } - - internal List CreateCommanders( - IReadOnlyCollection factions, - IReadOnlyCollection stations, - IReadOnlyCollection ships) - { - var commanders = new List(); - var factionCommanders = new Dictionary(StringComparer.Ordinal); - var factionsById = factions.ToDictionary(faction => faction.Id, StringComparer.Ordinal); - - foreach (var faction in factions) - { - var commander = new CommanderRuntime - { - Id = $"commander-faction-{faction.Id}", - Kind = CommanderKind.Faction, - FactionId = faction.Id, - ControlledEntityId = faction.Id, - PolicySetId = faction.DefaultPolicySetId, - Doctrine = "strategic-control", - }; - - commanders.Add(commander); - factionCommanders[faction.Id] = commander; - faction.CommanderIds.Add(commander.Id); - } - - foreach (var station in stations) - { - if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander)) - { - continue; - } - - var commander = new CommanderRuntime - { - Id = $"commander-station-{station.Id}", - Kind = CommanderKind.Station, - FactionId = station.FactionId, - ParentCommanderId = parentCommander.Id, - ControlledEntityId = station.Id, - PolicySetId = parentCommander.PolicySetId, - Doctrine = "station-control", - }; - - station.CommanderId = commander.Id; - station.PolicySetId = parentCommander.PolicySetId; - parentCommander.SubordinateCommanderIds.Add(commander.Id); - factionsById[station.FactionId].CommanderIds.Add(commander.Id); - commanders.Add(commander); - } - - foreach (var ship in ships) - { - if (!factionCommanders.TryGetValue(ship.FactionId, out var parentCommander)) - { - continue; - } - - var commander = new CommanderRuntime - { - Id = $"commander-ship-{ship.Id}", - Kind = CommanderKind.Ship, - FactionId = ship.FactionId, - ParentCommanderId = parentCommander.Id, - ControlledEntityId = ship.Id, - PolicySetId = parentCommander.PolicySetId, - Doctrine = "ship-control", - }; - - ship.CommanderId = commander.Id; - ship.PolicySetId = parentCommander.PolicySetId; - parentCommander.SubordinateCommanderIds.Add(commander.Id); - factionsById[ship.FactionId].CommanderIds.Add(commander.Id); - commanders.Add(commander); - } - - return commanders; - } - - internal PlayerFactionRuntime CreatePlayerFaction( - IReadOnlyCollection factions, - IReadOnlyCollection stations, - IReadOnlyCollection ships, - IReadOnlyCollection commanders, - IReadOnlyCollection policies, - DateTimeOffset nowUtc) - { - var sovereignFaction = factions.FirstOrDefault(faction => string.Equals(faction.Id, DefaultFactionId, StringComparison.Ordinal)) - ?? factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); - - var player = new PlayerFactionRuntime - { - Id = "player-faction", - Label = $"{sovereignFaction.Label} Command", - SovereignFactionId = sovereignFaction.Id, - CreatedAtUtc = nowUtc, - UpdatedAtUtc = nowUtc, - }; - - foreach (var shipId in ships.Where(ship => ship.FactionId == sovereignFaction.Id).Select(ship => ship.Id)) - { - player.AssetRegistry.ShipIds.Add(shipId); - } - - foreach (var stationId in stations.Where(station => station.FactionId == sovereignFaction.Id).Select(station => station.Id)) - { - player.AssetRegistry.StationIds.Add(stationId); - } - - foreach (var commanderId in commanders.Where(commander => commander.FactionId == sovereignFaction.Id).Select(commander => commander.Id)) - { - player.AssetRegistry.CommanderIds.Add(commanderId); - } - - foreach (var policy in policies.Where(policy => string.Equals(policy.OwnerId, sovereignFaction.Id, StringComparison.Ordinal))) - { - player.AssetRegistry.PolicySetIds.Add(policy.Id); - } - - player.Policies.Add(new PlayerFactionPolicyRuntime - { - Id = "player-core-policy", - Label = "Core Empire Policy", - ScopeKind = "player-faction", - ScopeId = player.Id, - PolicySetId = sovereignFaction.DefaultPolicySetId, - TradeAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.TradeAccessPolicy ?? "owner-and-allies", - DockingAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.DockingAccessPolicy ?? "owner-and-allies", - ConstructionAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.ConstructionAccessPolicy ?? "owner-only", - OperationalRangePolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.OperationalRangePolicy ?? "unrestricted", - CombatEngagementPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.CombatEngagementPolicy ?? "defensive", - AvoidHostileSystems = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.AvoidHostileSystems ?? true, - FleeHullRatio = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.FleeHullRatio ?? 0.35f, - UpdatedAtUtc = nowUtc, - }); - - if (policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId) is { } defaultPolicy) - { - foreach (var systemId in defaultPolicy.BlacklistedSystemIds) - { - player.Policies[0].BlacklistedSystemIds.Add(systemId); - } - } - - player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime - { - Id = "player-core-automation", - Label = "Core Automation", - ScopeKind = "player-faction", - ScopeId = player.Id, - BehaviorKind = "idle", - UpdatedAtUtc = nowUtc, - }); - - player.Reserves.Add(new PlayerReserveGroupRuntime - { - Id = "player-core-reserve", - Label = "Strategic Reserve", - ReserveKind = "military", - UpdatedAtUtc = nowUtc, - }); - player.AssetRegistry.ReserveIds.Add("player-core-reserve"); - - return player; - } - - internal static DefaultBehaviorRuntime CreateBehavior( - ShipDefinition definition, - string systemId, - string factionId, - ScenarioDefinition scenario, - IReadOnlyDictionary> patrolRoutes, - IReadOnlyCollection stations, - StationRuntime? refinery) - { - var homeStation = stations.FirstOrDefault(station => - string.Equals(station.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(station.SystemId, systemId, StringComparison.Ordinal)) - ?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) - ?? refinery; - - if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null) - { - return new DefaultBehaviorRuntime - { - Kind = "construct-station", - HomeSystemId = homeStation.SystemId, - HomeStationId = homeStation.Id, - PreferredConstructionSiteId = null, - }; - } - - if (HasCapabilities(definition, "mining") && homeStation is not null) - { - return new DefaultBehaviorRuntime - { - Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", - HomeSystemId = homeStation.SystemId, - HomeStationId = homeStation.Id, - AreaSystemId = scenario.MiningDefaults.NodeSystemId, - MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1, - }; - } - - if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal)) - { - return new DefaultBehaviorRuntime - { - Kind = "advanced-auto-trade", - HomeSystemId = homeStation?.SystemId ?? systemId, - HomeStationId = homeStation?.Id, - MaxSystemRange = 2, - }; - } - - if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route)) - { - return new DefaultBehaviorRuntime - { - Kind = "patrol", - HomeSystemId = homeStation?.SystemId ?? systemId, - HomeStationId = homeStation?.Id, - AreaSystemId = systemId, - PatrolPoints = route, - PatrolIndex = 0, - }; - } - - return new DefaultBehaviorRuntime - { - Kind = "idle", - HomeSystemId = homeStation?.SystemId ?? systemId, - HomeStationId = homeStation?.Id, - }; - } - - internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition) - { - return definition.Kind switch - { - "transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 }, - "construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 }, - "military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 }, - _ when HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 }, - _ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 }, - }; - } - - private static FactionRuntime CreateFaction(string factionId) - { - return factionId switch - { - DefaultFactionId => new FactionRuntime - { - Id = factionId, - Label = "Sol Dominion", - Color = "#7ed4ff", - Credits = MinimumFactionCredits, - }, - "asterion-league" => new FactionRuntime - { - Id = factionId, - Label = "Asterion League", - Color = "#ff8f70", - Credits = MinimumFactionCredits, - }, - "nadir-syndicate" => new FactionRuntime - { - Id = factionId, - Label = "Nadir Syndicate", - Color = "#91e6a8", - Credits = MinimumFactionCredits, - }, - _ => new FactionRuntime - { - Id = factionId, - Label = ToFactionLabel(factionId), - Color = "#c7d2e0", - Credits = MinimumFactionCredits, - }, - }; - } - - private static void InitializeStationPopulation(StationRuntime station) - { - var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01"); - station.PopulationCapacity = 40f + (habitatModules * 220f); - station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); - station.Population = habitatModules > 0 - ? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f) - : MathF.Min(28f, station.PopulationCapacity); - station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); - } - - private static string ToFactionLabel(string factionId) - { - return string.Join(" ", - factionId - .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..])); - } - } diff --git a/apps/backend/Universe/Simulation/OrbitalSimulationOptions.cs b/apps/backend/Universe/Simulation/OrbitalSimulationOptions.cs index 6002fa5..2eb062d 100644 --- a/apps/backend/Universe/Simulation/OrbitalSimulationOptions.cs +++ b/apps/backend/Universe/Simulation/OrbitalSimulationOptions.cs @@ -2,5 +2,5 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class OrbitalSimulationOptions { - public double SimulatedSecondsPerRealSecond { get; init; } = 0d; + public double SimulatedSecondsPerRealSecond { get; init; } = 0d; } diff --git a/apps/backend/Universe/Simulation/SimulationHostedService.cs b/apps/backend/Universe/Simulation/SimulationHostedService.cs index 657797e..427ff2b 100644 --- a/apps/backend/Universe/Simulation/SimulationHostedService.cs +++ b/apps/backend/Universe/Simulation/SimulationHostedService.cs @@ -2,18 +2,18 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class SimulationHostedService(WorldService worldService) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200)); - try + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) - { - worldService.Tick(0.2f); - } + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200)); + try + { + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) + { + worldService.Tick(0.2f); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - } - } } diff --git a/apps/backend/Universe/Simulation/TelemetryService.cs b/apps/backend/Universe/Simulation/TelemetryService.cs index 1ed198f..8a2b1d1 100644 --- a/apps/backend/Universe/Simulation/TelemetryService.cs +++ b/apps/backend/Universe/Simulation/TelemetryService.cs @@ -4,41 +4,41 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class TelemetryService : IDisposable { - private readonly Process _process = Process.GetCurrentProcess(); - private readonly Timer _timer; - private double _cpuPercent; - private DateTime _lastSampleTime; - private TimeSpan _lastCpuTime; + private readonly Process _process = Process.GetCurrentProcess(); + private readonly Timer _timer; + private double _cpuPercent; + private DateTime _lastSampleTime; + private TimeSpan _lastCpuTime; - public TelemetryService() - { - _process.Refresh(); - _lastSampleTime = DateTime.UtcNow; - _lastCpuTime = _process.TotalProcessorTime; - _timer = new Timer(Sample, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); - } + public TelemetryService() + { + _process.Refresh(); + _lastSampleTime = DateTime.UtcNow; + _lastCpuTime = _process.TotalProcessorTime; + _timer = new Timer(Sample, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } - private void Sample(object? _) - { - _process.Refresh(); - var now = DateTime.UtcNow; - var cpu = _process.TotalProcessorTime; - var elapsed = (now - _lastSampleTime).TotalSeconds; - var cpuUsed = (cpu - _lastCpuTime).TotalSeconds; - Volatile.Write(ref _cpuPercent, elapsed > 0 ? cpuUsed / elapsed / Environment.ProcessorCount * 100.0 : 0); - _lastSampleTime = now; - _lastCpuTime = cpu; - } + private void Sample(object? _) + { + _process.Refresh(); + var now = DateTime.UtcNow; + var cpu = _process.TotalProcessorTime; + var elapsed = (now - _lastSampleTime).TotalSeconds; + var cpuUsed = (cpu - _lastCpuTime).TotalSeconds; + Volatile.Write(ref _cpuPercent, elapsed > 0 ? cpuUsed / elapsed / Environment.ProcessorCount * 100.0 : 0); + _lastSampleTime = now; + _lastCpuTime = cpu; + } - public double CpuPercent => Volatile.Read(ref _cpuPercent); - public long WorkingSetBytes => _process.WorkingSet64; - public long GcMemoryBytes => GC.GetTotalMemory(false); - public int ThreadCount => _process.Threads.Count; - public TimeSpan Uptime => DateTime.UtcNow - _process.StartTime.ToUniversalTime(); + public double CpuPercent => Volatile.Read(ref _cpuPercent); + public long WorkingSetBytes => _process.WorkingSet64; + public long GcMemoryBytes => GC.GetTotalMemory(false); + public int ThreadCount => _process.Threads.Count; + public TimeSpan Uptime => DateTime.UtcNow - _process.StartTime.ToUniversalTime(); - public void Dispose() - { - _timer.Dispose(); - _process.Dispose(); - } + public void Dispose() + { + _timer.Dispose(); + _process.Dispose(); + } } diff --git a/apps/backend/Universe/Simulation/WorldGenerationOptions.cs b/apps/backend/Universe/Simulation/WorldGenerationOptions.cs index a15995a..989b66b 100644 --- a/apps/backend/Universe/Simulation/WorldGenerationOptions.cs +++ b/apps/backend/Universe/Simulation/WorldGenerationOptions.cs @@ -2,7 +2,7 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class WorldGenerationOptions { - public int TargetSystemCount { get; init; } - public int AiControllerFactionCount { get; init; } - public bool GeneratePlayerFaction { get; init; } + public int TargetSystemCount { get; init; } + public int AiControllerFactionCount { get; init; } + public bool GeneratePlayerFaction { get; init; } } diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 185f516..656d2d4 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -8,507 +8,507 @@ public sealed class WorldService( IOptions worldGenerationOptions, IOptions orbitalSimulationOptions) { - private const int DeltaHistoryLimit = 256; + private const int DeltaHistoryLimit = 256; - private readonly Lock _sync = new(); - private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); - private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value); - private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value); - private readonly PlayerFactionService _playerFaction = new(); - private readonly Dictionary _subscribers = []; - private readonly Queue _history = []; - private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); - private long _sequence; - private BalanceDefinition? _balanceOverride; + private readonly Lock _sync = new(); + private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); + private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value); + private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value); + private readonly PlayerFactionService _playerFaction = new(); + private readonly Dictionary _subscribers = []; + private readonly Queue _history = []; + private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); + private long _sequence; + private BalanceDefinition? _balanceOverride; - public WorldSnapshot GetSnapshot() - { - lock (_sync) + public WorldSnapshot GetSnapshot() { - return _engine.BuildSnapshot(_world, _sequence); - } - } - - public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus() - { - lock (_sync) - { - return (_sequence, _world.GeneratedAtUtc); - } - } - - public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats() - { - lock (_sync) - { - return (_subscribers.Count, _history.Count); - } - } - - public BalanceDefinition GetBalance() - { - lock (_sync) - { - var b = _world.Balance; - return new BalanceDefinition - { - SimulationSpeedMultiplier = b.SimulationSpeedMultiplier, - YPlane = b.YPlane, - ArrivalThreshold = b.ArrivalThreshold, - MiningRate = b.MiningRate, - MiningCycleSeconds = b.MiningCycleSeconds, - TransferRate = b.TransferRate, - DockingDuration = b.DockingDuration, - UndockingDuration = b.UndockingDuration, - UndockDistance = b.UndockDistance, - }; - } - } - - public BalanceDefinition UpdateBalance(BalanceDefinition balance) - { - lock (_sync) - { - _balanceOverride = SanitizeBalance(balance); - ApplyBalance(_world, _balanceOverride); - return GetBalance(); - } - } - - public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request) - { - lock (_sync) - { - var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request); - if (ship is null) - { - return null; - } - - return GetShipSnapshotUnsafe(ship.Id); - } - } - - public ShipSnapshot? RemoveShipOrder(string shipId, string orderId) - { - lock (_sync) - { - var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId); - if (ship is null) - { - return null; - } - - return GetShipSnapshotUnsafe(ship.Id); - } - } - - public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request) - { - lock (_sync) - { - var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request); - if (ship is null) - { - return null; - } - - return GetShipSnapshotUnsafe(ship.Id); - } - } - - public PlayerFactionSnapshot? GetPlayerFaction() - { - lock (_sync) - { - _playerFaction.EnsureDomain(_world); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request) - { - lock (_sync) - { - _playerFaction.CreateOrganization(_world, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId) - { - lock (_sync) - { - _playerFaction.DeleteOrganization(_world, organizationId); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpdateOrganizationMembership(_world, organizationId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertDirective(_world, directiveId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId) - { - lock (_sync) - { - _playerFaction.DeleteDirective(_world, directiveId); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertPolicy(_world, policyId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertProductionProgram(_world, productionProgramId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpsertAssignment(_world, assetId, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request) - { - lock (_sync) - { - _playerFaction.UpdateStrategicIntent(_world, request); - return GetPlayerFactionSnapshotUnsafe(); - } - } - - public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) - { - var channel = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - }); - - Guid subscriberId; - lock (_sync) - { - subscriberId = Guid.NewGuid(); - _subscribers.Add(subscriberId, new SubscriptionState(scope, channel)); - - foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence)) - { - var filtered = FilterDeltaForScope(delta, scope); - if (HasMeaningfulDelta(filtered)) + lock (_sync) { - channel.Writer.TryWrite(filtered); + return _engine.BuildSnapshot(_world, _sequence); } - } } - cancellationToken.Register(() => Unsubscribe(subscriberId)); - return channel.Reader; - } - - public void Tick(float deltaSeconds) - { - WorldDelta? delta = null; - lock (_sync) + public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus() { - delta = _engine.Tick(_world, deltaSeconds, ++_sequence); - if (!HasMeaningfulDelta(delta)) - { - return; - } - - _history.Enqueue(delta); - while (_history.Count > DeltaHistoryLimit) - { - _history.Dequeue(); - } - - foreach (var subscriber in _subscribers.Values.ToList()) - { - var filtered = FilterDeltaForScope(delta, subscriber.Scope); - if (HasMeaningfulDelta(filtered)) + lock (_sync) { - subscriber.Channel.Writer.TryWrite(filtered); + return (_sequence, _world.GeneratedAtUtc); } - } } - } - public WorldSnapshot Reset() - { - lock (_sync) + public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats() { - _world = _loader.Load(); - if (_balanceOverride is not null) - { - ApplyBalance(_world, _balanceOverride); - } - _sequence += 1; - _history.Clear(); - - var resetDelta = new WorldDelta( - _sequence, - _world.TickIntervalMs, - _world.OrbitalTimeSeconds, - _orbitalSimulation, - DateTimeOffset.UtcNow, - true, - [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")], - [], - [], - [], - [], - [], - [], - [], - [], - [], - null, - null); - - _history.Enqueue(resetDelta); - foreach (var subscriber in _subscribers.Values.ToList()) - { - subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope)); - } - - return _engine.BuildSnapshot(_world, _sequence); + lock (_sync) + { + return (_subscribers.Count, _history.Count); + } } - } - private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) => - world.Balance = new BalanceDefinition + public BalanceDefinition GetBalance() { - SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier, - YPlane = balance.YPlane, - ArrivalThreshold = balance.ArrivalThreshold, - MiningRate = balance.MiningRate, - MiningCycleSeconds = balance.MiningCycleSeconds, - TransferRate = balance.TransferRate, - DockingDuration = balance.DockingDuration, - UndockingDuration = balance.UndockingDuration, - UndockDistance = balance.UndockDistance, - }; - - private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate) - { - static float finiteOr(float value, float fallback) => - float.IsFinite(value) ? value : fallback; - - return new BalanceDefinition - { - SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)), - YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)), - ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)), - MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)), - MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)), - TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)), - DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)), - UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)), - UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)), - }; - } - - private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) => - _engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId); - - private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => - _engine.BuildSnapshot(_world, _sequence).PlayerFaction; - - private static bool HasMeaningfulDelta(WorldDelta delta) => - delta.RequiresSnapshotRefresh - || delta.Events.Count > 0 - || delta.Celestials.Count > 0 - || delta.Nodes.Count > 0 - || delta.Stations.Count > 0 - || delta.Claims.Count > 0 - || delta.ConstructionSites.Count > 0 - || delta.MarketOrders.Count > 0 - || delta.Policies.Count > 0 - || delta.Ships.Count > 0 - || delta.Factions.Count > 0 - || delta.PlayerFaction is not null - || delta.Geopolitics is not null; - - private void Unsubscribe(Guid subscriberId) - { - lock (_sync) - { - if (!_subscribers.Remove(subscriberId, out var subscription)) - { - return; - } - - subscription.Channel.Writer.TryComplete(); + lock (_sync) + { + var b = _world.Balance; + return new BalanceDefinition + { + SimulationSpeedMultiplier = b.SimulationSpeedMultiplier, + YPlane = b.YPlane, + ArrivalThreshold = b.ArrivalThreshold, + MiningRate = b.MiningRate, + MiningCycleSeconds = b.MiningCycleSeconds, + TransferRate = b.TransferRate, + DockingDuration = b.DockingDuration, + UndockingDuration = b.UndockingDuration, + UndockDistance = b.UndockDistance, + }; + } } - } - private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope) - { - if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase)) + public BalanceDefinition UpdateBalance(BalanceDefinition balance) { - return delta with + lock (_sync) + { + _balanceOverride = SanitizeBalance(balance); + ApplyBalance(_world, _balanceOverride); + return GetBalance(); + } + } + + public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request) + { + lock (_sync) + { + var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public ShipSnapshot? RemoveShipOrder(string shipId, string orderId) + { + lock (_sync) + { + var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request) + { + lock (_sync) + { + var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public PlayerFactionSnapshot? GetPlayerFaction() + { + lock (_sync) + { + _playerFaction.EnsureDomain(_world); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request) + { + lock (_sync) + { + _playerFaction.CreateOrganization(_world, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId) + { + lock (_sync) + { + _playerFaction.DeleteOrganization(_world, organizationId); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpdateOrganizationMembership(_world, organizationId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertDirective(_world, directiveId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId) + { + lock (_sync) + { + _playerFaction.DeleteDirective(_world, directiveId); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertPolicy(_world, policyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertProductionProgram(_world, productionProgramId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertAssignment(_world, assetId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpdateStrategicIntent(_world, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) + { + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + Guid subscriberId; + lock (_sync) + { + subscriberId = Guid.NewGuid(); + _subscribers.Add(subscriberId, new SubscriptionState(scope, channel)); + + foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence)) + { + var filtered = FilterDeltaForScope(delta, scope); + if (HasMeaningfulDelta(filtered)) + { + channel.Writer.TryWrite(filtered); + } + } + } + + cancellationToken.Register(() => Unsubscribe(subscriberId)); + return channel.Reader; + } + + public void Tick(float deltaSeconds) + { + WorldDelta? delta = null; + lock (_sync) + { + delta = _engine.Tick(_world, deltaSeconds, ++_sequence); + if (!HasMeaningfulDelta(delta)) + { + return; + } + + _history.Enqueue(delta); + while (_history.Count > DeltaHistoryLimit) + { + _history.Dequeue(); + } + + foreach (var subscriber in _subscribers.Values.ToList()) + { + var filtered = FilterDeltaForScope(delta, subscriber.Scope); + if (HasMeaningfulDelta(filtered)) + { + subscriber.Channel.Writer.TryWrite(filtered); + } + } + } + } + + public WorldSnapshot Reset() + { + lock (_sync) + { + _world = _loader.Load(); + if (_balanceOverride is not null) + { + ApplyBalance(_world, _balanceOverride); + } + _sequence += 1; + _history.Clear(); + + var resetDelta = new WorldDelta( + _sequence, + _world.TickIntervalMs, + _world.OrbitalTimeSeconds, + _orbitalSimulation, + DateTimeOffset.UtcNow, + true, + [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")], + [], + [], + [], + [], + [], + [], + [], + [], + [], + null, + null); + + _history.Enqueue(resetDelta); + foreach (var subscriber in _subscribers.Values.ToList()) + { + subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope)); + } + + return _engine.BuildSnapshot(_world, _sequence); + } + } + + private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) => + world.Balance = new BalanceDefinition { - Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(), - Scope = scope, + SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier, + YPlane = balance.YPlane, + ArrivalThreshold = balance.ArrivalThreshold, + MiningRate = balance.MiningRate, + MiningCycleSeconds = balance.MiningCycleSeconds, + TransferRate = balance.TransferRate, + DockingDuration = balance.DockingDuration, + UndockingDuration = balance.UndockingDuration, + UndockDistance = balance.UndockDistance, }; + + private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate) + { + static float finiteOr(float value, float fallback) => + float.IsFinite(value) ? value : fallback; + + return new BalanceDefinition + { + SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)), + YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)), + ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)), + MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)), + MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)), + TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)), + DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)), + UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)), + UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)), + }; } - var systemFilter = scope.SystemId; - if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null) + private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) => + _engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId); + + private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => + _engine.BuildSnapshot(_world, _sequence).PlayerFaction; + + private static bool HasMeaningfulDelta(WorldDelta delta) => + delta.RequiresSnapshotRefresh + || delta.Events.Count > 0 + || delta.Celestials.Count > 0 + || delta.Nodes.Count > 0 + || delta.Stations.Count > 0 + || delta.Claims.Count > 0 + || delta.ConstructionSites.Count > 0 + || delta.MarketOrders.Count > 0 + || delta.Policies.Count > 0 + || delta.Ships.Count > 0 + || delta.Factions.Count > 0 + || delta.PlayerFaction is not null + || delta.Geopolitics is not null; + + private void Unsubscribe(Guid subscriberId) { - systemFilter = ResolveCelestialSystemId(scope.CelestialId); + lock (_sync) + { + if (!_subscribers.Remove(subscriberId, out var subscription)) + { + return; + } + + subscription.Channel.Writer.TryComplete(); + } } - return delta with + private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope) { - Events = delta.Events - .Select((evt) => EnrichEventScope(evt)) - .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) - .ToList(), - Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(), - Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(), - Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(), - Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(), - ConstructionSites = delta.ConstructionSites.Where((site) => systemFilter is null || site.SystemId == systemFilter).ToList(), - MarketOrders = delta.MarketOrders.Where((order) => IsOrderVisibleToScope(order, systemFilter)).ToList(), - Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [], - Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(), - Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [], - PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null, - Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null, - Scope = scope, - }; - } + if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase)) + { + return delta with + { + Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(), + Scope = scope, + }; + } - private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt) - { - if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null) - { - return evt; + var systemFilter = scope.SystemId; + if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null) + { + systemFilter = ResolveCelestialSystemId(scope.CelestialId); + } + + return delta with + { + Events = delta.Events + .Select((evt) => EnrichEventScope(evt)) + .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) + .ToList(), + Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(), + Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(), + Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(), + Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(), + ConstructionSites = delta.ConstructionSites.Where((site) => systemFilter is null || site.SystemId == systemFilter).ToList(), + MarketOrders = delta.MarketOrders.Where((order) => IsOrderVisibleToScope(order, systemFilter)).ToList(), + Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [], + Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(), + Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [], + PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null, + Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null, + Scope = scope, + }; } - return evt.EntityKind switch + private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt) { - "ship" => WithEntityScope(evt, "system", _world.Ships.FirstOrDefault((ship) => ship.Id == evt.EntityId)?.SystemId), - "station" => WithEntityScope(evt, "system", _world.Stations.FirstOrDefault((station) => station.Id == evt.EntityId)?.SystemId), - "node" => WithEntityScope(evt, "system", _world.Nodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId), - "celestial" => WithEntityScope(evt, "system", _world.Celestials.FirstOrDefault((c) => c.Id == evt.EntityId)?.SystemId), - "claim" => WithEntityScope(evt, "system", _world.Claims.FirstOrDefault((claim) => claim.Id == evt.EntityId)?.SystemId), - "construction-site" => WithEntityScope(evt, "system", _world.ConstructionSites.FirstOrDefault((site) => site.Id == evt.EntityId)?.SystemId), - "market-order" => WithEntityScope(evt, "system", ResolveMarketOrderSystemId(evt.EntityId)), - _ => evt, - }; - } + if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null) + { + return evt; + } - private static SimulationEventRecord WithEntityScope(SimulationEventRecord evt, string scopeKind, string? scopeEntityId) => - evt with - { - Family = evt.Kind.Contains("power", StringComparison.Ordinal) ? "power" : - evt.Kind.Contains("construction", StringComparison.Ordinal) ? "construction" : - evt.Kind.Contains("population", StringComparison.Ordinal) ? "population" : - evt.Kind.Contains("claim", StringComparison.Ordinal) ? "claim" : - "simulation", - ScopeKind = scopeKind, - ScopeEntityId = scopeEntityId, - }; - - private string? ResolveCelestialSystemId(string celestialId) => - _world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId; - - private string? ResolveMarketOrderSystemId(string orderId) - { - var order = _world.MarketOrders.FirstOrDefault((candidate) => candidate.Id == orderId); - if (order?.StationId is not null) - { - return _world.Stations.FirstOrDefault((station) => station.Id == order.StationId)?.SystemId; + return evt.EntityKind switch + { + "ship" => WithEntityScope(evt, "system", _world.Ships.FirstOrDefault((ship) => ship.Id == evt.EntityId)?.SystemId), + "station" => WithEntityScope(evt, "system", _world.Stations.FirstOrDefault((station) => station.Id == evt.EntityId)?.SystemId), + "node" => WithEntityScope(evt, "system", _world.Nodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId), + "celestial" => WithEntityScope(evt, "system", _world.Celestials.FirstOrDefault((c) => c.Id == evt.EntityId)?.SystemId), + "claim" => WithEntityScope(evt, "system", _world.Claims.FirstOrDefault((claim) => claim.Id == evt.EntityId)?.SystemId), + "construction-site" => WithEntityScope(evt, "system", _world.ConstructionSites.FirstOrDefault((site) => site.Id == evt.EntityId)?.SystemId), + "market-order" => WithEntityScope(evt, "system", ResolveMarketOrderSystemId(evt.EntityId)), + _ => evt, + }; } - if (order?.ConstructionSiteId is not null) + private static SimulationEventRecord WithEntityScope(SimulationEventRecord evt, string scopeKind, string? scopeEntityId) => + evt with + { + Family = evt.Kind.Contains("power", StringComparison.Ordinal) ? "power" : + evt.Kind.Contains("construction", StringComparison.Ordinal) ? "construction" : + evt.Kind.Contains("population", StringComparison.Ordinal) ? "population" : + evt.Kind.Contains("claim", StringComparison.Ordinal) ? "claim" : + "simulation", + ScopeKind = scopeKind, + ScopeEntityId = scopeEntityId, + }; + + private string? ResolveCelestialSystemId(string celestialId) => + _world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId; + + private string? ResolveMarketOrderSystemId(string orderId) { - return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId; + var order = _world.MarketOrders.FirstOrDefault((candidate) => candidate.Id == orderId); + if (order?.StationId is not null) + { + return _world.Stations.FirstOrDefault((station) => station.Id == order.StationId)?.SystemId; + } + + if (order?.ConstructionSiteId is not null) + { + return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId; + } + + return null; } - return null; - } - - private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter) - { - if (systemFilter is null) + private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter) { - return true; + if (systemFilter is null) + { + return true; + } + + if (order.StationId is not null) + { + return _world.Stations.Any((station) => station.Id == order.StationId && station.SystemId == systemFilter); + } + + if (order.ConstructionSiteId is not null) + { + return _world.ConstructionSites.Any((site) => site.Id == order.ConstructionSiteId && site.SystemId == systemFilter); + } + + return false; } - if (order.StationId is not null) + private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter) { - return _world.Stations.Any((station) => station.Id == order.StationId && station.SystemId == systemFilter); + return scope.ScopeKind switch + { + "universe" => true, + "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, + "local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, + _ => true, + }; } - if (order.ConstructionSiteId is not null) - { - return _world.ConstructionSites.Any((site) => site.Id == order.ConstructionSiteId && site.SystemId == systemFilter); - } - - return false; - } - - private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter) - { - return scope.ScopeKind switch - { - "universe" => true, - "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, - "local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, - _ => true, - }; - } - - private sealed record SubscriptionState(ObserverScope Scope, Channel Channel); + private sealed record SubscriptionState(ObserverScope Scope, Channel Channel); }