From ddca4a16d5b23a28b18529ac240c6b57f3dc45f6 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Sat, 14 Mar 2026 02:30:15 -0400 Subject: [PATCH] Implement roadmap phases 1 through 8 --- apps/backend/Contracts/WorldContracts.cs | 243 ++- apps/backend/Data/WorldDefinitions.cs | 18 + apps/backend/Program.cs | 20 +- apps/backend/Simulation/RuntimeModels.cs | 288 ++- apps/backend/Simulation/ScenarioLoader.cs | 616 ++++++- apps/backend/Simulation/SimulationEngine.cs | 1779 +++++++++++++++++-- apps/backend/Simulation/WorldService.cs | 160 +- apps/viewer/src/GameViewer.ts | 759 +++++++- apps/viewer/src/api.ts | 22 +- apps/viewer/src/contracts.ts | 155 ++ 10 files changed, 3862 insertions(+), 198 deletions(-) diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index f746657..0392b9b 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -7,8 +7,14 @@ public sealed record WorldSnapshot( int TickIntervalMs, DateTimeOffset GeneratedAtUtc, IReadOnlyList Systems, + IReadOnlyList SpatialNodes, + IReadOnlyList LocalBubbles, IReadOnlyList Nodes, IReadOnlyList Stations, + IReadOnlyList Claims, + IReadOnlyList ConstructionSites, + IReadOnlyList MarketOrders, + IReadOnlyList Policies, IReadOnlyList Ships, IReadOnlyList Factions); @@ -18,17 +24,33 @@ public sealed record WorldDelta( DateTimeOffset GeneratedAtUtc, bool RequiresSnapshotRefresh, IReadOnlyList Events, + IReadOnlyList SpatialNodes, + IReadOnlyList LocalBubbles, IReadOnlyList Nodes, IReadOnlyList Stations, + IReadOnlyList Claims, + IReadOnlyList ConstructionSites, + IReadOnlyList MarketOrders, + IReadOnlyList Policies, IReadOnlyList Ships, - IReadOnlyList Factions); + IReadOnlyList Factions, + ObserverScope? Scope = null); public sealed record SimulationEventRecord( string EntityKind, string EntityId, string Kind, string Message, - DateTimeOffset OccurredAtUtc); + DateTimeOffset OccurredAtUtc, + string Family = "simulation", + string ScopeKind = "universe", + string? ScopeEntityId = null, + string Visibility = "public"); + +public sealed record ObserverScope( + string ScopeKind, + string? SystemId = null, + string? BubbleId = null); public sealed record SystemSnapshot( string Id, @@ -65,6 +87,46 @@ public sealed record ResourceNodeSnapshot( float MaxOre, string ItemId); +public sealed record SpatialNodeSnapshot( + string Id, + string SystemId, + string Kind, + Vector3Dto LocalPosition, + string BubbleId, + string? ParentNodeId, + string? OccupyingStructureId, + string? OrbitReferenceId); + +public sealed record SpatialNodeDelta( + string Id, + string SystemId, + string Kind, + Vector3Dto LocalPosition, + string BubbleId, + string? ParentNodeId, + string? OccupyingStructureId, + string? OrbitReferenceId); + +public sealed record LocalBubbleSnapshot( + string Id, + string NodeId, + string SystemId, + float Radius, + IReadOnlyList OccupantShipIds, + IReadOnlyList OccupantStationIds, + IReadOnlyList OccupantClaimIds, + IReadOnlyList OccupantConstructionSiteIds); + +public sealed record LocalBubbleDelta( + string Id, + string NodeId, + string SystemId, + float Radius, + IReadOnlyList OccupantShipIds, + IReadOnlyList OccupantStationIds, + IReadOnlyList OccupantClaimIds, + IReadOnlyList OccupantConstructionSiteIds); + public sealed record ResourceNodeDelta( string Id, string SystemId, @@ -84,12 +146,23 @@ public sealed record StationSnapshot( string Category, string SystemId, Vector3Dto LocalPosition, + string? NodeId, + string? BubbleId, + string? AnchorNodeId, string Color, int DockedShips, int DockingPads, float EnergyStored, IReadOnlyList Inventory, - string FactionId); + string FactionId, + string? CommanderId, + string? PolicySetId, + float Population, + float PopulationCapacity, + float WorkforceRequired, + float WorkforceEffectiveRatio, + IReadOnlyList InstalledModules, + IReadOnlyList MarketOrderIds); public sealed record StationDelta( string Id, @@ -97,12 +170,129 @@ public sealed record StationDelta( string Category, string SystemId, Vector3Dto LocalPosition, + string? NodeId, + string? BubbleId, + string? AnchorNodeId, string Color, int DockedShips, int DockingPads, float EnergyStored, IReadOnlyList Inventory, - string FactionId); + string FactionId, + string? CommanderId, + string? PolicySetId, + float Population, + float PopulationCapacity, + float WorkforceRequired, + float WorkforceEffectiveRatio, + IReadOnlyList InstalledModules, + IReadOnlyList MarketOrderIds); + +public sealed record ClaimSnapshot( + string Id, + string FactionId, + string SystemId, + string NodeId, + string BubbleId, + string State, + float Health, + DateTimeOffset PlacedAtUtc, + DateTimeOffset ActivatesAtUtc); + +public sealed record ClaimDelta( + string Id, + string FactionId, + string SystemId, + string NodeId, + string BubbleId, + string State, + float Health, + DateTimeOffset PlacedAtUtc, + DateTimeOffset ActivatesAtUtc); + +public sealed record ConstructionSiteSnapshot( + string Id, + string FactionId, + string SystemId, + string NodeId, + string BubbleId, + string TargetKind, + string TargetDefinitionId, + string? BlueprintId, + string? ClaimId, + string? StationId, + string State, + float Progress, + IReadOnlyList Inventory, + IReadOnlyList RequiredItems, + IReadOnlyList DeliveredItems, + IReadOnlyList AssignedConstructorShipIds, + IReadOnlyList MarketOrderIds); + +public sealed record ConstructionSiteDelta( + string Id, + string FactionId, + string SystemId, + string NodeId, + string BubbleId, + string TargetKind, + string TargetDefinitionId, + string? BlueprintId, + string? ClaimId, + string? StationId, + string State, + float Progress, + IReadOnlyList Inventory, + IReadOnlyList RequiredItems, + IReadOnlyList DeliveredItems, + IReadOnlyList AssignedConstructorShipIds, + IReadOnlyList MarketOrderIds); + +public sealed record MarketOrderSnapshot( + string Id, + string FactionId, + string? StationId, + string? ConstructionSiteId, + string Kind, + string ItemId, + float Amount, + float RemainingAmount, + float Valuation, + float? ReserveThreshold, + string? PolicySetId, + string State); + +public sealed record MarketOrderDelta( + string Id, + string FactionId, + string? StationId, + string? ConstructionSiteId, + string Kind, + string ItemId, + float Amount, + float RemainingAmount, + float Valuation, + float? ReserveThreshold, + string? PolicySetId, + string State); + +public sealed record PolicySetSnapshot( + string Id, + string OwnerKind, + string OwnerId, + string TradeAccessPolicy, + string DockingAccessPolicy, + string ConstructionAccessPolicy, + string OperationalRangePolicy); + +public sealed record PolicySetDelta( + string Id, + string OwnerKind, + string OwnerId, + string TradeAccessPolicy, + string DockingAccessPolicy, + string ConstructionAccessPolicy, + string OperationalRangePolicy); public sealed record ShipSnapshot( string Id, @@ -117,12 +307,19 @@ public sealed record ShipSnapshot( string? OrderKind, string DefaultBehaviorKind, string ControllerTaskKind, + string? NodeId, + string? BubbleId, + string? DockedStationId, + string? CommanderId, + string? PolicySetId, float CargoCapacity, + float WorkerPopulation, float EnergyStored, IReadOnlyList Inventory, string FactionId, float Health, - IReadOnlyList History); + IReadOnlyList History, + ShipSpatialStateSnapshot SpatialState); public sealed record ShipDelta( string Id, @@ -137,31 +334,61 @@ public sealed record ShipDelta( string? OrderKind, string DefaultBehaviorKind, string ControllerTaskKind, + string? NodeId, + string? BubbleId, + string? DockedStationId, + string? CommanderId, + string? PolicySetId, float CargoCapacity, + float WorkerPopulation, float EnergyStored, IReadOnlyList Inventory, string FactionId, float Health, - IReadOnlyList History); + IReadOnlyList History, + ShipSpatialStateSnapshot SpatialState); + +public sealed record ShipSpatialStateSnapshot( + string SpaceLayer, + string CurrentSystemId, + string? CurrentNodeId, + string? CurrentBubbleId, + Vector3Dto? LocalPosition, + Vector3Dto? SystemPosition, + string MovementRegime, + string? DestinationNodeId, + ShipTransitSnapshot? Transit); + +public sealed record ShipTransitSnapshot( + string Regime, + string? OriginNodeId, + string? DestinationNodeId, + DateTimeOffset? StartedAtUtc, + DateTimeOffset? ArrivalDueAtUtc, + float Progress); public sealed record FactionSnapshot( string Id, string Label, string Color, float Credits, + float PopulationTotal, float OreMined, float GoodsProduced, int ShipsBuilt, - int ShipsLost); + int ShipsLost, + string? DefaultPolicySetId); public sealed record FactionDelta( string Id, string Label, string Color, float Credits, + float PopulationTotal, float OreMined, float GoodsProduced, int ShipsBuilt, - int ShipsLost); + int ShipsLost, + string? DefaultPolicySetId); public sealed record Vector3Dto(float X, float Y, float Z); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 999f58d..1953ec9 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -83,6 +83,24 @@ public sealed class ModuleRecipeDefinition public required List Inputs { get; set; } } +public sealed class RecipeOutputDefinition +{ + public required string ItemId { get; set; } + public float Amount { 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 sealed class PlanetDefinition { public required string Label { get; set; } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index a30dd5e..abeacbc 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -1,3 +1,4 @@ +using SpaceGame.Simulation.Api.Contracts; using SpaceGame.Simulation.Api.Simulation; using System.Text.Json; @@ -31,7 +32,24 @@ app.MapGet("/api/world/stream", async (HttpContext httpContext, WorldService wor var afterSequenceRaw = httpContext.Request.Query["afterSequence"].ToString(); _ = long.TryParse(afterSequenceRaw, out var afterSequence); - var stream = worldService.Subscribe(afterSequence, cancellationToken); + 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); diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index b7054c0..a7f9087 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -9,12 +9,20 @@ public sealed class SimulationWorld public required BalanceDefinition Balance { get; init; } public required List Systems { get; init; } public required List Nodes { get; init; } + public required List SpatialNodes { get; init; } + public required List LocalBubbles { get; init; } public required List Stations { get; init; } public required List Ships { get; init; } public required List Factions { get; init; } + public required List Commanders { get; init; } + public required List Claims { get; init; } + public required List ConstructionSites { get; init; } + public required List MarketOrders { get; init; } + public required List Policies { get; init; } public required Dictionary ShipDefinitions { get; init; } public required Dictionary ItemDefinitions { get; init; } public required Dictionary ModuleRecipes { get; init; } + public required Dictionary Recipes { get; init; } public int TickIntervalMs { get; init; } = 200; public DateTimeOffset GeneratedAtUtc { get; set; } } @@ -37,18 +45,55 @@ public sealed class ResourceNodeRuntime public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class NodeRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; init; } + public required string Kind { get; init; } + public required Vector3 Position { get; set; } + public required string BubbleId { get; init; } + public string? ParentNodeId { get; set; } + public string? OccupyingStructureId { get; set; } + public string? OrbitReferenceId { get; set; } + public string LastDeltaSignature { get; set; } = string.Empty; +} + +public sealed class LocalBubbleRuntime +{ + public required string Id { get; init; } + public required string NodeId { get; init; } + public required string SystemId { get; init; } + public float Radius { get; init; } + public HashSet OccupantShipIds { get; } = new(StringComparer.Ordinal); + public HashSet OccupantStationIds { get; } = new(StringComparer.Ordinal); + public HashSet OccupantClaimIds { get; } = new(StringComparer.Ordinal); + public HashSet OccupantConstructionSiteIds { get; } = new(StringComparer.Ordinal); + public string LastDeltaSignature { get; set; } = string.Empty; +} + public sealed class StationRuntime { public required string Id { get; init; } public required string SystemId { get; init; } public required ConstructibleDefinition Definition { get; init; } - public required Vector3 Position { get; init; } + public required Vector3 Position { get; set; } public required string FactionId { get; init; } + public string? NodeId { get; set; } + public string? BubbleId { get; set; } + public string? AnchorNodeId { get; set; } + public string? CommanderId { get; set; } + public string? PolicySetId { get; set; } public HashSet InstalledModules { get; } = new(StringComparer.Ordinal); public Dictionary Inventory { get; } = new(StringComparer.Ordinal); public Dictionary DockingPadAssignments { get; } = new(); + public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); public float EnergyStored { get; set; } public float ProcessTimer { get; set; } + 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 HashSet DockedShipIds { get; } = []; public ModuleConstructionRuntime? ActiveConstruction { get; set; } public string LastDeltaSignature { get; set; } = string.Empty; @@ -70,6 +115,7 @@ public sealed class ShipRuntime 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 string State { get; set; } = "idle"; public ShipOrderRuntime? Order { get; set; } @@ -77,9 +123,12 @@ public sealed class ShipRuntime public required ControllerTaskRuntime ControllerTask { get; set; } public float ActionTimer { get; set; } public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public float WorkerPopulation { get; set; } public float EnergyStored { get; set; } public string? DockedStationId { get; set; } public int? AssignedDockingPadIndex { get; set; } + public string? CommanderId { get; set; } + public string? PolicySetId { get; set; } public float Health { get; set; } public List History { get; } = []; public string LastSignature { get; set; } = string.Empty; @@ -92,10 +141,128 @@ public sealed class FactionRuntime 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 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 List Goals { get; } = []; + public CommanderBehaviorRuntime? ActiveBehavior { get; set; } + public CommanderOrderRuntime? ActiveOrder { get; set; } + public CommanderTaskRuntime? ActiveTask { get; set; } + public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); + public bool IsAlive { get; set; } = true; +} + +public sealed class CommanderBehaviorRuntime +{ + public required string Kind { get; set; } + public string? Phase { get; set; } + public string? NodeId { get; set; } + public string? StationId { get; set; } + public string? ModuleId { get; set; } + public string? AreaSystemId { get; set; } + public int PatrolIndex { get; set; } +} + +public sealed class CommanderOrderRuntime +{ + public required string Kind { get; init; } + public string Status { get; set; } = "accepted"; + public string? TargetEntityId { get; set; } + public string? DestinationNodeId { get; set; } + public required string DestinationSystemId { get; init; } + public required Vector3 DestinationPosition { get; init; } +} + +public sealed class CommanderTaskRuntime +{ + public required string Kind { get; set; } + public string Status { get; set; } = "pending"; + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetNodeId { get; set; } + public Vector3? TargetPosition { get; set; } + public float Threshold { get; set; } +} + +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 NodeId { get; init; } + public required string BubbleId { 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 NodeId { get; init; } + public required string BubbleId { 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 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 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 LastDeltaSignature { get; set; } = string.Empty; } @@ -123,12 +290,131 @@ public sealed class DefaultBehaviorRuntime public sealed class ControllerTaskRuntime { public required string Kind { get; set; } + public string Status { get; set; } = "pending"; + public string? CommanderId { get; set; } public string? TargetEntityId { get; set; } public string? TargetSystemId { get; set; } + public string? TargetNodeId { get; set; } public Vector3? TargetPosition { get; set; } public float Threshold { get; set; } } +public sealed class ShipSpatialStateRuntime +{ + public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace; + public required string CurrentSystemId { get; set; } + public string? CurrentNodeId { get; set; } + public string? CurrentBubbleId { get; set; } + public Vector3? LocalPosition { get; set; } + public Vector3? SystemPosition { get; set; } + public string MovementRegime { get; set; } = MovementRegimeKinds.LocalFlight; + public string? DestinationNodeId { get; set; } + public ShipTransitRuntime? Transit { get; set; } +} + +public sealed class ShipTransitRuntime +{ + public required string Regime { get; init; } + public string? OriginNodeId { get; init; } + public string? DestinationNodeId { get; init; } + public DateTimeOffset? StartedAtUtc { get; set; } + public DateTimeOffset? ArrivalDueAtUtc { get; set; } + public float Progress { get; set; } +} + +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 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 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 static class ShipTaskKinds +{ + public const string Idle = "idle"; + public const string LocalMove = "local-move"; + public const string WarpToNode = "warp-to-node"; + public const string UseStargate = "use-stargate"; + public const string UseFtl = "use-ftl"; + 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 LoadWorkers = "load-workers"; + public const string UnloadWorkers = "unload-workers"; + public const string MineNode = "mine-node"; + public const string HarvestGas = "harvest-gas"; + public const string DeliverToStation = "deliver-to-station"; + public const string ClaimLagrangePoint = "claim-lagrange-point"; + public const string BuildConstructionSite = "build-construction-site"; + public const string EscortTarget = "escort-target"; + public const string AttackTarget = "attack-target"; + public const string DefendBubble = "defend-bubble"; + public const string Retreat = "retreat"; + public const string HoldPosition = "hold-position"; +} + +public static class ShipOrderKinds +{ + public const string DirectMove = "direct-move"; + public const string TravelToNode = "travel-to-node"; + public const string DockAtStation = "dock-at-station"; + public const string DeliverCargo = "deliver-cargo"; + public const string BuildAtSite = "build-at-site"; + public const string AttackTarget = "attack-target"; + public const string HoldPosition = "hold-position"; +} + +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 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 static class MarketOrderKinds +{ + 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 readonly record struct Vector3(float X, float Y, float Z) { public static Vector3 Zero => new(0f, 0f, 0f); diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index 1eda561..db66935 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -13,6 +13,11 @@ public sealed class ScenarioLoader private const float MinimumRefineryStock = 0f; private const float MinimumShipyardStock = 0f; private const float MinimumSystemSeparation = 3200f; + private const float StarBubbleRadiusPadding = 40f; + private const float PlanetBubbleRadiusPadding = 80f; + private const float MoonBubbleRadiusPadding = 40f; + private const float LagrangeBubbleRadius = 150f; + private const float ResourceBubbleRadius = 120f; private static readonly string[] GeneratedSystemNames = [ "Aquila Verge", @@ -88,12 +93,14 @@ public sealed class ScenarioLoader var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); var items = Read>("items.json"); + var recipes = Read>("recipes.json"); var moduleRecipes = Read>("module-recipes.json"); var balance = Read("balance.json"); var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); + var recipeDefinitions = recipes.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal); var systemRuntimes = systems .Select((definition) => new SystemRuntime @@ -103,14 +110,26 @@ public sealed class ScenarioLoader }) .ToList(); var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal); + var systemGraphs = systemRuntimes.ToDictionary( + (system) => system.Definition.Id, + (system) => BuildSystemSpatialGraph(system), + StringComparer.Ordinal); var nodes = new List(); + var spatialNodes = new List(); + var localBubbles = new List(); var nodeIdCounter = 0; + foreach (var graph in systemGraphs.Values) + { + spatialNodes.AddRange(graph.Nodes); + localBubbles.AddRange(graph.Bubbles); + } + foreach (var system in systemRuntimes) { foreach (var node in system.Definition.ResourceNodes) { - nodes.Add(new ResourceNodeRuntime + var resourceNode = new ResourceNodeRuntime { Id = $"node-{++nodeIdCounter}", SystemId = system.Definition.Id, @@ -122,6 +141,24 @@ public sealed class ScenarioLoader ItemId = node.ItemId, OreRemaining = node.OreAmount, MaxOre = node.OreAmount, + }; + + nodes.Add(resourceNode); + var bubbleId = $"bubble-{resourceNode.Id}"; + spatialNodes.Add(new NodeRuntime + { + Id = resourceNode.Id, + SystemId = resourceNode.SystemId, + Kind = "resource-site", + Position = resourceNode.Position, + BubbleId = bubbleId, + }); + localBubbles.Add(new LocalBubbleRuntime + { + Id = bubbleId, + NodeId = resourceNode.Id, + SystemId = resourceNode.SystemId, + Radius = ResourceBubbleRadius, }); } } @@ -135,14 +172,41 @@ public sealed class ScenarioLoader continue; } - stations.Add(new StationRuntime + var placement = ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], spatialNodes); + var station = new StationRuntime { Id = $"station-{++stationIdCounter}", SystemId = system.Definition.Id, Definition = definition, - Position = ResolveStationPosition(system, plan, balance), + Position = placement.Position, FactionId = plan.FactionId ?? DefaultFactionId, + }; + + var stationNodeId = $"node-{station.Id}"; + var stationBubbleId = $"bubble-{station.Id}"; + station.NodeId = stationNodeId; + station.BubbleId = stationBubbleId; + station.AnchorNodeId = placement.AnchorNode.Id; + stations.Add(station); + spatialNodes.Add(new NodeRuntime + { + Id = stationNodeId, + SystemId = station.SystemId, + Kind = "station", + Position = station.Position, + BubbleId = stationBubbleId, + ParentNodeId = placement.AnchorNode.Id, + OccupyingStructureId = station.Id, }); + localBubbles.Add(new LocalBubbleRuntime + { + Id = stationBubbleId, + NodeId = stationNodeId, + SystemId = station.SystemId, + Radius = MathF.Max(160f, definition.Radius + 60f), + }); + localBubbles[^1].OccupantStationIds.Add(station.Id); + placement.AnchorNode.OccupyingStructureId = station.Id; foreach (var moduleId in definition.Modules) { @@ -152,8 +216,13 @@ public sealed class ScenarioLoader foreach (var station in stations) { + InitializeStationPopulation(station); station.Inventory["fuel"] = 240f; station.Inventory["refined-metals"] = 120f; + if (station.Population > 0f) + { + station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f); + } } var refinery = stations.FirstOrDefault((station) => @@ -187,8 +256,9 @@ public sealed class ScenarioLoader FactionId = formation.FactionId ?? DefaultFactionId, Position = position, TargetPosition = position, + SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes), DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), - ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold }, + ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = "pending" }, Health = definition.MaxHealth, }); @@ -209,6 +279,11 @@ public sealed class ScenarioLoader var factions = CreateFactions(stations, shipsRuntime); BootstrapFactionEconomy(factions, stations); + var policies = CreatePolicies(factions); + var commanders = CreateCommanders(factions, stations, shipsRuntime); + var now = DateTimeOffset.UtcNow; + var claims = CreateClaims(stations, spatialNodes, now); + var (constructionSites, marketOrders) = CreateConstructionSites(stations, claims, spatialNodes, moduleRecipeDefinitions); return new SimulationWorld { @@ -217,12 +292,20 @@ public sealed class ScenarioLoader Balance = balance, Systems = systemRuntimes, Nodes = nodes, + SpatialNodes = spatialNodes, + LocalBubbles = localBubbles, Stations = stations, Ships = shipsRuntime, Factions = factions, + Commanders = commanders, + Claims = claims, + ConstructionSites = constructionSites, + MarketOrders = marketOrders, + Policies = policies, ShipDefinitions = shipDefinitions, ItemDefinitions = itemDefinitions, ModuleRecipes = moduleRecipeDefinitions, + Recipes = recipeDefinitions, GeneratedAtUtc = DateTimeOffset.UtcNow, }; } @@ -810,6 +893,442 @@ public sealed class ScenarioLoader private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) + { + var nodes = new List(); + var bubbles = new List(); + var lagrangeNodesByPlanetIndex = new Dictionary>(); + + var starNode = AddSpatialNode( + nodes, + bubbles, + id: $"node-{system.Definition.Id}-star", + systemId: system.Definition.Id, + kind: "star", + position: Vector3.Zero, + radius: MathF.Max(system.Definition.GravityWellRadius + StarBubbleRadiusPadding, 180f)); + + 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 planetNode = AddSpatialNode( + nodes, + bubbles, + id: planetNodeId, + systemId: system.Definition.Id, + kind: "planet", + position: planetPosition, + radius: MathF.Max(planet.Size + PlanetBubbleRadiusPadding, 120f), + parentNodeId: starNode.Id); + + var lagrangeNodes = new Dictionary(StringComparer.Ordinal); + foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) + { + var lagrangeNode = AddSpatialNode( + nodes, + bubbles, + id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}", + systemId: system.Definition.Id, + kind: "lagrange-point", + position: point.Position, + radius: LagrangeBubbleRadius, + parentNodeId: planetNode.Id, + orbitReferenceId: point.Designation); + lagrangeNodes[point.Designation] = lagrangeNode; + } + + lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes; + + if (planet.MoonCount <= 0) + { + continue; + } + + var moonOrbitRadius = MathF.Max(planet.Size + 48f, 42f); + for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1) + { + var moonPosition = ComputeMoonPosition(planetPosition, moonOrbitRadius, moonIndex, planetIndex); + AddSpatialNode( + nodes, + bubbles, + id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}", + systemId: system.Definition.Id, + kind: "moon", + position: moonPosition, + radius: MoonBubbleRadiusPadding + 24f, + parentNodeId: planetNode.Id); + moonOrbitRadius += 30f; + } + } + + return new SystemSpatialGraph(system.Definition.Id, nodes, bubbles, lagrangeNodesByPlanetIndex); + } + + private static NodeRuntime AddSpatialNode( + ICollection nodes, + ICollection bubbles, + string id, + string systemId, + string kind, + Vector3 position, + float radius, + string? parentNodeId = null, + string? orbitReferenceId = null) + { + var bubbleId = $"bubble-{id}"; + var node = new NodeRuntime + { + Id = id, + SystemId = systemId, + Kind = kind, + Position = position, + BubbleId = bubbleId, + ParentNodeId = parentNodeId, + OrbitReferenceId = orbitReferenceId, + }; + + nodes.Add(node); + bubbles.Add(new LocalBubbleRuntime + { + Id = bubbleId, + NodeId = id, + SystemId = systemId, + Radius = radius, + }); + return node; + } + + private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) + { + var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); + var tangential = new Vector3(-radial.Z, 0f, radial.X); + var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f)); + 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, -orbitRadius)); + yield return new LagrangePointPlacement( + "L4", + Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); + yield return new LagrangePointPlacement( + "L5", + Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); + } + + private static StationPlacement ResolveStationPlacement( + InitialStationDefinition plan, + SystemRuntime system, + SystemSpatialGraph graph, + IReadOnlyCollection existingNodes) + { + if (plan.PlanetIndex is int planetIndex && + graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) + { + var designation = ResolveLagrangeDesignation(plan.LagrangeSide); + if (lagrangeNodes.TryGetValue(designation, out var lagrangeNode)) + { + return new StationPlacement(lagrangeNode, lagrangeNode.Position); + } + } + + if (plan.Position is { Length: 3 }) + { + var targetPosition = NormalizeScenarioPoint(system, plan.Position); + var preferredNode = existingNodes + .Where((node) => node.SystemId == system.Definition.Id && node.Kind == "lagrange-point") + .OrderBy((node) => node.Position.DistanceTo(targetPosition)) + .FirstOrDefault() + ?? existingNodes + .Where((node) => node.SystemId == system.Definition.Id) + .OrderBy((node) => node.Position.DistanceTo(targetPosition)) + .First(); + return new StationPlacement(preferredNode, preferredNode.Position); + } + + var fallbackNode = graph.Nodes + .FirstOrDefault((node) => node.Kind == "lagrange-point" && string.IsNullOrEmpty(node.OccupyingStructureId)) + ?? graph.Nodes.First((node) => node.Kind == "planet"); + return new StationPlacement(fallbackNode, fallbackNode.Position); + } + + private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch + { + < 0 => "L4", + > 0 => "L5", + _ => "L1", + }; + + private static Vector3 ComputePlanetPosition(PlanetDefinition planet) + { + var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); + var x = MathF.Cos(angle) * planet.OrbitRadius; + var z = MathF.Sin(angle) * planet.OrbitRadius; + return new Vector3(x, 0f, z); + } + + private static Vector3 ComputeMoonPosition(Vector3 planetPosition, float orbitRadius, int moonIndex, int planetIndex) + { + var angle = ((MathF.PI * 2f) / MathF.Max(1, moonIndex + 3)) * (moonIndex + 1) + (planetIndex * 0.37f); + return Add(planetPosition, new Vector3(MathF.Cos(angle) * orbitRadius, 0f, MathF.Sin(angle) * orbitRadius)); + } + + private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection nodes) + { + var nearestNode = nodes + .Where((node) => node.SystemId == systemId) + .OrderBy((node) => node.Position.DistanceTo(position)) + .FirstOrDefault(); + + return new ShipSpatialStateRuntime + { + CurrentSystemId = systemId, + SpaceLayer = SpaceLayerKinds.LocalSpace, + CurrentNodeId = nearestNode?.Id, + CurrentBubbleId = nearestNode?.BubbleId, + LocalPosition = position, + SystemPosition = position, + MovementRegime = MovementRegimeKinds.LocalFlight, + }; + } + + private static List CreateClaims( + IReadOnlyCollection stations, + IReadOnlyCollection nodes, + DateTimeOffset nowUtc) + { + var claims = new List(stations.Count); + foreach (var station in stations) + { + if (station.AnchorNodeId is null) + { + continue; + } + + var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId); + if (anchorNode is null) + { + continue; + } + + claims.Add(new ClaimRuntime + { + Id = $"claim-{station.Id}", + FactionId = station.FactionId, + SystemId = station.SystemId, + NodeId = anchorNode.Id, + BubbleId = anchorNode.BubbleId, + PlacedAtUtc = nowUtc, + ActivatesAtUtc = nowUtc.AddSeconds(8), + State = ClaimStateKinds.Activating, + Health = 100f, + }); + } + + return claims; + } + + private static (List ConstructionSites, List MarketOrders) CreateConstructionSites( + IReadOnlyCollection stations, + IReadOnlyCollection claims, + IReadOnlyCollection nodes, + IReadOnlyDictionary moduleRecipes) + { + var sites = new List(); + var orders = new List(); + + foreach (var station in stations) + { + var moduleId = GetNextConstructionSiteModule(station, moduleRecipes); + if (moduleId is null || station.AnchorNodeId is null) + { + continue; + } + + var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId); + var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}"); + if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) + { + continue; + } + + var site = new ConstructionSiteRuntime + { + Id = $"site-{station.Id}", + FactionId = station.FactionId, + SystemId = station.SystemId, + NodeId = anchorNode.Id, + BubbleId = anchorNode.BubbleId, + TargetKind = "station-module", + TargetDefinitionId = station.Definition.Id, + 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 string? GetNextConstructionSiteModule( + StationRuntime station, + IReadOnlyDictionary moduleRecipes) + { + foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) + { + if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) + && moduleRecipes.ContainsKey(moduleId)) + { + return moduleId; + } + } + + return null; + } + + private static void InitializeStationPopulation(StationRuntime station) + { + var habitatModules = CountModules(station.InstalledModules, "habitat-ring"); + station.PopulationCapacity = 40f + (habitatModules * 220f); + station.WorkforceRequired = MathF.Max(12f, station.InstalledModules.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 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; + } + + private static 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-default", + }; + + 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-default", + }; + + 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-default", + ActiveBehavior = CopyBehavior(ship.DefaultBehavior), + ActiveTask = CopyTask(ship.ControllerTask, null), + }; + + if (ship.Order is not null) + { + commander.ActiveOrder = CopyOrder(ship.Order); + } + + 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; + } + private static string ToFactionLabel(string factionId) { return string.Join(" ", @@ -880,26 +1399,6 @@ public sealed class ScenarioLoader }; } - private static Vector3 ResolveStationPosition(SystemRuntime system, InitialStationDefinition plan, BalanceDefinition balance) - { - if (plan.Position is { Length: 3 }) - { - return NormalizeScenarioPoint(system, plan.Position); - } - - if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count) - { - var planet = system.Definition.Planets[planetIndex]; - var side = plan.LagrangeSide ?? 1; - return new Vector3( - planet.OrbitRadius + (side * 72f), - balance.YPlane, - (planetIndex + 1) * 42f * side); - } - - return new Vector3(180f, balance.YPlane, 0f); - } - private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) @@ -925,4 +1424,73 @@ public sealed class ScenarioLoader modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); + + private static int CountModules(IEnumerable modules, string moduleId) => + modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); + + private 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); + } + + private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new() + { + Kind = behavior.Kind, + AreaSystemId = behavior.AreaSystemId, + ModuleId = behavior.ModuleId, + NodeId = behavior.NodeId, + Phase = behavior.Phase, + PatrolIndex = behavior.PatrolIndex, + StationId = behavior.StationId, + }; + + private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new() + { + Kind = order.Kind, + Status = order.Status, + DestinationSystemId = order.DestinationSystemId, + DestinationPosition = order.DestinationPosition, + }; + + private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new() + { + Kind = task.Kind, + Status = task.Status, + TargetEntityId = task.TargetEntityId, + TargetNodeId = targetNodeId ?? task.TargetNodeId, + TargetPosition = task.TargetPosition, + TargetSystemId = task.TargetSystemId, + Threshold = task.Threshold, + }; + + private static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale); + + private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f); + + private static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback) + { + var length = MathF.Sqrt(vector.LengthSquared()); + if (length <= 0.0001f) + { + return fallback; + } + + return vector.Divide(length); + } + + private sealed record SystemSpatialGraph( + string SystemId, + List Nodes, + List Bubbles, + Dictionary> LagrangeNodesByPlanetIndex); + + private sealed record LagrangePointPlacement(string Designation, Vector3 Position); + + private sealed record StationPlacement(NodeRuntime AnchorNode, Vector3 Position); } diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index 09ae246..c34242e 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -11,11 +11,19 @@ public sealed class SimulationEngine private const float StationEnergyPerPowerCore = 480f; private const float ShipFuelPerReactor = 100f; private const float StationFuelPerTank = 500f; + private const float WaterConsumptionPerWorkerPerSecond = 0.004f; + private const float PopulationGrowthPerSecond = 0.012f; + private const float PopulationAttritionPerSecond = 0.018f; public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { var events = new List(); + var nowUtc = DateTimeOffset.UtcNow; + UpdateOrbitalState(world, nowUtc); + + UpdateClaims(world, events); + UpdateConstructionSites(world, events); UpdateStationPower(world, deltaSeconds, events); UpdateStations(world, deltaSeconds, events); @@ -27,17 +35,18 @@ public sealed class SimulationEngine var previousTask = ship.ControllerTask.Kind; UpdateShipPower(ship, world, deltaSeconds, events); - RefreshControlLayers(ship); + RefreshControlLayers(ship, world); PlanControllerTask(ship, world); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); - AdvanceControlState(ship, controllerEvent); + AdvanceControlState(ship, world, controllerEvent); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); TrackHistory(ship); EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } - world.GeneratedAtUtc = DateTimeOffset.UtcNow; + SyncSpatialState(world); + world.GeneratedAtUtc = nowUtc; return new WorldDelta( sequence, @@ -45,8 +54,14 @@ public sealed class SimulationEngine world.GeneratedAtUtc, false, events, + BuildSpatialNodeDeltas(world), + BuildLocalBubbleDeltas(world), BuildNodeDeltas(world), BuildStationDeltas(world), + BuildClaimDeltas(world), + BuildConstructionSiteDeltas(world), + BuildMarketOrderDeltas(world), + BuildPolicyDeltas(world), BuildShipDeltas(world), BuildFactionDeltas(world)); } @@ -84,6 +99,24 @@ public sealed class SimulationEngine planet.Size, planet.Color, planet.HasRing)).ToList())).ToList(), + world.SpatialNodes.Select(ToSpatialNodeDelta).Select((node) => new SpatialNodeSnapshot( + node.Id, + node.SystemId, + node.Kind, + node.LocalPosition, + node.BubbleId, + node.ParentNodeId, + node.OccupyingStructureId, + node.OrbitReferenceId)).ToList(), + world.LocalBubbles.Select(ToLocalBubbleDelta).Select((bubble) => new LocalBubbleSnapshot( + bubble.Id, + bubble.NodeId, + bubble.SystemId, + bubble.Radius, + bubble.OccupantShipIds, + bubble.OccupantStationIds, + bubble.OccupantClaimIds, + bubble.OccupantConstructionSiteIds)).ToList(), world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot( node.Id, node.SystemId, @@ -98,12 +131,72 @@ public sealed class SimulationEngine station.Category, station.SystemId, station.LocalPosition, + station.NodeId, + station.BubbleId, + station.AnchorNodeId, station.Color, station.DockedShips, station.DockingPads, station.EnergyStored, station.Inventory, - station.FactionId)).ToList(), + station.FactionId, + station.CommanderId, + station.PolicySetId, + station.Population, + station.PopulationCapacity, + station.WorkforceRequired, + station.WorkforceEffectiveRatio, + station.InstalledModules, + station.MarketOrderIds)).ToList(), + world.Claims.Select(ToClaimDelta).Select((claim) => new ClaimSnapshot( + claim.Id, + claim.FactionId, + claim.SystemId, + claim.NodeId, + claim.BubbleId, + claim.State, + claim.Health, + claim.PlacedAtUtc, + claim.ActivatesAtUtc)).ToList(), + world.ConstructionSites.Select(ToConstructionSiteDelta).Select((site) => new ConstructionSiteSnapshot( + site.Id, + site.FactionId, + site.SystemId, + site.NodeId, + site.BubbleId, + 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)).ToList(), world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot( ship.Id, ship.Label, @@ -117,21 +210,30 @@ public sealed class SimulationEngine ship.OrderKind, ship.DefaultBehaviorKind, ship.ControllerTaskKind, + ship.NodeId, + ship.BubbleId, + ship.DockedStationId, + ship.CommanderId, + ship.PolicySetId, ship.CargoCapacity, + ship.WorkerPopulation, ship.EnergyStored, ship.Inventory, ship.FactionId, ship.Health, - ship.History)).ToList(), + ship.History, + ship.SpatialState)).ToList(), world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot( faction.Id, faction.Label, faction.Color, faction.Credits, + faction.PopulationTotal, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, - faction.ShipsLost)).ToList()); + faction.ShipsLost, + faction.DefaultPolicySetId)).ToList()); } public void PrimeDeltaBaseline(SimulationWorld world) @@ -141,11 +243,41 @@ public sealed class SimulationEngine node.LastDeltaSignature = BuildNodeSignature(node); } + foreach (var node in world.SpatialNodes) + { + node.LastDeltaSignature = BuildSpatialNodeSignature(node); + } + + foreach (var bubble in world.LocalBubbles) + { + bubble.LastDeltaSignature = BuildLocalBubbleSignature(bubble); + } + foreach (var station in world.Stations) { station.LastDeltaSignature = BuildStationSignature(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(ship); @@ -175,6 +307,42 @@ public sealed class SimulationEngine return deltas; } + private static IReadOnlyList BuildSpatialNodeDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var node in world.SpatialNodes) + { + var signature = BuildSpatialNodeSignature(node); + if (signature == node.LastDeltaSignature) + { + continue; + } + + node.LastDeltaSignature = signature; + deltas.Add(ToSpatialNodeDelta(node)); + } + + return deltas; + } + + private static IReadOnlyList BuildLocalBubbleDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var bubble in world.LocalBubbles) + { + var signature = BuildLocalBubbleSignature(bubble); + if (signature == bubble.LastDeltaSignature) + { + continue; + } + + bubble.LastDeltaSignature = signature; + deltas.Add(ToLocalBubbleDelta(bubble)); + } + + return deltas; + } + private static IReadOnlyList BuildStationDeltas(SimulationWorld world) { var deltas = new List(); @@ -193,6 +361,78 @@ public sealed class SimulationEngine 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(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 static IReadOnlyList BuildShipDeltas(SimulationWorld world) { var deltas = new List(); @@ -232,8 +472,280 @@ public sealed class SimulationEngine private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}"; + private static string BuildSpatialNodeSignature(NodeRuntime node) => + $"{node.SystemId}|{node.Kind}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}"; + + private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) => + $"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy((id) => id, StringComparer.Ordinal))}"; + + private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds) + { + var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f); + var meanAnomaly = DegreesToRadians(planet.OrbitPhaseAtEpoch) + (timeSeconds * planet.OrbitSpeed); + var eccentricAnomaly = meanAnomaly + + (eccentricity * MathF.Sin(meanAnomaly)) + + (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly)); + var semiMajorAxis = planet.OrbitRadius; + var semiMinorAxis = semiMajorAxis * MathF.Sqrt(MathF.Max(1f - (eccentricity * eccentricity), 0.05f)); + var local = new Vector3( + semiMajorAxis * (MathF.Cos(eccentricAnomaly) - eccentricity), + 0f, + semiMinorAxis * MathF.Sin(eccentricAnomaly)); + + local = RotateAroundY(local, DegreesToRadians(planet.OrbitArgumentOfPeriapsis)); + local = RotateAroundX(local, DegreesToRadians(planet.OrbitInclination)); + local = RotateAroundY(local, DegreesToRadians(planet.OrbitLongitudeOfAscendingNode)); + return local; + } + + private static Vector3 ComputeMoonOffset(PlanetDefinition planet, int moonIndex, float timeSeconds) + { + var orbitRadius = ComputeMoonOrbitRadius(planet, moonIndex); + var speed = ComputeMoonOrbitSpeed(planet, moonIndex); + var phase = HashUnit($"{planet.Label}:{moonIndex}:phase") * MathF.PI * 2f; + var inclination = DegreesToRadians((HashUnit($"{planet.Label}:{moonIndex}:inclination") - 0.5f) * 28f); + var ascendingNode = DegreesToRadians(HashUnit($"{planet.Label}:{moonIndex}:node") * 360f); + var angle = phase + (timeSeconds * speed); + + var local = new Vector3( + MathF.Cos(angle) * orbitRadius, + 0f, + MathF.Sin(angle) * orbitRadius); + local = RotateAroundX(local, inclination); + local = RotateAroundY(local, ascendingNode); + return local; + } + + private static float ComputeMoonOrbitRadius(PlanetDefinition planet, int moonIndex) + { + var spacing = planet.Size * 1.4f; + var variance = HashUnit($"{planet.Label}:{moonIndex}:radius") * planet.Size * 0.9f; + return (planet.Size * 1.8f) + (moonIndex * spacing) + variance; + } + + private static float ComputeMoonOrbitSpeed(PlanetDefinition planet, int moonIndex) + { + var radius = ComputeMoonOrbitRadius(planet, moonIndex); + return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f); + } + + private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) + { + var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); + var tangential = new Vector3(-radial.Z, 0f, radial.X); + var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f)); + 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, -orbitRadius)); + yield return new LagrangePointPlacement( + "L4", + Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); + yield return new LagrangePointPlacement( + "L5", + Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); + } + + private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback) + { + var length = MathF.Sqrt(value.LengthSquared()); + if (length <= 0.0001f) + { + return fallback; + } + + return value.Divide(length); + } + + private static Vector3 RotateAroundX(Vector3 value, float angle) + { + var cos = MathF.Cos(angle); + var sin = MathF.Sin(angle); + return new Vector3( + value.X, + (value.Y * cos) - (value.Z * sin), + (value.Y * sin) + (value.Z * cos)); + } + + private static Vector3 RotateAroundY(Vector3 value, float angle) + { + var cos = MathF.Cos(angle); + var sin = MathF.Sin(angle); + return new Vector3( + (value.X * cos) + (value.Z * sin), + value.Y, + (-value.X * sin) + (value.Z * cos)); + } + + private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); + + private static Vector3 Scale(Vector3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); + + private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f); + + private static float HashUnit(string input) + { + unchecked + { + var hash = 2166136261u; + foreach (var character in input) + { + hash ^= character; + hash *= 16777619u; + } + + return (hash & 0x00FFFFFF) / (float)0x01000000; + } + } + + private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc) + { + var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f); + var spatialNodesById = world.SpatialNodes.ToDictionary((node) => node.Id, StringComparer.Ordinal); + + foreach (var system in world.Systems) + { + var starNodeId = $"node-{system.Definition.Id}-star"; + if (spatialNodesById.TryGetValue(starNodeId, out var starNode)) + { + starNode.Position = Vector3.Zero; + } + + 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}"; + if (!spatialNodesById.TryGetValue(planetNodeId, out var planetNode)) + { + continue; + } + + var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds); + planetNode.Position = planetPosition; + + foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) + { + var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}"; + if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode)) + { + lagrangeNode.Position = lagrange.Position; + } + } + + var moonCount = planet.MoonCount; + for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1) + { + var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; + if (!spatialNodesById.TryGetValue(moonId, out var moonNode)) + { + continue; + } + + moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet, moonIndex, worldTimeSeconds)); + } + } + } + + foreach (var station in world.Stations) + { + if (station.AnchorNodeId is null || !spatialNodesById.TryGetValue(station.AnchorNodeId, out var anchorNode)) + { + continue; + } + + station.Position = anchorNode.Position; + if (station.NodeId is not null && spatialNodesById.TryGetValue(station.NodeId, out var stationNode)) + { + stationNode.Position = station.Position; + } + } + + foreach (var ship in world.Ships) + { + if (ship.DockedStationId is null) + { + continue; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station is null) + { + continue; + } + + var dockedPosition = GetShipDockedPosition(ship, station); + ship.Position = dockedPosition; + ship.TargetPosition = dockedPosition; + } + } + + private static void SyncSpatialState(SimulationWorld world) + { + foreach (var bubble in world.LocalBubbles) + { + bubble.OccupantShipIds.Clear(); + } + + foreach (var ship in world.Ships) + { + ship.SpatialState.CurrentSystemId = ship.SystemId; + ship.SpatialState.LocalPosition = ship.Position; + ship.SpatialState.SystemPosition = ship.Position; + if (ship.SpatialState.Transit is not null) + { + ship.SpatialState.CurrentNodeId = null; + ship.SpatialState.CurrentBubbleId = null; + continue; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + var nearestNode = world.SpatialNodes + .Where((candidate) => candidate.SystemId == ship.SystemId) + .OrderBy((candidate) => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + ship.SpatialState.CurrentNodeId = nearestNode?.Id; + ship.SpatialState.CurrentBubbleId = nearestNode?.BubbleId; + + if (nearestNode is not null) + { + var nearestBubble = world.LocalBubbles.FirstOrDefault((candidate) => candidate.Id == nearestNode.BubbleId); + nearestBubble?.OccupantShipIds.Add(ship.Id); + } + + if (ship.DockedStationId is null) + { + continue; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station?.BubbleId is null) + { + continue; + } + + ship.SpatialState.CurrentNodeId = station.NodeId; + ship.SpatialState.CurrentBubbleId = station.BubbleId; + var bubble = world.LocalBubbles.FirstOrDefault((candidate) => candidate.Id == station.BubbleId); + bubble?.OccupantShipIds.Add(ship.Id); + } + } + private static string BuildStationSignature(StationRuntime station) => - $"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{string.Join(",", station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}"; + $"{station.SystemId}|{station.NodeId}|{station.BubbleId}|{station.AnchorNodeId}|{station.CommanderId}|{station.PolicySetId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{station.Population:0.###}|{station.PopulationCapacity:0.###}|{station.WorkforceRequired:0.###}|{station.WorkforceEffectiveRatio: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"}"; + + private static string BuildClaimSignature(ClaimRuntime claim) => + $"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; + + private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) => + $"{site.FactionId}|{site.SystemId}|{site.NodeId}|{site.BubbleId}|{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}"; private static string BuildShipSignature(ShipRuntime ship) => string.Join("|", @@ -251,6 +763,21 @@ public sealed class SimulationEngine ship.Order?.Kind ?? "none", ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, + ship.SpatialState.CurrentNodeId ?? "none", + ship.SpatialState.CurrentBubbleId ?? "none", + ship.DockedStationId ?? "none", + ship.CommanderId ?? "none", + ship.PolicySetId ?? "none", + ship.WorkerPopulation.ToString("0.###"), + ship.SpatialState.SpaceLayer, + ship.SpatialState.CurrentNodeId ?? "none", + ship.SpatialState.CurrentBubbleId ?? "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.###"), GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"), ship.EnergyStored.ToString("0.###"), @@ -264,7 +791,7 @@ public sealed class SimulationEngine .Select((entry) => $"{entry.Key}:{entry.Value:0.###}")); private static string BuildFactionSignature(FactionRuntime faction) => - $"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}"; + $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}"; private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( node.Id, @@ -275,18 +802,102 @@ public sealed class SimulationEngine node.MaxOre, node.ItemId); + private static SpatialNodeDelta ToSpatialNodeDelta(NodeRuntime node) => new( + node.Id, + node.SystemId, + node.Kind, + ToDto(node.Position), + node.BubbleId, + node.ParentNodeId, + node.OccupyingStructureId, + node.OrbitReferenceId); + + private static LocalBubbleDelta ToLocalBubbleDelta(LocalBubbleRuntime bubble) => new( + bubble.Id, + bubble.NodeId, + bubble.SystemId, + bubble.Radius, + bubble.OccupantShipIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(), + bubble.OccupantStationIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(), + bubble.OccupantClaimIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(), + bubble.OccupantConstructionSiteIds.OrderBy((id) => id, StringComparer.Ordinal).ToList()); + private static StationDelta ToStationDelta(StationRuntime station) => new( station.Id, station.Definition.Label, station.Definition.Category, station.SystemId, ToDto(station.Position), + station.NodeId, + station.BubbleId, + station.AnchorNodeId, station.Definition.Color, station.DockedShipIds.Count, GetDockingPadCount(station), station.EnergyStored, ToInventoryEntries(station.Inventory), - station.FactionId); + station.FactionId, + station.CommanderId, + station.PolicySetId, + station.Population, + station.PopulationCapacity, + station.WorkforceRequired, + station.WorkforceEffectiveRatio, + station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal).ToList(), + station.MarketOrderIds.OrderBy((orderId) => orderId, StringComparer.Ordinal).ToList()); + + private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new( + claim.Id, + claim.FactionId, + claim.SystemId, + claim.NodeId, + claim.BubbleId, + claim.State, + claim.Health, + claim.PlacedAtUtc, + claim.ActivatesAtUtc); + + private static ConstructionSiteDelta ToConstructionSiteDelta(ConstructionSiteRuntime site) => new( + site.Id, + site.FactionId, + site.SystemId, + site.NodeId, + site.BubbleId, + site.TargetKind, + site.TargetDefinitionId, + site.BlueprintId, + site.ClaimId, + site.StationId, + site.State, + site.Progress, + 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 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); private static ShipDelta ToShipDelta(ShipRuntime ship) => new( ship.Id, @@ -301,12 +912,19 @@ public sealed class SimulationEngine ship.Order?.Kind, ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, + ship.SpatialState.CurrentNodeId, + ship.SpatialState.CurrentBubbleId, + ship.DockedStationId, + ship.CommanderId, + ship.PolicySetId, ship.Definition.CargoCapacity, + ship.WorkerPopulation, ship.EnergyStored, ToInventoryEntries(ship.Inventory), ship.FactionId, ship.Health, - ship.History.ToList()); + ship.History.ToList(), + ToShipSpatialStateSnapshot(ship.SpatialState)); private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => inventory @@ -320,10 +938,29 @@ public sealed class SimulationEngine faction.Label, faction.Color, faction.Credits, + faction.PopulationTotal, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, - faction.ShipsLost); + faction.ShipsLost, + faction.DefaultPolicySetId); + + private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( + state.SpaceLayer, + state.CurrentSystemId, + state.CurrentNodeId, + state.CurrentBubbleId, + 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 void EmitShipStateEvents( ShipRuntime ship, @@ -358,64 +995,256 @@ public sealed class SimulationEngine private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) { + var factionPopulation = new Dictionary(StringComparer.Ordinal); foreach (var station in world.Stations) { - if (CanProcessFuel(station) && GetInventoryAmount(station.Inventory, "gas") >= 20f) + UpdateStationPopulation(station, deltaSeconds, events); + ReviewStationMarketOrders(world, station); + RunStationProduction(world, station, deltaSeconds, events); + factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population; + } + + foreach (var faction in world.Factions) + { + faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id); + } + } + + private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection events) + { + station.WorkforceRequired = MathF.Max(12f, station.InstalledModules.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 hasPower = station.EnergyStored > 0.01f; + var habitatModules = CountModules(station.InstalledModules, "habitat-ring"); + station.PopulationCapacity = 40f + (habitatModules * 220f); + + if (waterSatisfied && hasPower) + { + if (habitatModules > 0 && station.Population < station.PopulationCapacity) { - if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - station.ProcessTimer = 0f; - continue; - } - - station.ProcessTimer += deltaSeconds; - if (station.ProcessTimer >= 6f) - { - station.ProcessTimer = 0f; - RemoveInventory(station.Inventory, "gas", 20f); - var addedFuel = TryAddStationInventory(world, station, "fuel", 18f); - if (addedFuel > 0.01f) - { - events.Add(new SimulationEventRecord("station", station.Id, "fuel-processed", $"{station.Definition.Label} processed 20 gas into {addedFuel:0.#} fuel", DateTimeOffset.UtcNow)); - } - } - - continue; + station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); } - - if (!HasRefineryCapability(station) || GetInventoryAmount(station.Inventory, "ore") < 60f) + } + 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)) { - station.ProcessTimer = 0f; - continue; + events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Definition.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); } + } - if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - station.ProcessTimer = 0f; - continue; - } + station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); + } - station.ProcessTimer += deltaSeconds; - if (station.ProcessTimer < 8f) - { - continue; - } + private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) + { + if (station.CommanderId is null) + { + return; + } + var desiredOrders = new List(); + var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f); + var waterReserve = MathF.Max(30f, station.Population * 3f); + var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f; + var oreReserve = HasRefineryCapability(station) ? 180f : 0f; + var gasReserve = CanProcessFuel(station) ? 120f : 0f; + + AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f); + AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f); + AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f); + AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f); + AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f); + + AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f); + AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f); + AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f); + AddSupplyOrder(desiredOrders, station, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f); + AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f); + + ReconcileStationMarketOrders(world, station, desiredOrders); + } + + private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) + { + var recipe = SelectProductionRecipe(world, station); + if (recipe is null || station.EnergyStored <= 0.01f) + { station.ProcessTimer = 0f; - RemoveInventory(station.Inventory, "ore", 60f); - var refined = TryAddStationInventory(world, station, "refined-metals", 60f); - if (refined <= 0.01f) + return; + } + + if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + station.ProcessTimer = 0f; + return; + } + + station.ProcessTimer += deltaSeconds * station.WorkforceEffectiveRatio; + if (station.ProcessTimer < recipe.Duration) + { + return; + } + + station.ProcessTimer = 0f; + foreach (var input in recipe.Inputs) + { + RemoveInventory(station.Inventory, input.ItemId, input.Amount); + } + + var produced = 0f; + foreach (var output in recipe.Outputs) + { + produced += TryAddStationInventory(world, station, output.ItemId, output.Amount); + } + + if (produced <= 0.01f) + { + return; + } + + events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow)); + var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId); + if (faction is not null) + { + faction.GoodsProduced += produced; + } + } + + private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) + { + return world.Recipes.Values + .Where((recipe) => RecipeAppliesToStation(station, recipe)) + .OrderByDescending((recipe) => recipe.Priority) + .FirstOrDefault((recipe) => CanRunRecipe(world, station, recipe)); + } + + private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) + { + var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal) + || (string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) + && station.Definition.Category is "station" or "shipyard" or "defense" or "gate"); + return categoryMatch && recipe.RequiredModules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); + } + + private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + 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.Storage); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + { + return false; + } + + if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity)) + { + return false; + } + + var used = station.Inventory + .Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == itemDefinition.Storage) + .Sum((entry) => entry.Value); + return used + amount <= capacity + 0.001f; + } + + 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; + } + + desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, 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) + { + if (desiredOrders.Any((desired) => desired.Kind == order.Kind && desired.ItemId == order.ItemId)) { continue; } - events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow)); - var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId); - if (faction is not null) - { - faction.GoodsProduced += refined; - faction.Credits += refined * 0.3f; - } + order.RemainingAmount = 0f; + order.State = MarketOrderStateKinds.Cancelled; } } @@ -428,6 +1257,12 @@ public sealed class SimulationEngine private static bool HasShipModules(ShipDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); + private static bool CanTransportWorkers(ShipRuntime ship) => + CountModules(ship.Definition.Modules, "habitat-ring") > 0; + + private static float GetWorkerTransportCapacity(ShipRuntime ship) => + CountModules(ship.Definition.Modules, "habitat-ring") * 120f; + private static void UpdateStationPower( SimulationWorld world, float deltaSeconds, @@ -593,6 +1428,17 @@ public sealed class SimulationEngine private static bool NeedsRefuel(ShipRuntime ship) => GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f); + private 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); + } + private static string? GetStorageRequirement(string storageClass) => storageClass switch { @@ -637,6 +1483,85 @@ public sealed class SimulationEngine private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) => recipe.Inputs.All((input) => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount); + private 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); + + private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) => + site.RequiredItems.All((entry) => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value); + + private static void UpdateClaims(SimulationWorld world, ICollection events) + { + foreach (var claim in world.Claims) + { + 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)); + } + } + } + + private static 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 - GetInventoryAmount(site.DeliveredItems, order.ItemId)); + order.RemainingAmount = remaining; + order.State = remaining <= 0.01f + ? MarketOrderStateKinds.Filled + : remaining < order.Amount + ? MarketOrderStateKinds.PartiallyFilled + : MarketOrderStateKinds.Open; + } + } + } + private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) { if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal)) @@ -684,6 +1609,61 @@ public sealed class SimulationEngine return null; } + private 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; + } + } + + 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 static int GetDockingPadCount(StationRuntime station) => CountModules(station.InstalledModules, "dock-bay-small") * 2; @@ -787,26 +1767,146 @@ public sealed class SimulationEngine return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId); } - private void RefreshControlLayers(ShipRuntime ship) + private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) => + ship.CommanderId is null + ? null + : world.Commanders.FirstOrDefault((candidate) => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship); + + private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander) { + if (commander.ActiveBehavior is not null) + { + ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind; + ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId; + ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId; + ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId; + ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase; + ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex; + ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId; + } + + if (commander.ActiveOrder is null) + { + ship.Order = null; + } + else + { + ship.Order = new ShipOrderRuntime + { + Kind = commander.ActiveOrder.Kind, + Status = commander.ActiveOrder.Status, + DestinationSystemId = commander.ActiveOrder.DestinationSystemId, + DestinationPosition = commander.ActiveOrder.DestinationPosition, + }; + } + + if (commander.ActiveTask is not null) + { + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = commander.ActiveTask.Kind, + Status = commander.ActiveTask.Status, + CommanderId = commander.Id, + TargetEntityId = commander.ActiveTask.TargetEntityId, + TargetNodeId = commander.ActiveTask.TargetNodeId, + TargetPosition = commander.ActiveTask.TargetPosition, + TargetSystemId = commander.ActiveTask.TargetSystemId, + Threshold = commander.ActiveTask.Threshold, + }; + } + } + + private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander) + { + commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind }; + commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind; + commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId; + commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId; + commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId; + commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase; + commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex; + commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId; + + if (ship.Order is null) + { + commander.ActiveOrder = null; + } + else + { + commander.ActiveOrder ??= new CommanderOrderRuntime + { + Kind = ship.Order.Kind, + DestinationSystemId = ship.Order.DestinationSystemId, + DestinationPosition = ship.Order.DestinationPosition, + }; + commander.ActiveOrder.Status = ship.Order.Status; + commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId; + commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId; + } + + commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind }; + commander.ActiveTask.Kind = ship.ControllerTask.Kind; + commander.ActiveTask.Status = ship.ControllerTask.Status; + commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId; + commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId; + commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition; + commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId; + commander.ActiveTask.Threshold = ship.ControllerTask.Threshold; + } + + private void RefreshControlLayers(ShipRuntime ship, SimulationWorld world) + { + var commander = GetShipCommander(world, ship); + if (commander is not null) + { + SyncCommanderToShip(ship, commander); + } + if (ship.Order is not null && ship.Order.Status == "queued") { ship.Order.Status = "accepted"; + if (commander?.ActiveOrder is not null) + { + commander.ActiveOrder.Status = ship.Order.Status; + } + } + + if (commander is not null) + { + SyncShipToCommander(ship, commander); } } private void PlanControllerTask(ShipRuntime ship, SimulationWorld world) { + var commander = GetShipCommander(world, ship); if (ship.Order is not null) { - ship.ControllerTask = new ControllerTaskRuntime + var plannedTask = new ControllerTaskRuntime { Kind = "travel", + Status = "active", + CommanderId = commander?.Id, TargetEntityId = null, TargetSystemId = ship.Order.DestinationSystemId, + TargetNodeId = ship.SpatialState.DestinationNodeId, TargetPosition = ship.Order.DestinationPosition, Threshold = world.Balance.ArrivalThreshold, }; + ship.ControllerTask = plannedTask; + if (commander is not null) + { + commander.ActiveTask = new CommanderTaskRuntime + { + Kind = plannedTask.Kind, + Status = plannedTask.Status, + TargetEntityId = plannedTask.TargetEntityId, + TargetNodeId = plannedTask.TargetNodeId, + TargetPosition = plannedTask.TargetPosition, + TargetSystemId = plannedTask.TargetSystemId, + Threshold = plannedTask.Threshold, + }; + } return; } @@ -850,7 +1950,8 @@ public sealed class SimulationEngine private void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) { var behavior = ship.DefaultBehavior; - var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.StationId); + var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId); + behavior.StationId = refinery?.Id; var node = behavior.NodeId is null ? world.Nodes .Where((candidate) => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId) @@ -962,10 +2063,36 @@ public sealed class SimulationEngine } } + private static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId) + { + var preferred = preferredStationId is null + ? null + : world.Stations.FirstOrDefault((station) => station.Id == preferredStationId); + + var bestOrder = world.MarketOrders + .Where((order) => + order.Kind == MarketOrderKinds.Buy && + order.ConstructionSiteId is null && + order.State != MarketOrderStateKinds.Cancelled && + order.ItemId == itemId && + order.RemainingAmount > 0.01f) + .Select((order) => (Order: order, Station: world.Stations.FirstOrDefault((station) => station.Id == order.StationId))) + .Where((entry) => entry.Station is not null) + .OrderByDescending((entry) => + { + var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; + return entry.Order.Valuation - distancePenalty; + }) + .FirstOrDefault(); + + return bestOrder.Station ?? preferred; + } + private void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == behavior.StationId); + var site = station is null ? null : GetConstructionSiteForStation(world, station.Id); if (station is null) { behavior.Kind = "idle"; @@ -973,7 +2100,7 @@ public sealed class SimulationEngine return; } - var moduleId = GetNextStationModuleToBuild(station, world); + var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); behavior.ModuleId = moduleId; if (moduleId is null) { @@ -987,6 +2114,18 @@ public sealed class SimulationEngine { behavior.Phase = "refuel"; } + else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site)) + { + behavior.Phase = "deliver-to-site"; + } + else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site)) + { + behavior.Phase = "build-site"; + } + else if (site is not null) + { + behavior.Phase = "wait-for-materials"; + } else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId])) { behavior.Phase = "construct-module"; @@ -1033,6 +2172,26 @@ public sealed class SimulationEngine Threshold = 0f, }; break; + case "deliver-to-site": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "deliver-construction", + TargetEntityId = site?.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + case "build-site": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "build-construction-site", + TargetEntityId = site?.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; case "wait-for-materials": ship.ControllerTask = new ControllerTaskRuntime { @@ -1077,6 +2236,14 @@ public sealed class SimulationEngine return UpdateUnload(ship, world, deltaSeconds); case "refuel": return UpdateRefuel(ship, world, deltaSeconds); + case "deliver-construction": + return UpdateDeliverConstruction(ship, world, deltaSeconds); + case "build-construction-site": + return UpdateBuildConstructionSite(ship, world, deltaSeconds); + case "load-workers": + return UpdateLoadWorkers(ship, world, deltaSeconds); + case "unload-workers": + return UpdateUnloadWorkers(ship, world, deltaSeconds); case "construct-module": return UpdateConstructModule(ship, world, deltaSeconds); case "undock": @@ -1098,95 +2265,250 @@ public sealed class SimulationEngine return "none"; } - ship.TargetPosition = task.TargetPosition.Value; - var distance = ship.Position.DistanceTo(task.TargetPosition.Value); - if (distance <= task.Threshold) + var targetPosition = task.TargetPosition.Value; + var targetNode = ResolveTravelTargetNode(world, task, targetPosition); + ship.TargetPosition = targetPosition; + + if (ship.SystemId != task.TargetSystemId) + { + return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode); + } + + var currentNode = ResolveCurrentNode(world, ship); + if (targetNode is not null && currentNode is not null && !string.Equals(currentNode.Id, targetNode.Id, StringComparison.Ordinal)) + { + return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetNode); + } + + return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode, task.Threshold); + } + + private static NodeRuntime? ResolveTravelTargetNode(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition) + { + if (!string.IsNullOrWhiteSpace(task.TargetEntityId)) + { + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); + if (station?.NodeId is not null) + { + return world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == station.NodeId); + } + + var node = world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); + if (node is not null) + { + return node; + } + } + + return world.SpatialNodes + .Where((candidate) => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId) + .OrderBy((candidate) => candidate.Position.DistanceTo(targetPosition)) + .FirstOrDefault(); + } + + private static NodeRuntime? ResolveCurrentNode(SimulationWorld world, ShipRuntime ship) + { + if (ship.SpatialState.CurrentNodeId is not null) + { + return world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == ship.SpatialState.CurrentNodeId); + } + + return world.SpatialNodes + .Where((candidate) => candidate.SystemId == ship.SystemId) + .OrderBy((candidate) => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private string UpdateLocalTravel( + ShipRuntime ship, + SimulationWorld world, + float deltaSeconds, + string targetSystemId, + Vector3 targetPosition, + NodeRuntime? targetNode, + float threshold) + { + var distance = ship.Position.DistanceTo(targetPosition); + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.Transit = null; + ship.SpatialState.DestinationNodeId = targetNode?.Id; + + if (distance <= threshold) { ship.ActionTimer = 0f; TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); - ship.Position = task.TargetPosition.Value; + ship.Position = targetPosition; ship.TargetPosition = ship.Position; - ship.SystemId = task.TargetSystemId; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentNodeId = targetNode?.Id; + ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; ship.State = "arriving"; return "arrived"; } - var speed = ship.Definition.Speed; - var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds; - if (ship.SystemId != task.TargetSystemId) - { - var spoolDuration = ship.Definition.SpoolTime; - if (ship.State != "ftl") - { - if (ship.State != "spooling-ftl") - { - ship.ActionTimer = 0f; - } - - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "spooling-ftl"; - if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) - { - return "none"; - } - - ship.State = "ftl"; - } - speed = ship.Definition.FtlSpeed; - energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; - } - else if (distance > 200f) - { - var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != "warping") - { - if (ship.State != "spooling-warp") - { - ship.ActionTimer = 0f; - } - - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "spooling-warp"; - if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) - { - return "none"; - } - - ship.State = "warping"; - } - speed = ship.Definition.Speed; - energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; - } - else - { - ship.ActionTimer = 0f; - ship.State = "approaching"; - speed = ship.Definition.Speed; - } - - if (!TryConsumeShipEnergy(ship, energyCost)) + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } - ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds); + ship.ActionTimer = 0f; + ship.State = "local-flight"; + ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds); return "none"; } + private string UpdateWarpTransit( + ShipRuntime ship, + SimulationWorld world, + float deltaSeconds, + Vector3 targetPosition, + NodeRuntime targetNode) + { + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetNode.Id) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKinds.Warp, + OriginNodeId = ship.SpatialState.CurrentNodeId, + DestinationNodeId = targetNode.Id, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; + ship.SpatialState.CurrentNodeId = null; + ship.SpatialState.CurrentBubbleId = null; + ship.SpatialState.DestinationNodeId = targetNode.Id; + + var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + if (ship.State != "warping") + { + if (ship.State != "spooling-warp") + { + ship.ActionTimer = 0f; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "spooling-warp"; + if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) + { + return "none"; + } + + ship.State = "warping"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null + ? ship.Position.DistanceTo(targetPosition) + : (world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); + ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds); + transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + return ship.Position.DistanceTo(targetPosition) <= 18f + ? CompleteTransitArrival(ship, targetNode.SystemId, targetPosition, targetNode) + : "none"; + } + + private string UpdateFtlTransit( + ShipRuntime ship, + SimulationWorld world, + float deltaSeconds, + string targetSystemId, + Vector3 targetPosition, + NodeRuntime? targetNode) + { + var destinationNodeId = targetNode?.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.CurrentNodeId, + DestinationNodeId = destinationNodeId, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; + ship.SpatialState.CurrentNodeId = null; + ship.SpatialState.CurrentBubbleId = null; + ship.SpatialState.DestinationNodeId = destinationNodeId; + + if (ship.State != "ftl") + { + if (ship.State != "spooling-ftl") + { + ship.ActionTimer = 0f; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "spooling-ftl"; + if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime)) + { + return "none"; + } + + ship.State = "ftl"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var totalDistance = MathF.Max(0.001f, ship.Position.DistanceTo(targetPosition)); + ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds); + transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + return ship.Position.DistanceTo(targetPosition) <= 24f + ? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode) + : "none"; + } + + private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode) + { + ship.ActionTimer = 0f; + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.CurrentNodeId = targetNode?.Id; + ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; + ship.SpatialState.DestinationNodeId = targetNode?.Id; + ship.State = "arriving"; + return "arrived"; + } + private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; @@ -1464,6 +2786,167 @@ public sealed class SimulationEngine return "module-constructed"; } + private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + var site = world.ConstructionSites.FirstOrDefault((candidate) => candidate.Id == ship.ControllerTask.TargetEntityId); + if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) + || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = "delivering-construction"; + + foreach (var required in site.RequiredItems) + { + var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); + var remaining = MathF.Max(0f, required.Value - delivered); + if (remaining <= 0.01f) + { + continue; + } + + var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); + moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key)); + if (moved <= 0.01f) + { + continue; + } + + RemoveInventory(station.Inventory, required.Key, moved); + AddInventory(site.Inventory, required.Key, moved); + AddInventory(site.DeliveredItems, required.Key, moved); + return IsConstructionSiteReady(site) ? "construction-delivered" : "none"; + } + + return IsConstructionSiteReady(site) ? "construction-delivered" : "none"; + } + + private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + var site = world.ConstructionSites.FirstOrDefault((candidate) => candidate.Id == ship.ControllerTask.TargetEntityId); + if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + ship.State = "waiting-materials"; + ship.TargetPosition = GetShipDockedPosition(ship, station); + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) + || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = "constructing"; + site.AssignedConstructorShipIds.Add(ship.Id); + site.Progress += deltaSeconds; + if (site.Progress < recipe.Duration) + { + return "none"; + } + + station.InstalledModules.Add(site.BlueprintId); + PrepareNextConstructionSiteStep(world, station, site); + return "site-constructed"; + } + + private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null || !CanTransportWorkers(ship)) + { + ship.State = "blocked"; + return "failed"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station is null || station.Population <= 0.01f) + { + ship.State = "idle"; + return "none"; + } + + var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation); + transfer = MathF.Min(transfer, 4f * deltaSeconds); + if (transfer <= 0.01f) + { + return "none"; + } + + station.Population = MathF.Max(0f, station.Population - transfer); + ship.WorkerPopulation += transfer; + ship.State = "loading"; + return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none"; + } + + private string UpdateUnloadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null || !CanTransportWorkers(ship)) + { + ship.State = "blocked"; + return "failed"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station is null || ship.WorkerPopulation <= 0.01f) + { + ship.State = "idle"; + return "none"; + } + + var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population)); + transfer = MathF.Min(transfer, 4f * deltaSeconds); + if (transfer <= 0.01f) + { + return "none"; + } + + ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer); + station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer); + ship.State = "unloading"; + return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none"; + } + private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; @@ -1520,12 +3003,24 @@ public sealed class SimulationEngine return "undocked"; } - private void AdvanceControlState(ShipRuntime ship, string controllerEvent) + private void AdvanceControlState(ShipRuntime ship, SimulationWorld world, string controllerEvent) { + var commander = GetShipCommander(world, ship); if (ship.Order is not null && controllerEvent == "arrived") { ship.Order = null; ship.ControllerTask.Kind = "idle"; + if (commander is not null) + { + commander.ActiveOrder = null; + commander.ActiveTask = new CommanderTaskRuntime + { + Kind = ShipTaskKinds.Idle, + Status = "completed", + TargetSystemId = ship.SystemId, + Threshold = 0f, + }; + } return; } @@ -1595,12 +3090,16 @@ public sealed class SimulationEngine ship.DefaultBehavior.Phase = "dock"; break; case ("dock", "docked"): - ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "construct-module"; + ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "deliver-to-site"; break; case ("refuel", "refueled"): - ship.DefaultBehavior.Phase = "construct-module"; + ship.DefaultBehavior.Phase = "deliver-to-site"; + break; + case ("deliver-to-site", "construction-delivered"): + ship.DefaultBehavior.Phase = "build-site"; break; case ("construct-module", "module-constructed"): + case ("build-site", "site-constructed"): ship.DefaultBehavior.Phase = "travel-to-station"; ship.DefaultBehavior.ModuleId = null; break; @@ -1611,6 +3110,15 @@ public sealed class SimulationEngine { ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count; } + + if (commander is not null) + { + SyncShipToCommander(ship, commander); + if (commander.ActiveTask is not null) + { + commander.ActiveTask.Status = controllerEvent == "none" ? "active" : "completed"; + } + } } private static void TrackHistory(ShipRuntime ship) @@ -1630,4 +3138,7 @@ public sealed class SimulationEngine } private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); + + private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); + private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position); } diff --git a/apps/backend/Simulation/WorldService.cs b/apps/backend/Simulation/WorldService.cs index 6e34e5a..75880de 100644 --- a/apps/backend/Simulation/WorldService.cs +++ b/apps/backend/Simulation/WorldService.cs @@ -10,7 +10,7 @@ public sealed class WorldService(IWebHostEnvironment environment) private readonly object _sync = new(); private readonly ScenarioLoader _loader = new(environment.ContentRootPath); private readonly SimulationEngine _engine = new(); - private readonly Dictionary> _subscribers = []; + private readonly Dictionary _subscribers = []; private readonly Queue _history = []; private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load(); private long _sequence; @@ -31,7 +31,7 @@ public sealed class WorldService(IWebHostEnvironment environment) } } - public ChannelReader Subscribe(long afterSequence, CancellationToken cancellationToken) + public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { @@ -43,11 +43,15 @@ public sealed class WorldService(IWebHostEnvironment environment) lock (_sync) { subscriberId = Guid.NewGuid(); - _subscribers.Add(subscriberId, channel); + _subscribers.Add(subscriberId, new SubscriptionState(scope, channel)); foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence)) { - channel.Writer.TryWrite(delta); + var filtered = FilterDeltaForScope(delta, scope); + if (HasMeaningfulDelta(filtered)) + { + channel.Writer.TryWrite(filtered); + } } } @@ -74,7 +78,11 @@ public sealed class WorldService(IWebHostEnvironment environment) foreach (var subscriber in _subscribers.Values.ToList()) { - subscriber.Writer.TryWrite(delta); + var filtered = FilterDeltaForScope(delta, subscriber.Scope); + if (HasMeaningfulDelta(filtered)) + { + subscriber.Channel.Writer.TryWrite(filtered); + } } } } @@ -92,7 +100,13 @@ public sealed class WorldService(IWebHostEnvironment environment) _world.TickIntervalMs, DateTimeOffset.UtcNow, true, - [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow)], + [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")], + [], + [], + [], + [], + [], + [], [], [], [], @@ -101,7 +115,7 @@ public sealed class WorldService(IWebHostEnvironment environment) _history.Enqueue(resetDelta); foreach (var subscriber in _subscribers.Values.ToList()) { - subscriber.Writer.TryWrite(resetDelta); + subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope)); } return _engine.BuildSnapshot(_world, _sequence); @@ -111,8 +125,14 @@ public sealed class WorldService(IWebHostEnvironment environment) private static bool HasMeaningfulDelta(WorldDelta delta) => delta.RequiresSnapshotRefresh || delta.Events.Count > 0 + || delta.SpatialNodes.Count > 0 + || delta.LocalBubbles.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; @@ -120,12 +140,134 @@ public sealed class WorldService(IWebHostEnvironment environment) { lock (_sync) { - if (!_subscribers.Remove(subscriberId, out var channel)) + if (!_subscribers.Remove(subscriberId, out var subscription)) { return; } - channel.Writer.TryComplete(); + subscription.Channel.Writer.TryComplete(); } } + + private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope) + { + if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase)) + { + return delta with + { + Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(), + Scope = scope, + }; + } + + var systemFilter = scope.SystemId; + if (string.Equals(scope.ScopeKind, "local-bubble", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.BubbleId is not null) + { + systemFilter = ResolveBubbleSystemId(scope.BubbleId); + } + + return delta with + { + Events = delta.Events + .Select((evt) => EnrichEventScope(evt)) + .Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter)) + .ToList(), + SpatialNodes = delta.SpatialNodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(), + LocalBubbles = delta.LocalBubbles.Where((bubble) => systemFilter is null || bubble.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 : [], + Scope = scope, + }; + } + + private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt) + { + if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null) + { + return evt; + } + + 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), + "spatial-node" => WithEntityScope(evt, "system", _world.SpatialNodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId), + "local-bubble" => WithEntityScope(evt, "local-bubble", _world.LocalBubbles.FirstOrDefault((bubble) => bubble.Id == evt.EntityId)?.Id), + "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, + }; + } + + 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? ResolveBubbleSystemId(string bubbleId) => + _world.LocalBubbles.FirstOrDefault((bubble) => bubble.Id == bubbleId)?.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; + } + + if (order?.ConstructionSiteId is not null) + { + return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId; + } + + return null; + } + + private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter) + { + 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; + } + + private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter) + { + return scope.ScopeKind switch + { + "universe" => true, + "system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, + "local-bubble" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, + _ => true, + }; + } + + private sealed record SubscriptionState(ObserverScope Scope, Channel Channel); } diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 5046e64..1d80e05 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -1,14 +1,26 @@ import * as THREE from "three"; import { fetchWorldSnapshot, openWorldStream } from "./api"; import type { + ClaimDelta, + ClaimSnapshot, + ConstructionSiteDelta, + ConstructionSiteSnapshot, FactionSnapshot, InventoryEntry, + LocalBubbleDelta, + LocalBubbleSnapshot, + MarketOrderDelta, + MarketOrderSnapshot, PlanetSnapshot, + PolicySetDelta, + PolicySetSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipDelta, ShipSnapshot, SimulationEventRecord, + SpatialNodeDelta, + SpatialNodeSnapshot, StationDelta, StationSnapshot, SystemSnapshot, @@ -25,6 +37,10 @@ type Selectable = | { kind: "ship"; id: string } | { kind: "station"; id: string } | { kind: "node"; id: string } + | { kind: "spatial-node"; id: string } + | { kind: "bubble"; id: string } + | { kind: "claim"; id: string } + | { kind: "construction-site"; id: string } | { kind: "system"; id: string } | { kind: "planet"; systemId: string; planetIndex: number }; @@ -72,7 +88,43 @@ interface NodeVisual { orbitInclination: number; } +interface SpatialNodeVisual { + id: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + kind: string; + localPosition: THREE.Vector3; +} + +interface BubbleVisual { + id: string; + systemId: string; + mesh: THREE.LineLoop; + localPosition: THREE.Vector3; + radius: number; +} + +interface ClaimVisual { + id: string; + nodeId: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + localPosition: THREE.Vector3; +} + +interface ConstructionSiteVisual { + id: string; + nodeId: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + localPosition: THREE.Vector3; +} + interface StructureVisual { + id: string; systemId: string; mesh: THREE.Mesh; icon: THREE.Sprite; @@ -101,8 +153,14 @@ interface WorldState { tickIntervalMs: number; generatedAtUtc: string; systems: Map; + spatialNodes: Map; + localBubbles: Map; nodes: Map; stations: Map; + claims: Map; + constructionSites: Map; + marketOrders: Map; + policies: Map; ships: Map; factions: Map; recentEvents: SimulationEventRecord[]; @@ -171,6 +229,8 @@ const GALAXY_PARALLAX_FACTOR = 0.025; const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000; const PROJECTED_GALAXY_RADIUS = 65000; const STAR_RENDER_SCALE = 0.18; +const PLANET_RENDER_SCALE = 0.95; +const MOON_RENDER_SCALE = 1.1; const MIN_CAMERA_DISTANCE = 450; const MAX_CAMERA_DISTANCE = 42000; @@ -193,14 +253,22 @@ export class GameViewer { private readonly cameraOffset = new THREE.Vector3(); private readonly keyState = new Set(); private readonly systemGroup = new THREE.Group(); + private readonly spatialNodeGroup = new THREE.Group(); + private readonly bubbleGroup = new THREE.Group(); private readonly nodeGroup = new THREE.Group(); private readonly stationGroup = new THREE.Group(); + private readonly claimGroup = new THREE.Group(); + private readonly constructionSiteGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group(); private readonly ambienceGroup = new THREE.Group(); private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; private readonly nodeVisuals = new Map(); + private readonly spatialNodeVisuals = new Map(); + private readonly bubbleVisuals = new Map(); private readonly stationVisuals = new Map(); + private readonly claimVisuals = new Map(); + private readonly constructionSiteVisuals = new Map(); private readonly shipVisuals = new Map(); private readonly systemVisuals = new Map(); private readonly systemSummaryVisuals = new Map(); @@ -223,6 +291,7 @@ export class GameViewer { private world?: WorldState; private worldTimeSyncMs = performance.now(); private stream?: EventSource; + private currentStreamScopeKey = ""; private readonly networkStats: NetworkStats = { snapshotBytes: 0, deltasReceived: 0, @@ -279,7 +348,17 @@ export class GameViewer { keyLight.position.set(1000, 1200, 800); this.scene.add(keyLight); this.initializeAmbience(); - this.scene.add(this.ambienceGroup, this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup); + this.scene.add( + this.ambienceGroup, + this.systemGroup, + this.bubbleGroup, + this.spatialNodeGroup, + this.nodeGroup, + this.stationGroup, + this.claimGroup, + this.constructionSiteGroup, + this.shipGroup, + ); const hud = document.createElement("div"); hud.className = "viewer-shell"; @@ -377,6 +456,8 @@ export class GameViewer { private openDeltaStream(afterSequence: number) { this.stream?.close(); + const scope = this.getPreferredStreamScope(); + this.currentStreamScopeKey = JSON.stringify(scope); this.stream = openWorldStream(afterSequence, { onOpen: () => { this.networkStats.streamConnected = true; @@ -392,7 +473,7 @@ export class GameViewer { onDelta: (delta, rawBytes) => { void this.handleDelta(delta, rawBytes); }, - }); + }, scope); } private async handleDelta(delta: WorldDelta, rawBytes: number) { @@ -410,6 +491,41 @@ export class GameViewer { this.updateGamePanel("Live"); this.updatePanels(); this.updateNetworkPanel(); + this.refreshStreamScopeIfNeeded(); + } + + private getPreferredStreamScope() { + if (this.zoomLevel === "universe" || !this.activeSystemId) { + return { scopeKind: "universe" as const }; + } + + const bubbleId = this.resolveFocusedBubbleId(); + if (this.zoomLevel === "local" && bubbleId) { + return { + scopeKind: "local-bubble" as const, + systemId: this.activeSystemId, + bubbleId, + }; + } + + return { + scopeKind: "system" as const, + systemId: this.activeSystemId, + }; + } + + private refreshStreamScopeIfNeeded() { + if (!this.world) { + return; + } + + const nextScope = this.getPreferredStreamScope(); + const nextScopeKey = JSON.stringify(nextScope); + if (nextScopeKey === this.currentStreamScopeKey) { + return; + } + + this.openDeltaStream(this.world.sequence); } private createWorldState(snapshot: WorldSnapshot): WorldState { @@ -420,8 +536,14 @@ export class GameViewer { tickIntervalMs: snapshot.tickIntervalMs, generatedAtUtc: snapshot.generatedAtUtc, systems: new Map(snapshot.systems.map((system) => [system.id, system])), + spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])), + localBubbles: new Map(snapshot.localBubbles.map((bubble) => [bubble.id, bubble])), nodes: new Map(snapshot.nodes.map((node) => [node.id, node])), stations: new Map(snapshot.stations.map((station) => [station.id, station])), + claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])), + constructionSites: new Map(snapshot.constructionSites.map((site) => [site.id, site])), + marketOrders: new Map(snapshot.marketOrders.map((order) => [order.id, order])), + policies: new Map(snapshot.policies.map((policy) => [policy.id, policy])), ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])), factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])), recentEvents: [], @@ -436,8 +558,12 @@ export class GameViewer { this.rebuildSystems(snapshot.systems); } + this.syncSpatialNodes(snapshot.spatialNodes); + this.syncLocalBubbles(snapshot.localBubbles); this.syncNodes(snapshot.nodes); this.syncStations(snapshot.stations); + this.syncClaims(snapshot.claims); + this.syncConstructionSites(snapshot.constructionSites); this.syncShips(snapshot.ships, snapshot.tickIntervalMs); this.rebuildFactions(snapshot.factions); this.updateSystemSummaries(); @@ -456,12 +582,30 @@ export class GameViewer { this.world.generatedAtUtc = delta.generatedAtUtc; this.world.recentEvents = [...delta.events, ...this.world.recentEvents].slice(0, 18); + for (const node of delta.spatialNodes) { + this.world.spatialNodes.set(node.id, node); + } + for (const bubble of delta.localBubbles) { + this.world.localBubbles.set(bubble.id, bubble); + } for (const node of delta.nodes) { this.world.nodes.set(node.id, node); } for (const station of delta.stations) { this.world.stations.set(station.id, station); } + for (const claim of delta.claims) { + this.world.claims.set(claim.id, claim); + } + for (const site of delta.constructionSites) { + this.world.constructionSites.set(site.id, site); + } + for (const order of delta.marketOrders) { + this.world.marketOrders.set(order.id, order); + } + for (const policy of delta.policies) { + this.world.policies.set(policy.id, policy); + } for (const ship of delta.ships) { this.world.ships.set(ship.id, ship); } @@ -469,8 +613,12 @@ export class GameViewer { this.world.factions.set(faction.id, faction); } + this.applySpatialNodeDeltas(delta.spatialNodes); + this.applyLocalBubbleDeltas(delta.localBubbles); this.applyNodeDeltas(delta.nodes); this.applyStationDeltas(delta.stations); + this.applyClaimDeltas(delta.claims); + this.applyConstructionSiteDeltas(delta.constructionSites); this.applyShipDeltas(delta.ships, delta.tickIntervalMs); if (delta.factions.length > 0) { this.rebuildFactions([...this.world.factions.values()]); @@ -491,7 +639,7 @@ export class GameViewer { const root = new THREE.Group(); root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z); const detailGroup = new THREE.Group(); - const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8); + const renderedStarSize = this.celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const starCluster = this.createStarCluster(system); const systemIcon = this.createTacticalIcon(system.starColor, 96); @@ -521,8 +669,9 @@ export class GameViewer { for (const [planetIndex, planet] of system.planets.entries()) { const orbit = this.createPlanetOrbit(planet); + const renderedPlanetRadius = this.celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); const planetMesh = new THREE.Mesh( - new THREE.SphereGeometry(planet.size, 18, 18), + new THREE.SphereGeometry(renderedPlanetRadius, 18, 18), new THREE.MeshStandardMaterial({ color: planet.color, roughness: 0.92, @@ -531,7 +680,7 @@ export class GameViewer { }), ); planetMesh.position.copy(this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds())); - const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2)); + const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, renderedPlanetRadius * 2)); planetIcon.position.copy(planetMesh.position); const ring = planet.hasRing ? this.createPlanetRing(planet) : undefined; if (ring) { @@ -563,6 +712,51 @@ export class GameViewer { } } + private syncSpatialNodes(nodes: SpatialNodeSnapshot[]) { + this.spatialNodeGroup.clear(); + this.spatialNodeVisuals.clear(); + + for (const node of nodes) { + const mesh = this.createSpatialNodeMesh(node); + const icon = this.createTacticalIcon(this.spatialNodeColor(node.kind), 18); + const localPosition = this.toThreeVector(node.localPosition); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + this.spatialNodeVisuals.set(node.id, { + id: node.id, + systemId: node.systemId, + mesh, + icon, + kind: node.kind, + localPosition, + }); + this.spatialNodeGroup.add(mesh, icon); + this.registerPresentation(mesh, icon, true, true, node.systemId); + this.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id }); + this.selectableTargets.set(icon, { kind: "spatial-node", id: node.id }); + } + } + + private syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) { + this.bubbleGroup.clear(); + this.bubbleVisuals.clear(); + + for (const bubble of bubbles) { + const localPosition = this.resolveBubblePosition(bubble); + const mesh = this.createBubbleRing(bubble, localPosition); + this.setBubbleVisualState({ id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius }, bubble); + this.bubbleVisuals.set(bubble.id, { + id: bubble.id, + systemId: bubble.systemId, + mesh, + localPosition, + radius: bubble.radius, + }); + this.bubbleGroup.add(mesh); + this.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id }); + } + } + private syncNodes(nodes: ResourceNodeSnapshot[]) { this.nodeGroup.clear(); this.nodeVisuals.clear(); @@ -604,6 +798,7 @@ export class GameViewer { const anchor = this.resolveOrbitalAnchor(station.systemId, localPosition); const orbital = this.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); this.stationVisuals.set(station.id, { + id: station.id, systemId: station.systemId, mesh, icon, @@ -620,15 +815,67 @@ export class GameViewer { } } + private syncClaims(claims: ClaimSnapshot[]) { + this.claimGroup.clear(); + this.claimVisuals.clear(); + + for (const claim of claims) { + const localPosition = this.resolvePointPosition(claim.systemId, claim.nodeId); + const mesh = this.createClaimMesh(claim); + const icon = this.createTacticalIcon("#ff5b5b", 18); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + this.claimVisuals.set(claim.id, { + id: claim.id, + nodeId: claim.nodeId, + systemId: claim.systemId, + mesh, + icon, + localPosition, + }); + this.claimGroup.add(mesh, icon); + this.registerPresentation(mesh, icon, true, true, claim.systemId); + this.selectableTargets.set(mesh, { kind: "claim", id: claim.id }); + this.selectableTargets.set(icon, { kind: "claim", id: claim.id }); + } + } + + private syncConstructionSites(sites: ConstructionSiteSnapshot[]) { + this.constructionSiteGroup.clear(); + this.constructionSiteVisuals.clear(); + + for (const site of sites) { + const localPosition = this.resolvePointPosition(site.systemId, site.nodeId); + const mesh = this.createConstructionSiteMesh(site); + const icon = this.createTacticalIcon("#9df29c", 18); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + this.constructionSiteVisuals.set(site.id, { + id: site.id, + nodeId: site.nodeId, + systemId: site.systemId, + mesh, + icon, + localPosition, + }); + this.constructionSiteGroup.add(mesh, icon); + this.registerPresentation(mesh, icon, true, true, site.systemId); + this.selectableTargets.set(mesh, { kind: "construction-site", id: site.id }); + this.selectableTargets.set(icon, { kind: "construction-site", id: site.id }); + } + } + private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) { this.shipGroup.clear(); this.shipVisuals.clear(); for (const ship of ships) { const mesh = this.createShipMesh(ship); - const icon = this.createTacticalIcon(this.shipColor(ship.role), 18); + const shipColor = this.shipPresentationColor(ship); + const icon = this.createTacticalIcon(shipColor, 18); const position = this.toThreeVector(ship.localPosition); icon.position.copy(position); + icon.material.color.set(shipColor); this.shipGroup.add(mesh, icon); this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); this.selectableTargets.set(icon, { kind: "ship", id: ship.id }); @@ -647,6 +894,38 @@ export class GameViewer { } } + private applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) { + for (const node of nodes) { + const visual = this.spatialNodeVisuals.get(node.id); + if (!visual) { + continue; + } + + visual.systemId = node.systemId; + visual.kind = node.kind; + visual.localPosition.copy(this.toThreeVector(node.localPosition)); + visual.mesh.position.copy(visual.localPosition); + visual.icon.position.copy(visual.localPosition); + (visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.spatialNodeColor(node.kind)); + } + } + + private applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) { + for (const bubble of bubbles) { + const visual = this.bubbleVisuals.get(bubble.id); + if (!visual) { + continue; + } + + visual.systemId = bubble.systemId; + visual.radius = bubble.radius; + visual.localPosition.copy(this.resolveBubblePosition(bubble)); + visual.mesh.position.copy(visual.localPosition); + visual.mesh.scale.setScalar(Math.max(bubble.radius, 60)); + this.setBubbleVisualState(visual, bubble); + } + } + private applyNodeDeltas(nodes: ResourceNodeDelta[]) { for (const node of nodes) { const visual = this.nodeVisuals.get(node.id); @@ -686,6 +965,40 @@ export class GameViewer { } } + private applyClaimDeltas(claims: ClaimDelta[]) { + for (const claim of claims) { + const visual = this.claimVisuals.get(claim.id); + if (!visual) { + continue; + } + + visual.systemId = claim.systemId; + visual.localPosition.copy(this.resolvePointPosition(claim.systemId, claim.nodeId)); + visual.mesh.position.copy(visual.localPosition); + visual.icon.position.copy(visual.localPosition); + const material = visual.mesh.material as THREE.MeshStandardMaterial; + material.color.set(claim.state === "active" ? "#ff7f50" : "#ff5b5b"); + material.emissive.set(claim.state === "active" ? "#ffb27d" : "#7a2020"); + } + } + + private applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) { + for (const site of sites) { + const visual = this.constructionSiteVisuals.get(site.id); + if (!visual) { + continue; + } + + visual.systemId = site.systemId; + visual.localPosition.copy(this.resolvePointPosition(site.systemId, site.nodeId)); + visual.mesh.position.copy(visual.localPosition); + visual.icon.position.copy(visual.localPosition); + const material = visual.mesh.material as THREE.MeshStandardMaterial; + material.color.set(site.state === "completed" ? "#46d37f" : "#9df29c"); + visual.mesh.scale.setScalar(0.75 + site.progress * 0.35); + } + } + private applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) { for (const ship of ships) { const visual = this.shipVisuals.get(ship.id); @@ -700,7 +1013,11 @@ export class GameViewer { visual.velocity.copy(this.toThreeVector(ship.localVelocity)); visual.receivedAtMs = performance.now(); visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); - (visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role)); + const shipColor = this.shipPresentationColor(ship); + const material = visual.mesh.material as THREE.MeshStandardMaterial; + material.color.set(shipColor); + material.emissive.set(new THREE.Color(shipColor).multiplyScalar(0.18)); + visual.icon.material.color.set(shipColor); } } @@ -754,13 +1071,18 @@ export class GameViewer { this.refreshHistoryWindows(); this.updateSystemPanel(); + this.refreshStreamScopeIfNeeded(); if (this.selectedItems.length === 0) { this.detailTitleEl.textContent = this.world.label; this.detailBodyEl.innerHTML = ` Zoom ${this.zoomLevel}
Systems ${this.world.systems.size}
+ Spatial nodes ${this.world.spatialNodes.size}
+ Bubbles ${this.world.localBubbles.size}
Stations ${this.world.stations.size}
+ Claims ${this.world.claims.size}
+ Construction ${this.world.constructionSites.size}
Ships ${this.world.ships.size}
Recent events ${this.world.recentEvents.length} `; @@ -830,6 +1152,70 @@ export class GameViewer { return; } + if (selected.kind === "spatial-node") { + const node = this.world.spatialNodes.get(selected.id); + if (!node) { + return; + } + const bubble = this.world.localBubbles.get(node.bubbleId); + this.detailTitleEl.textContent = `${node.kind} node`; + this.detailBodyEl.innerHTML = ` +

${node.systemId}

+

Bubble ${node.bubbleId}

+

Parent ${node.parentNodeId ?? "none"}
Orbit ref ${node.orbitReferenceId ?? "none"}

+

Occupying structure ${node.occupyingStructureId ?? "none"}

+

Bubble occupants ${bubble ? bubble.occupantShipIds.length + bubble.occupantStationIds.length : 0}

+ `; + return; + } + + if (selected.kind === "bubble") { + const bubble = this.world.localBubbles.get(selected.id); + if (!bubble) { + return; + } + this.detailTitleEl.textContent = `Bubble ${bubble.id}`; + this.detailBodyEl.innerHTML = ` +

${bubble.systemId}

+

Anchor node ${bubble.nodeId}
Radius ${bubble.radius.toFixed(0)}

+

Ships ${bubble.occupantShipIds.length}
Stations ${bubble.occupantStationIds.length}

+

Claims ${bubble.occupantClaimIds.length}
Construction sites ${bubble.occupantConstructionSiteIds.length}

+ `; + return; + } + + if (selected.kind === "claim") { + const claim = this.world.claims.get(selected.id); + if (!claim) { + return; + } + this.detailTitleEl.textContent = `Claim ${claim.id}`; + this.detailBodyEl.innerHTML = ` +

${claim.systemId}

+

Node ${claim.nodeId}
Bubble ${claim.bubbleId}

+

State ${claim.state}
Health ${claim.health.toFixed(0)}

+

Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}

+ `; + return; + } + + if (selected.kind === "construction-site") { + const site = this.world.constructionSites.get(selected.id); + if (!site) { + return; + } + const orderCount = [...this.world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length; + this.detailTitleEl.textContent = `Construction ${site.id}`; + this.detailBodyEl.innerHTML = ` +

${site.systemId}

+

Node ${site.nodeId}
Bubble ${site.bubbleId}

+

${site.targetKind} ${site.targetDefinitionId}

+

State ${site.state}
Progress ${(site.progress * 100).toFixed(0)}%

+

Orders ${orderCount}
Assigned constructors ${site.assignedConstructorShipIds.length}

+ `; + return; + } + if (selected.kind === "planet") { const system = this.world.systems.get(selected.systemId); const planet = system?.planets[selected.planetIndex]; @@ -984,7 +1370,16 @@ export class GameViewer { } private recordDeltaStats(delta: WorldDelta, rawBytes: number) { - const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length; + const changedEntities = delta.ships.length + + delta.stations.length + + delta.nodes.length + + delta.spatialNodes.length + + delta.localBubbles.length + + delta.claims.length + + delta.constructionSites.length + + delta.marketOrders.length + + delta.policies.length + + delta.factions.length; this.networkStats.deltasReceived += 1; this.networkStats.deltaBytes += rawBytes; this.networkStats.lastDeltaBytes = rawBytes; @@ -1100,10 +1495,37 @@ export class GameViewer { visual.icon.position.copy(visual.mesh.position); visual.mesh.visible = visual.systemId === this.activeSystemId; } - for (const visual of this.stationVisuals.values()) { - visual.mesh.position.copy(this.toDisplayLocalPosition(visual.localPosition, visual.systemId)); + for (const visual of this.spatialNodeVisuals.values()) { + const animatedLocalPosition = this.computeSpatialNodeLocalPosition(visual, worldTimeSeconds); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); visual.mesh.visible = visual.systemId === this.activeSystemId; + visual.icon.visible = visual.systemId === this.activeSystemId; + } + for (const visual of this.bubbleVisuals.values()) { + const animatedLocalPosition = this.resolveBubbleAnimatedLocalPosition(visual, worldTimeSeconds); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.visible = visual.systemId === this.activeSystemId; + } + for (const visual of this.stationVisuals.values()) { + const animatedLocalPosition = this.resolveStructureAnimatedLocalPosition(visual, worldTimeSeconds); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === this.activeSystemId; + } + for (const visual of this.claimVisuals.values()) { + const animatedLocalPosition = this.computeSpatialNodeLocalPositionById(visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === this.activeSystemId; + visual.icon.visible = visual.systemId === this.activeSystemId; + } + for (const visual of this.constructionSiteVisuals.values()) { + const animatedLocalPosition = this.computeSpatialNodeLocalPositionById(visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === this.activeSystemId; + visual.icon.visible = visual.systemId === this.activeSystemId; } this.updateSystemStarPresentation(); @@ -1173,9 +1595,58 @@ export class GameViewer { return mesh; } + private createSpatialNodeMesh(node: SpatialNodeSnapshot) { + return new THREE.Mesh( + new THREE.OctahedronGeometry(10, 0), + new THREE.MeshStandardMaterial({ + color: this.spatialNodeColor(node.kind), + emissive: new THREE.Color(this.spatialNodeColor(node.kind)).multiplyScalar(0.16), + roughness: 0.35, + metalness: 0.45, + }), + ); + } + + private createBubbleRing(bubble: LocalBubbleSnapshot, localPosition: THREE.Vector3) { + const ring = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(this.createCirclePoints(Math.max(bubble.radius, 60), 64)), + new THREE.LineBasicMaterial({ + color: 0x6ed6ff, + transparent: true, + opacity: 0.32, + }), + ); + ring.position.copy(localPosition); + return ring; + } + + private createClaimMesh(claim: ClaimSnapshot) { + return new THREE.Mesh( + new THREE.ConeGeometry(9, 20, 4), + new THREE.MeshStandardMaterial({ + color: claim.state === "active" ? 0xff7f50 : 0xff5b5b, + emissive: new THREE.Color(claim.state === "active" ? 0xff7f50 : 0xff5b5b).multiplyScalar(0.16), + roughness: 0.4, + metalness: 0.28, + }), + ); + } + + private createConstructionSiteMesh(site: ConstructionSiteSnapshot) { + return new THREE.Mesh( + new THREE.TorusKnotGeometry(7, 2.2, 54, 8), + new THREE.MeshStandardMaterial({ + color: site.state === "completed" ? 0x46d37f : 0x9df29c, + emissive: new THREE.Color(site.state === "completed" ? 0x46d37f : 0x9df29c).multiplyScalar(0.15), + roughness: 0.34, + metalness: 0.48, + }), + ); + } + private createStarCluster(system: SystemSnapshot) { const root = new THREE.Group(); - const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8); + const renderedStarSize = this.celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const offsets = system.starCount > 1 ? [new THREE.Vector3(-renderedStarSize * 0.55, 0, 0), new THREE.Vector3(renderedStarSize * 0.75, renderedStarSize * 0.08, 0)] : [new THREE.Vector3(0, 0, 0)]; @@ -1216,8 +1687,9 @@ export class GameViewer { } private createPlanetRing(planet: PlanetSnapshot) { + const renderedPlanetRadius = this.celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); const ring = new THREE.Mesh( - new THREE.RingGeometry(planet.size * 1.35, planet.size * 2.15, 48), + new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48), new THREE.MeshBasicMaterial({ color: 0xdac89a, transparent: true, @@ -1251,7 +1723,7 @@ export class GameViewer { ); orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35); - const moonSize = this.computeMoonSize(planet, moonIndex); + const moonSize = this.computeMoonRenderRadius(planet, moonIndex); const mesh = new THREE.Mesh( new THREE.SphereGeometry(moonSize, 12, 12), new THREE.MeshStandardMaterial({ @@ -1280,9 +1752,13 @@ export class GameViewer { private createShipMesh(ship: ShipSnapshot) { const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7); geometry.rotateX(Math.PI / 2); + const shipColor = this.shipPresentationColor(ship); const mesh = new THREE.Mesh( geometry, - new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }), + new THREE.MeshStandardMaterial({ + color: shipColor, + emissive: new THREE.Color(shipColor).multiplyScalar(0.18), + }), ); mesh.position.copy(this.toThreeVector(ship.localPosition)); return mesh; @@ -1644,6 +2120,18 @@ export class GameViewer { if (item.kind === "node") { return item.id; } + if (item.kind === "spatial-node") { + return `${this.world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`; + } + if (item.kind === "bubble") { + return `bubble ${item.id}`; + } + if (item.kind === "claim") { + return `claim ${item.id}`; + } + if (item.kind === "construction-site") { + return `construction ${item.id}`; + } if (item.kind === "planet") { return this.world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`; } @@ -1654,7 +2142,14 @@ export class GameViewer { if (item.kind === "ship") { return "ships"; } - if (item.kind === "station" || item.kind === "node") { + if ( + item.kind === "station" + || item.kind === "node" + || item.kind === "spatial-node" + || item.kind === "bubble" + || item.kind === "claim" + || item.kind === "construction-site" + ) { return "structures"; } return "celestials"; @@ -1806,6 +2301,14 @@ export class GameViewer { return Math.min(base + variance, planet.size * 0.42); } + private computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number) { + return this.celestialRenderRadius(this.computeMoonSize(planet, moonIndex), MOON_RENDER_SCALE, 2.5, 1.04); + } + + private celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1) { + return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale); + } + private deriveNodeOrbital(node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) { return this.deriveOrbitalFromLocalPosition(this.toThreeVector(node.localPosition), node.systemId, anchor); } @@ -2443,6 +2946,22 @@ export class GameViewer { ? this.computeNodeLocalPosition(visual, this.currentWorldTimeSeconds()) : (node ? this.toThreeVector(node.localPosition) : undefined); } + if (selection.kind === "spatial-node") { + const node = this.world.spatialNodes.get(selection.id); + return node ? this.toThreeVector(node.localPosition) : undefined; + } + if (selection.kind === "bubble") { + const bubble = this.world.localBubbles.get(selection.id); + return bubble ? this.resolveBubblePosition(bubble) : undefined; + } + if (selection.kind === "claim") { + const claim = this.world.claims.get(selection.id); + return claim ? this.resolvePointPosition(claim.systemId, claim.nodeId) : undefined; + } + if (selection.kind === "construction-site") { + const site = this.world.constructionSites.get(selection.id); + return site ? this.resolvePointPosition(site.systemId, site.nodeId) : undefined; + } if (selection.kind === "planet") { const system = this.world.systems.get(selection.systemId); const planet = system?.planets[selection.planetIndex]; @@ -2541,6 +3060,152 @@ export class GameViewer { return "#8bc0ff"; } + private shipPresentationColor(ship: ShipSnapshot) { + if (ship.spatialState.spaceLayer !== "local-space") { + return "#c77dff"; + } + if (ship.spatialState.movementRegime === "warp") { + return "#ffd166"; + } + if (ship.spatialState.movementRegime === "ftl-transit") { + return "#ff6ad5"; + } + return this.shipColor(ship.role); + } + + private spatialNodeColor(kind: string) { + if (kind.includes("lagrange")) { + return "#7fe8ff"; + } + if (kind.includes("station")) { + return "#ffc36e"; + } + if (kind.includes("planet")) { + return "#8bc0ff"; + } + if (kind.includes("moon")) { + return "#c7d7e8"; + } + return "#ffe082"; + } + + private createCirclePoints(radius: number, segments: number) { + return Array.from({ length: segments }, (_, index) => { + const theta = (index / segments) * Math.PI * 2; + return new THREE.Vector3(Math.cos(theta) * radius, 0, Math.sin(theta) * radius); + }); + } + + private resolvePointPosition(systemId: string, nodeId?: string | null) { + if (nodeId) { + const spatialNode = this.world?.spatialNodes.get(nodeId); + if (spatialNode) { + return this.toThreeVector(spatialNode.localPosition); + } + } + + return new THREE.Vector3(0, 0, 0); + } + + private resolveBubblePosition(bubble: LocalBubbleSnapshot | LocalBubbleDelta) { + return this.resolvePointPosition(bubble.systemId, bubble.nodeId); + } + + private resolveBubbleAnimatedLocalPosition(visual: BubbleVisual, timeSeconds: number) { + const bubble = this.world?.localBubbles.get(visual.id); + if (!bubble) { + return visual.localPosition.clone(); + } + + return this.computeSpatialNodeLocalPositionById(bubble.nodeId, timeSeconds) ?? visual.localPosition.clone(); + } + + private resolveStructureAnimatedLocalPosition(visual: StructureVisual, timeSeconds: number) { + if (!this.world) { + return visual.localPosition.clone(); + } + + const station = this.world.stations.get(visual.id); + if (!station?.nodeId) { + return visual.localPosition.clone(); + } + + return this.computeSpatialNodeLocalPositionById(station.nodeId, timeSeconds) ?? visual.localPosition.clone(); + } + + private computeSpatialNodeLocalPosition(visual: SpatialNodeVisual, timeSeconds: number) { + return this.computeSpatialNodeLocalPositionById(visual.id, timeSeconds) ?? visual.localPosition.clone(); + } + + private computeSpatialNodeLocalPositionById(nodeId: string, timeSeconds: number, visiting = new Set()): THREE.Vector3 | undefined { + if (!this.world || visiting.has(nodeId)) { + return undefined; + } + + const node = this.world.spatialNodes.get(nodeId); + if (!node) { + return undefined; + } + + const basePosition = this.toThreeVector(node.localPosition); + if (!node.parentNodeId) { + return basePosition; + } + + const parentNode = this.world.spatialNodes.get(node.parentNodeId); + if (!parentNode) { + return basePosition; + } + + visiting.add(nodeId); + const parentCurrentPosition = this.computeSpatialNodeLocalPositionById(node.parentNodeId, timeSeconds, visiting); + visiting.delete(nodeId); + if (!parentCurrentPosition) { + return basePosition; + } + + const parentInitialPosition = this.toThreeVector(parentNode.localPosition); + const relativeOffset = basePosition.clone().sub(parentInitialPosition); + const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x); + const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x); + const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle); + return parentCurrentPosition.clone().add(rotatedOffset); + } + + private setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { + const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length; + const material = visual.mesh.material as THREE.LineBasicMaterial; + material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72); + material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff"); + } + + private resolveFocusedBubbleId() { + if (!this.world || this.selectedItems.length !== 1) { + return undefined; + } + + const selected = this.selectedItems[0]; + if (selected.kind === "bubble") { + return selected.id; + } + if (selected.kind === "ship") { + return this.world.ships.get(selected.id)?.bubbleId ?? this.world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined; + } + if (selected.kind === "station") { + return this.world.stations.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "spatial-node") { + return this.world.spatialNodes.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "claim") { + return this.world.claims.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "construction-site") { + return this.world.constructionSites.get(selected.id)?.bubbleId ?? undefined; + } + return undefined; + } + private onResize = () => { const width = window.innerWidth; const height = window.innerHeight; @@ -2738,6 +3403,18 @@ export class GameViewer { if (selection.kind === "node") { return this.world.nodes.get(selection.id)?.systemId; } + if (selection.kind === "spatial-node") { + return this.world.spatialNodes.get(selection.id)?.systemId; + } + if (selection.kind === "bubble") { + return this.world.localBubbles.get(selection.id)?.systemId; + } + if (selection.kind === "claim") { + return this.world.claims.get(selection.id)?.systemId; + } + if (selection.kind === "construction-site") { + return this.world.constructionSites.get(selection.id)?.systemId; + } if (selection.kind === "planet") { return selection.systemId; } @@ -2772,10 +3449,26 @@ export class GameViewer { const visual = station ? this.stationVisuals.get(selection.id) : undefined; return this.describeOrbitalParent(station?.systemId, visual?.anchor); } + if (selection.kind === "node") { + const node = this.world.nodes.get(selection.id); + const visual = node ? this.nodeVisuals.get(selection.id) : undefined; + return this.describeOrbitalParent(node?.systemId, visual?.anchor); + } + if (selection.kind === "spatial-node") { + const node = this.world.spatialNodes.get(selection.id); + return node?.parentNodeId ?? `${node?.systemId ?? "unknown"} network`; + } + if (selection.kind === "bubble") { + return `${this.world.localBubbles.get(selection.id)?.nodeId ?? "unknown"} node`; + } + if (selection.kind === "claim") { + return this.world.claims.get(selection.id)?.nodeId ?? "unknown"; + } + if (selection.kind === "construction-site") { + return this.world.constructionSites.get(selection.id)?.nodeId ?? "unknown"; + } - const node = this.world.nodes.get(selection.id); - const visual = node ? this.nodeVisuals.get(selection.id) : undefined; - return this.describeOrbitalParent(node?.systemId, visual?.anchor); + return "unknown"; } private describeOrbitalParent(systemId?: string, anchor?: OrbitalAnchor) { @@ -2870,6 +3563,10 @@ export class GameViewer { let shipCount = 0; let stationCount = 0; let nodeCount = 0; + let spatialNodeCount = 0; + let bubbleCount = 0; + let claimCount = 0; + let constructionCount = 0; let moonCount = 0; for (const ship of this.world.ships.values()) { @@ -2887,6 +3584,26 @@ export class GameViewer { nodeCount += 1; } } + for (const node of this.world.spatialNodes.values()) { + if (node.systemId === system.id) { + spatialNodeCount += 1; + } + } + for (const bubble of this.world.localBubbles.values()) { + if (bubble.systemId === system.id) { + bubbleCount += 1; + } + } + for (const claim of this.world.claims.values()) { + if (claim.systemId === system.id) { + claimCount += 1; + } + } + for (const site of this.world.constructionSites.values()) { + if (site.systemId === system.id) { + constructionCount += 1; + } + } for (const planet of system.planets) { moonCount += planet.moonCount; } @@ -2898,7 +3615,9 @@ export class GameViewer { return `

${system.id}${activeContext ? " · active system" : ""}

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

-

Planets ${system.planets.length}
Moons ${moonCount}
Ships ${shipCount}
Stations ${stationCount}
Nodes ${nodeCount}

+

Planets ${system.planets.length}
Moons ${moonCount}
Ships ${shipCount}
Stations ${stationCount}

+

Spatial nodes ${spatialNodeCount}
Resource nodes ${nodeCount}
Bubbles ${bubbleCount}

+

Claims ${claimCount}
Construction sites ${constructionCount}

Height ${system.galaxyPosition.y.toFixed(0)}

${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("
")}

${followText} @@ -2921,6 +3640,6 @@ export class GameViewer { } this.systemTitleEl.textContent = activeSystem.label; - this.systemBodyEl.innerHTML = `

${activeSystem.starKind}

`; + this.systemBodyEl.innerHTML = ""; } } diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index a12d1c8..8f8f5ce 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -1,5 +1,11 @@ import type { WorldDelta, WorldSnapshot } from "./contracts"; +export interface WorldStreamScope { + scopeKind?: string; + systemId?: string | null; + bubbleId?: string | null; +} + export async function fetchWorldSnapshot(signal?: AbortSignal) { const response = await fetch("/api/world", { signal }); if (!response.ok) { @@ -15,8 +21,22 @@ export function openWorldStream( onOpen?: () => void; onError?: () => void; }, + scope?: WorldStreamScope, ) { - const stream = new EventSource(`/api/world/stream?afterSequence=${afterSequence}`); + const query = new URLSearchParams({ + afterSequence: String(afterSequence), + }); + if (scope?.scopeKind) { + query.set("scopeKind", scope.scopeKind); + } + if (scope?.systemId) { + query.set("systemId", scope.systemId); + } + if (scope?.bubbleId) { + query.set("bubbleId", scope.bubbleId); + } + + const stream = new EventSource(`/api/world/stream?${query.toString()}`); stream.addEventListener("open", () => handlers.onOpen?.()); stream.addEventListener("error", () => handlers.onError?.()); stream.addEventListener("world-delta", (event) => { diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 96c807a..29c99da 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -5,8 +5,14 @@ export interface WorldSnapshot { tickIntervalMs: number; generatedAtUtc: string; systems: SystemSnapshot[]; + spatialNodes: SpatialNodeSnapshot[]; + localBubbles: LocalBubbleSnapshot[]; nodes: ResourceNodeSnapshot[]; stations: StationSnapshot[]; + claims: ClaimSnapshot[]; + constructionSites: ConstructionSiteSnapshot[]; + marketOrders: MarketOrderSnapshot[]; + policies: PolicySetSnapshot[]; ships: ShipSnapshot[]; factions: FactionSnapshot[]; } @@ -17,10 +23,17 @@ export interface WorldDelta { generatedAtUtc: string; requiresSnapshotRefresh: boolean; events: SimulationEventRecord[]; + spatialNodes: SpatialNodeDelta[]; + localBubbles: LocalBubbleDelta[]; nodes: ResourceNodeDelta[]; stations: StationDelta[]; + claims: ClaimDelta[]; + constructionSites: ConstructionSiteDelta[]; + marketOrders: MarketOrderDelta[]; + policies: PolicySetDelta[]; ships: ShipDelta[]; factions: FactionDelta[]; + scope?: ObserverScope | null; } export interface SimulationEventRecord { @@ -29,6 +42,16 @@ export interface SimulationEventRecord { kind: string; message: string; occurredAtUtc: string; + family?: string; + scopeKind?: string; + scopeEntityId?: string | null; + visibility?: string; +} + +export interface ObserverScope { + scopeKind: string; + systemId?: string | null; + bubbleId?: string | null; } export interface Vector3Dto { @@ -77,6 +100,32 @@ export interface ResourceNodeSnapshot { export interface ResourceNodeDelta extends ResourceNodeSnapshot {} +export interface SpatialNodeSnapshot { + id: string; + systemId: string; + kind: string; + localPosition: Vector3Dto; + bubbleId: string; + parentNodeId?: string | null; + occupyingStructureId?: string | null; + orbitReferenceId?: string | null; +} + +export interface SpatialNodeDelta extends SpatialNodeSnapshot {} + +export interface LocalBubbleSnapshot { + id: string; + nodeId: string; + systemId: string; + radius: number; + occupantShipIds: string[]; + occupantStationIds: string[]; + occupantClaimIds: string[]; + occupantConstructionSiteIds: string[]; +} + +export interface LocalBubbleDelta extends LocalBubbleSnapshot {} + export interface InventoryEntry { itemId: string; amount: number; @@ -88,16 +137,92 @@ export interface StationSnapshot { category: string; systemId: string; localPosition: Vector3Dto; + nodeId?: string | null; + bubbleId?: string | null; + anchorNodeId?: string | null; color: string; dockedShips: number; dockingPads: number; energyStored: number; inventory: InventoryEntry[]; factionId: string; + commanderId?: string | null; + policySetId?: string | null; + population: number; + populationCapacity: number; + workforceRequired: number; + workforceEffectiveRatio: number; + installedModules: string[]; + marketOrderIds: string[]; } export interface StationDelta extends StationSnapshot {} +export interface ClaimSnapshot { + id: string; + factionId: string; + systemId: string; + nodeId: string; + bubbleId: string; + state: string; + health: number; + placedAtUtc: string; + activatesAtUtc: string; +} + +export interface ClaimDelta extends ClaimSnapshot {} + +export interface ConstructionSiteSnapshot { + id: string; + factionId: string; + systemId: string; + nodeId: string; + bubbleId: string; + targetKind: string; + targetDefinitionId: string; + blueprintId?: string | null; + claimId?: string | null; + stationId?: string | null; + state: string; + progress: number; + inventory: InventoryEntry[]; + requiredItems: InventoryEntry[]; + deliveredItems: InventoryEntry[]; + assignedConstructorShipIds: string[]; + marketOrderIds: string[]; +} + +export interface ConstructionSiteDelta extends ConstructionSiteSnapshot {} + +export interface MarketOrderSnapshot { + id: string; + factionId: string; + stationId?: string | null; + constructionSiteId?: string | null; + kind: string; + itemId: string; + amount: number; + remainingAmount: number; + valuation: number; + reserveThreshold?: number | null; + policySetId?: string | null; + state: string; +} + +export interface MarketOrderDelta extends MarketOrderSnapshot {} + +export interface PolicySetSnapshot { + id: string; + ownerKind: string; + ownerId: string; + tradeAccessPolicy: string; + dockingAccessPolicy: string; + constructionAccessPolicy: string; + operationalRangePolicy: string; +} + +export interface PolicySetDelta extends PolicySetSnapshot {} + export interface ShipSnapshot { id: string; label: string; @@ -111,25 +236,55 @@ export interface ShipSnapshot { orderKind: string | null; defaultBehaviorKind: string; controllerTaskKind: string; + nodeId?: string | null; + bubbleId?: string | null; + dockedStationId?: string | null; + commanderId?: string | null; + policySetId?: string | null; cargoCapacity: number; + workerPopulation: number; energyStored: number; inventory: InventoryEntry[]; factionId: string; health: number; history: string[]; + spatialState: ShipSpatialStateSnapshot; } export interface ShipDelta extends ShipSnapshot {} +export interface ShipSpatialStateSnapshot { + spaceLayer: string; + currentSystemId: string; + currentNodeId?: string | null; + currentBubbleId?: string | null; + localPosition?: Vector3Dto | null; + systemPosition?: Vector3Dto | null; + movementRegime: string; + destinationNodeId?: string | null; + transit?: ShipTransitSnapshot | null; +} + +export interface ShipTransitSnapshot { + regime: string; + originNodeId?: string | null; + destinationNodeId?: string | null; + startedAtUtc?: string | null; + arrivalDueAtUtc?: string | null; + progress: number; +} + export interface FactionSnapshot { id: string; label: string; color: string; credits: number; + populationTotal: number; oreMined: number; goodsProduced: number; shipsBuilt: number; shipsLost: number; + defaultPolicySetId?: string | null; } export interface FactionDelta extends FactionSnapshot {}