diff --git a/apps/backend/Contracts/WorldContracts.Celestial.cs b/apps/backend/Contracts/WorldContracts.Celestial.cs new file mode 100644 index 0000000..c1bf907 --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Celestial.cs @@ -0,0 +1,85 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record SystemSnapshot( + string Id, + string Label, + Vector3Dto GalaxyPosition, + string StarKind, + int StarCount, + string StarColor, + float StarSize, + IReadOnlyList Planets); + +public sealed record PlanetSnapshot( + string Label, + string PlanetType, + string Shape, + int MoonCount, + float OrbitRadius, + float OrbitSpeed, + float OrbitEccentricity, + float OrbitInclination, + float OrbitLongitudeOfAscendingNode, + float OrbitArgumentOfPeriapsis, + float OrbitPhaseAtEpoch, + float Size, + string Color, + bool HasRing); + +public sealed record ResourceNodeSnapshot( + string Id, + string SystemId, + Vector3Dto LocalPosition, + string SourceKind, + float OreRemaining, + float MaxOre, + string ItemId); + +public sealed record ResourceNodeDelta( + string Id, + string SystemId, + Vector3Dto LocalPosition, + string SourceKind, + float OreRemaining, + 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); diff --git a/apps/backend/Contracts/WorldContracts.Common.cs b/apps/backend/Contracts/WorldContracts.Common.cs new file mode 100644 index 0000000..b0f5c4b --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Common.cs @@ -0,0 +1,4 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record Vector3Dto(float X, float Y, float Z); + diff --git a/apps/backend/Contracts/WorldContracts.Economy.cs b/apps/backend/Contracts/WorldContracts.Economy.cs new file mode 100644 index 0000000..8d06997 --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Economy.cs @@ -0,0 +1,47 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +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); diff --git a/apps/backend/Contracts/WorldContracts.Factions.cs b/apps/backend/Contracts/WorldContracts.Factions.cs new file mode 100644 index 0000000..5fb2706 --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Factions.cs @@ -0,0 +1,25 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record FactionSnapshot( + string Id, + string Label, + string Color, + float Credits, + float PopulationTotal, + float OreMined, + float GoodsProduced, + int ShipsBuilt, + 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, + string? DefaultPolicySetId); diff --git a/apps/backend/Contracts/WorldContracts.Infrastructure.cs b/apps/backend/Contracts/WorldContracts.Infrastructure.cs new file mode 100644 index 0000000..aef5569 --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Infrastructure.cs @@ -0,0 +1,113 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record InventoryEntry( + string ItemId, + float Amount); + +public sealed record StationSnapshot( + string Id, + string Label, + 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? CommanderId, + string? PolicySetId, + float Population, + float PopulationCapacity, + float WorkforceRequired, + float WorkforceEffectiveRatio, + IReadOnlyList InstalledModules, + IReadOnlyList MarketOrderIds); + +public sealed record StationDelta( + string Id, + string Label, + 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? 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); diff --git a/apps/backend/Contracts/WorldContracts.Ships.cs b/apps/backend/Contracts/WorldContracts.Ships.cs new file mode 100644 index 0000000..6acc0e6 --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.Ships.cs @@ -0,0 +1,74 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record ShipSnapshot( + string Id, + string Label, + string Role, + string ShipClass, + string SystemId, + Vector3Dto LocalPosition, + Vector3Dto LocalVelocity, + Vector3Dto TargetLocalPosition, + string State, + 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, + ShipSpatialStateSnapshot SpatialState); + +public sealed record ShipDelta( + string Id, + string Label, + string Role, + string ShipClass, + string SystemId, + Vector3Dto LocalPosition, + Vector3Dto LocalVelocity, + Vector3Dto TargetLocalPosition, + string State, + 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, + 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); diff --git a/apps/backend/Contracts/WorldContracts.World.cs b/apps/backend/Contracts/WorldContracts.World.cs new file mode 100644 index 0000000..280cebf --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.World.cs @@ -0,0 +1,53 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record WorldSnapshot( + string Label, + int Seed, + long Sequence, + 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); + +public sealed record WorldDelta( + long Sequence, + int TickIntervalMs, + 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, + ObserverScope? Scope = null); + +public sealed record SimulationEventRecord( + string EntityKind, + string EntityId, + string Kind, + string Message, + 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); diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs deleted file mode 100644 index 0392b9b..0000000 --- a/apps/backend/Contracts/WorldContracts.cs +++ /dev/null @@ -1,394 +0,0 @@ -namespace SpaceGame.Simulation.Api.Contracts; - -public sealed record WorldSnapshot( - string Label, - int Seed, - long Sequence, - 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); - -public sealed record WorldDelta( - long Sequence, - int TickIntervalMs, - 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, - ObserverScope? Scope = null); - -public sealed record SimulationEventRecord( - string EntityKind, - string EntityId, - string Kind, - string Message, - 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, - string Label, - Vector3Dto GalaxyPosition, - string StarKind, - int StarCount, - string StarColor, - float StarSize, - IReadOnlyList Planets); - -public sealed record PlanetSnapshot( - string Label, - string PlanetType, - string Shape, - int MoonCount, - float OrbitRadius, - float OrbitSpeed, - float OrbitEccentricity, - float OrbitInclination, - float OrbitLongitudeOfAscendingNode, - float OrbitArgumentOfPeriapsis, - float OrbitPhaseAtEpoch, - float Size, - string Color, - bool HasRing); - -public sealed record ResourceNodeSnapshot( - string Id, - string SystemId, - Vector3Dto LocalPosition, - string SourceKind, - float OreRemaining, - 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, - Vector3Dto LocalPosition, - string SourceKind, - float OreRemaining, - float MaxOre, - string ItemId); - -public sealed record InventoryEntry( - string ItemId, - float Amount); - -public sealed record StationSnapshot( - string Id, - string Label, - 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? CommanderId, - string? PolicySetId, - float Population, - float PopulationCapacity, - float WorkforceRequired, - float WorkforceEffectiveRatio, - IReadOnlyList InstalledModules, - IReadOnlyList MarketOrderIds); - -public sealed record StationDelta( - string Id, - string Label, - 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? 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, - string Label, - string Role, - string ShipClass, - string SystemId, - Vector3Dto LocalPosition, - Vector3Dto LocalVelocity, - Vector3Dto TargetLocalPosition, - string State, - 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, - ShipSpatialStateSnapshot SpatialState); - -public sealed record ShipDelta( - string Id, - string Label, - string Role, - string ShipClass, - string SystemId, - Vector3Dto LocalPosition, - Vector3Dto LocalVelocity, - Vector3Dto TargetLocalPosition, - string State, - 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, - 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, - string? DefaultPolicySetId); - -public sealed record FactionDelta( - string Id, - string Label, - string Color, - float Credits, - float PopulationTotal, - float OreMined, - float GoodsProduced, - int ShipsBuilt, - int ShipsLost, - string? DefaultPolicySetId); - -public sealed record Vector3Dto(float X, float Y, float Z); diff --git a/apps/backend/Simulation/AI/IShipBehaviorState.cs b/apps/backend/Simulation/AI/IShipBehaviorState.cs new file mode 100644 index 0000000..0c08832 --- /dev/null +++ b/apps/backend/Simulation/AI/IShipBehaviorState.cs @@ -0,0 +1,10 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +internal interface IShipBehaviorState +{ + string Kind { get; } + + void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world); + + void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent); +} diff --git a/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs b/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs new file mode 100644 index 0000000..55c4375 --- /dev/null +++ b/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs @@ -0,0 +1,39 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +internal sealed class ShipBehaviorStateMachine +{ + private readonly IReadOnlyDictionary states; + private readonly IShipBehaviorState fallbackState; + + private ShipBehaviorStateMachine(IReadOnlyDictionary states, IShipBehaviorState fallbackState) + { + this.states = states; + this.fallbackState = fallbackState; + } + + public static ShipBehaviorStateMachine CreateDefault() + { + var idleState = new IdleShipBehaviorState(); + var knownStates = new IShipBehaviorState[] + { + idleState, + new PatrolShipBehaviorState(), + new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"), + new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"), + new ConstructStationShipBehaviorState(), + }; + + return new ShipBehaviorStateMachine( + knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal), + idleState); + } + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) => + Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent); + + private IShipBehaviorState Resolve(string kind) => + states.TryGetValue(kind, out var state) ? state : fallbackState; +} diff --git a/apps/backend/Simulation/AI/ShipBehaviorStates.cs b/apps/backend/Simulation/AI/ShipBehaviorStates.cs new file mode 100644 index 0000000..42e7840 --- /dev/null +++ b/apps/backend/Simulation/AI/ShipBehaviorStates.cs @@ -0,0 +1,135 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +internal sealed class IdleShipBehaviorState : IShipBehaviorState +{ + public string Kind => "idle"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) + { + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "idle", + Threshold = world.Balance.ArrivalThreshold, + Status = WorkStatus.Pending, + }; + } + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + } +} + +internal sealed class PatrolShipBehaviorState : IShipBehaviorState +{ + public string Kind => "patrol"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) + { + if (ship.DefaultBehavior.PatrolPoints.Count == 0) + { + ship.DefaultBehavior.Kind = "idle"; + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "idle", + Threshold = world.Balance.ArrivalThreshold, + Status = WorkStatus.Pending, + }; + return; + } + + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "travel", + TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], + TargetSystemId = ship.SystemId, + Threshold = 18f, + }; + } + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0) + { + ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count; + } + } +} + +internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState +{ + private readonly string resourceItemId; + private readonly string requiredModule; + + public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule) + { + Kind = kind; + this.resourceItemId = resourceItemId; + this.requiredModule = requiredModule; + } + + public string Kind { get; } + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-node", "arrived"): + ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; + break; + case ("extract", "cargo-full"): + ship.DefaultBehavior.Phase = "travel-to-station"; + break; + case ("travel-to-station", "arrived"): + ship.DefaultBehavior.Phase = "dock"; + break; + case ("dock", "docked"): + ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; + break; + case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = "refuel"; + break; + case ("refuel", "refueled"): + ship.DefaultBehavior.Phase = "undock"; + break; + case ("undock", "undocked"): + ship.DefaultBehavior.Phase = "travel-to-node"; + ship.DefaultBehavior.NodeId = null; + break; + } + } +} + +internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState +{ + public string Kind => "construct-station"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + engine.PlanStationConstruction(ship, world); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-station", "arrived"): + ship.DefaultBehavior.Phase = "dock"; + break; + case ("dock", "docked"): + ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship) ? "refuel" : "deliver-to-site"; + break; + case ("refuel", "refueled"): + 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; + } + } +} diff --git a/apps/backend/Simulation/Model/CommerceRuntimeModels.cs b/apps/backend/Simulation/Model/CommerceRuntimeModels.cs new file mode 100644 index 0000000..3da5032 --- /dev/null +++ b/apps/backend/Simulation/Model/CommerceRuntimeModels.cs @@ -0,0 +1,30 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +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; +} diff --git a/apps/backend/Simulation/Model/ConstructionRuntimeModels.cs b/apps/backend/Simulation/Model/ConstructionRuntimeModels.cs new file mode 100644 index 0000000..4251286 --- /dev/null +++ b/apps/backend/Simulation/Model/ConstructionRuntimeModels.cs @@ -0,0 +1,38 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +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; +} diff --git a/apps/backend/Simulation/Model/FactionRuntimeModels.cs b/apps/backend/Simulation/Model/FactionRuntimeModels.cs new file mode 100644 index 0000000..8c798fa --- /dev/null +++ b/apps/backend/Simulation/Model/FactionRuntimeModels.cs @@ -0,0 +1,66 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class FactionRuntime +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string Color { get; init; } + public float Credits { get; set; } + public float PopulationTotal { get; set; } + public float OreMined { get; set; } + public float GoodsProduced { get; set; } + public int ShipsBuilt { get; set; } + public int ShipsLost { get; set; } + public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); + public string? DefaultPolicySetId { get; set; } + public 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 OrderStatus Status { get; set; } = OrderStatus.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 WorkStatus Status { get; set; } = WorkStatus.Pending; + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetNodeId { get; set; } + public Vector3? TargetPosition { get; set; } + public float Threshold { get; set; } +} diff --git a/apps/backend/Simulation/Model/ShipRuntimeModels.cs b/apps/backend/Simulation/Model/ShipRuntimeModels.cs new file mode 100644 index 0000000..f726ea2 --- /dev/null +++ b/apps/backend/Simulation/Model/ShipRuntimeModels.cs @@ -0,0 +1,64 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class ShipRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; set; } + public required ShipDefinition Definition { get; init; } + public required string FactionId { get; init; } + public required Vector3 Position { get; set; } + public required Vector3 TargetPosition { get; set; } + public required ShipSpatialStateRuntime SpatialState { get; set; } + public Vector3 Velocity { get; set; } = Vector3.Zero; + public string State { get; set; } = "idle"; + public ShipOrderRuntime? Order { get; set; } + public required DefaultBehaviorRuntime DefaultBehavior { get; set; } + 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; + public string LastDeltaSignature { get; set; } = string.Empty; +} + +public sealed class ShipOrderRuntime +{ + public required string Kind { get; init; } + public OrderStatus Status { get; set; } = OrderStatus.Accepted; + public required string DestinationSystemId { get; init; } + public required Vector3 DestinationPosition { get; init; } +} + +public sealed class DefaultBehaviorRuntime +{ + public required string Kind { get; set; } + public string? AreaSystemId { get; set; } + public string? StationId { get; set; } + public string? RefineryId { get; set; } + public string? NodeId { get; set; } + public string? ModuleId { get; set; } + public string? Phase { get; set; } + public List PatrolPoints { get; set; } = []; + public int PatrolIndex { get; set; } +} + +public sealed class ControllerTaskRuntime +{ + public required string Kind { get; set; } + public WorkStatus Status { get; set; } = WorkStatus.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; } +} diff --git a/apps/backend/Simulation/Model/SimulationKinds.cs b/apps/backend/Simulation/Model/SimulationKinds.cs new file mode 100644 index 0000000..6353f55 --- /dev/null +++ b/apps/backend/Simulation/Model/SimulationKinds.cs @@ -0,0 +1,148 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public enum SpatialNodeKind +{ + Star, + Planet, + Moon, + LagrangePoint, + Station, + ResourceSite, +} + +public enum WorkStatus +{ + Pending, + Active, + Completed, +} + +public enum OrderStatus +{ + Queued, + Accepted, + Completed, +} + +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 static class SimulationEnumMappings +{ + public static string ToContractValue(this SpatialNodeKind kind) => kind switch + { + SpatialNodeKind.Star => "star", + SpatialNodeKind.Planet => "planet", + SpatialNodeKind.Moon => "moon", + SpatialNodeKind.LagrangePoint => "lagrange-point", + SpatialNodeKind.Station => "station", + SpatialNodeKind.ResourceSite => "resource-site", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + + public static string ToContractValue(this WorkStatus status) => status switch + { + WorkStatus.Pending => "pending", + WorkStatus.Active => "active", + WorkStatus.Completed => "completed", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + public static string ToContractValue(this OrderStatus status) => status switch + { + OrderStatus.Queued => "queued", + OrderStatus.Accepted => "accepted", + OrderStatus.Completed => "completed", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; +} diff --git a/apps/backend/Simulation/Model/SimulationWorld.cs b/apps/backend/Simulation/Model/SimulationWorld.cs new file mode 100644 index 0000000..6f5ae1a --- /dev/null +++ b/apps/backend/Simulation/Model/SimulationWorld.cs @@ -0,0 +1,28 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class SimulationWorld +{ + public required string Label { get; init; } + public required int Seed { get; init; } + 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; } +} diff --git a/apps/backend/Simulation/Model/SpatialRuntimeModels.cs b/apps/backend/Simulation/Model/SpatialRuntimeModels.cs new file mode 100644 index 0000000..daf4a50 --- /dev/null +++ b/apps/backend/Simulation/Model/SpatialRuntimeModels.cs @@ -0,0 +1,70 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class SystemRuntime +{ + public required SolarSystemDefinition Definition { get; init; } + public required Vector3 Position { get; init; } +} + +public sealed class ResourceNodeRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; init; } + public required Vector3 Position { get; init; } + public required string SourceKind { get; init; } + public required string ItemId { get; init; } + public float OreRemaining { get; set; } + public float MaxOre { get; init; } + 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 SpatialNodeKind 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 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; } +} diff --git a/apps/backend/Simulation/Model/StationRuntimeModels.cs b/apps/backend/Simulation/Model/StationRuntimeModels.cs new file mode 100644 index 0000000..e7d0037 --- /dev/null +++ b/apps/backend/Simulation/Model/StationRuntimeModels.cs @@ -0,0 +1,39 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +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; 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; +} + +public sealed class ModuleConstructionRuntime +{ + public required string ModuleId { get; init; } + public float ProgressSeconds { get; set; } + public float RequiredSeconds { get; init; } + public string AssignedConstructorShipId { get; set; } = string.Empty; +} diff --git a/apps/backend/Simulation/Model/Vector3.cs b/apps/backend/Simulation/Model/Vector3.cs new file mode 100644 index 0000000..3ee6374 --- /dev/null +++ b/apps/backend/Simulation/Model/Vector3.cs @@ -0,0 +1,36 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public readonly record struct Vector3(float X, float Y, float Z) +{ + public static Vector3 Zero => new(0f, 0f, 0f); + + public float DistanceTo(Vector3 other) + { + var dx = X - other.X; + var dy = Y - other.Y; + var dz = Z - other.Z; + return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + } + + public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z); + + public Vector3 MoveToward(Vector3 target, float maxDistance) + { + var delta = target.Subtract(this); + var distance = MathF.Sqrt(delta.LengthSquared()); + if (distance <= 0.0001f || distance <= maxDistance) + { + return target; + } + + var scale = maxDistance / distance; + return new Vector3( + X + (delta.X * scale), + Y + (delta.Y * scale), + Z + (delta.Z * scale)); + } + + public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z); + + public Vector3 Divide(float scalar) => MathF.Abs(scalar) <= 0.0001f ? Zero : new Vector3(X / scalar, Y / scalar, Z / scalar); +} diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs deleted file mode 100644 index a7f9087..0000000 --- a/apps/backend/Simulation/RuntimeModels.cs +++ /dev/null @@ -1,458 +0,0 @@ -using SpaceGame.Simulation.Api.Data; - -namespace SpaceGame.Simulation.Api.Simulation; - -public sealed class SimulationWorld -{ - public required string Label { get; init; } - public required int Seed { get; init; } - 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; } -} - -public sealed class SystemRuntime -{ - public required SolarSystemDefinition Definition { get; init; } - public required Vector3 Position { get; init; } -} - -public sealed class ResourceNodeRuntime -{ - public required string Id { get; init; } - public required string SystemId { get; init; } - public required Vector3 Position { get; init; } - public required string SourceKind { get; init; } - public required string ItemId { get; init; } - public float OreRemaining { get; set; } - public float MaxOre { get; init; } - 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; 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; -} - -public sealed class ModuleConstructionRuntime -{ - public required string ModuleId { get; init; } - public float ProgressSeconds { get; set; } - public float RequiredSeconds { get; init; } - public string AssignedConstructorShipId { get; set; } = string.Empty; -} - -public sealed class ShipRuntime -{ - public required string Id { get; init; } - public required string SystemId { get; set; } - public required ShipDefinition Definition { get; init; } - public required string FactionId { get; init; } - public required Vector3 Position { get; set; } - public required Vector3 TargetPosition { get; set; } - public required ShipSpatialStateRuntime SpatialState { get; set; } - public Vector3 Velocity { get; set; } = Vector3.Zero; - public string State { get; set; } = "idle"; - public ShipOrderRuntime? Order { get; set; } - public required DefaultBehaviorRuntime DefaultBehavior { get; set; } - 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; - public string LastDeltaSignature { get; set; } = string.Empty; -} - -public sealed class FactionRuntime -{ - public required string Id { get; init; } - public required string Label { get; init; } - public required string Color { get; init; } - public float Credits { get; set; } - public float PopulationTotal { get; set; } - public float OreMined { get; set; } - public float GoodsProduced { get; set; } - public int ShipsBuilt { get; set; } - public int ShipsLost { get; set; } - public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); - public string? DefaultPolicySetId { get; set; } - public 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; -} - -public sealed class ShipOrderRuntime -{ - public required string Kind { get; init; } - public string Status { get; set; } = "accepted"; - public required string DestinationSystemId { get; init; } - public required Vector3 DestinationPosition { get; init; } -} - -public sealed class DefaultBehaviorRuntime -{ - public required string Kind { get; set; } - public string? AreaSystemId { get; set; } - public string? StationId { get; set; } - public string? RefineryId { get; set; } - public string? NodeId { get; set; } - public string? ModuleId { get; set; } - public string? Phase { get; set; } - public List PatrolPoints { get; set; } = []; - public int PatrolIndex { get; set; } -} - -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); - - public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z); - - public float DistanceTo(Vector3 other) - { - var dx = X - other.X; - var dy = Y - other.Y; - var dz = Z - other.Z; - return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); - } - - public Vector3 MoveToward(Vector3 target, float maxDistance) - { - var distance = DistanceTo(target); - if (distance <= maxDistance || distance <= 0.0001f) - { - return target; - } - - var t = maxDistance / distance; - return new Vector3( - X + ((target.X - X) * t), - Y + ((target.Y - Y) * t), - Z + ((target.Z - Z) * t)); - } - - public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z); - - public Vector3 Divide(float value) - { - if (value == 0f) - { - return Zero; - } - - return new(X / value, Y / value, Z / value); - } -} diff --git a/apps/backend/Simulation/ScenarioLoader.Generation.cs b/apps/backend/Simulation/ScenarioLoader.Generation.cs new file mode 100644 index 0000000..8f2a947 --- /dev/null +++ b/apps/backend/Simulation/ScenarioLoader.Generation.cs @@ -0,0 +1,509 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class ScenarioLoader +{ + private static List InjectSpecialSystems(IReadOnlyList authoredSystems) + { + var systems = authoredSystems + .Select(CloneSystemDefinition) + .ToList(); + + if (systems.All((system) => system.Id != "sol")) + { + systems.Add(CreateSolSystem()); + } + + return systems; + } + + private static List ExpandSystems(IReadOnlyList authoredSystems) + { + var systems = authoredSystems + .Select(CloneSystemDefinition) + .ToList(); + + if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0) + { + return systems; + } + + var existingIds = systems + .Select((system) => system.Id) + .ToHashSet(StringComparer.Ordinal); + var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count); + + for (var index = systems.Count; index < TargetSystemCount; index += 1) + { + var template = authoredSystems[index % authoredSystems.Count]; + var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; + var id = BuildGeneratedSystemId(name, index + 1); + while (!existingIds.Add(id)) + { + id = $"{id}-x"; + } + + systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count])); + } + + return systems; + } + + private static SolarSystemDefinition CreateGeneratedSystem( + SolarSystemDefinition template, + string label, + string id, + int generatedIndex, + Vector3 position) + { + var starProfile = SelectStarProfile(generatedIndex); + var planets = BuildGeneratedPlanets(template, generatedIndex); + + var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) + .Select((node) => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + }) + .ToList(); + + return new SolarSystemDefinition + { + Id = id, + Label = label, + Position = [position.X, position.Y, position.Z], + StarKind = starProfile.Kind, + StarCount = starProfile.StarCount, + StarColor = starProfile.StarColor, + StarGlow = starProfile.StarGlow, + StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f), + GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f), + AsteroidField = new AsteroidFieldDefinition + { + DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10), + RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f), + RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f), + HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f), + }, + ResourceNodes = resourceNodes, + Planets = planets, + }; + } + + private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) + { + return new SolarSystemDefinition + { + Id = definition.Id, + Label = definition.Label, + Position = definition.Position.ToArray(), + StarKind = definition.StarKind, + StarCount = definition.StarCount, + StarColor = definition.StarColor, + StarGlow = definition.StarGlow, + StarSize = definition.StarSize, + GravityWellRadius = definition.GravityWellRadius, + AsteroidField = new AsteroidFieldDefinition + { + DecorationCount = definition.AsteroidField.DecorationCount, + RadiusOffset = definition.AsteroidField.RadiusOffset, + RadiusVariance = definition.AsteroidField.RadiusVariance, + HeightVariance = definition.AsteroidField.HeightVariance, + }, + ResourceNodes = definition.ResourceNodes + .Select((node) => new ResourceNodeDefinition + { + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + }) + .ToList(), + Planets = definition.Planets + .Select((planet) => new PlanetDefinition + { + Label = planet.Label, + PlanetType = planet.PlanetType, + Shape = planet.Shape, + MoonCount = planet.MoonCount, + OrbitRadius = planet.OrbitRadius, + OrbitSpeed = planet.OrbitSpeed, + OrbitEccentricity = planet.OrbitEccentricity, + OrbitInclination = planet.OrbitInclination, + OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, + OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, + OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, + Size = planet.Size, + Color = planet.Color, + Tilt = planet.Tilt, + HasRing = planet.HasRing, + }) + .ToList(), + }; + } + + private static List BuildProceduralResourceNodes( + SolarSystemDefinition template, + IReadOnlyList planets, + int generatedIndex) + { + var nodes = new List(); + if (template.ResourceNodes.Count > 0) + { + nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + })); + } + + nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets)); + nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets)); + return nodes; + } + + private static List BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) + { + var allPositions = occupiedPositions.ToList(); + var generated = new List(count); + + for (var index = 0; index < count; index += 1) + { + Vector3? accepted = null; + for (var attempt = 0; attempt < 64; attempt += 1) + { + var candidate = ComputeGeneratedSystemPosition(index, attempt); + if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) + { + accepted = candidate; + break; + } + } + + accepted ??= ComputeFallbackGeneratedSystemPosition(index); + generated.Add(accepted.Value); + allPositions.Add(accepted.Value); + } + + return generated; + } + + private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt) + { + const int armCount = 4; + const float baseInnerRadius = 9000f; + const float radiusStep = 540f; + const float armOffset = MathF.PI * 2f / armCount; + + var armIndex = (generatedIndex + attempt) % armCount; + var armDepth = generatedIndex / armCount; + var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f); + var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f); + var x = MathF.Cos(angle) * radius; + var z = MathF.Sin(angle) * radius * 0.58f; + var y = ComputeSystemHeight(radius, generatedIndex, attempt); + return new Vector3(x, y, z); + } + + private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex) + { + const int ringCount = 5; + const float fallbackRadius = 42000f; + var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f; + var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f; + return new Vector3( + MathF.Cos(angle) * radius, + ComputeSystemHeight(radius, generatedIndex, 99), + MathF.Sin(angle) * radius * 0.6f); + } + + private static string BuildGeneratedSystemId(string label, int ordinal) + { + var slug = string.Concat(label + .ToLowerInvariant() + .Select((character) => char.IsLetterOrDigit(character) ? character : '-')) + .Trim('-'); + + return $"gen-{ordinal}-{slug}"; + } + + private static IEnumerable BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList planets) + { + var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex); + var nodeCount = 4 + (generatedIndex % 4); + var oreAmount = 2800f + ((generatedIndex % 5) * 320f); + + for (var index = 0; index < nodeCount; index += 1) + { + yield return new ResourceNodeDefinition + { + SourceKind = "asteroid-belt", + Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), + RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f), + OreAmount = oreAmount, + ItemId = "ore", + ShardCount = 6 + (index % 4), + }; + } + } + + private static IEnumerable BuildGasCloudNodes(int generatedIndex, IReadOnlyList planets) + { + var gasAnchor = planets + .Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant") + .OrderByDescending((planet) => planet.OrbitRadius) + .FirstOrDefault(); + + if (gasAnchor is null) + { + yield break; + } + + var nodeCount = 2 + (generatedIndex % 3); + var gasAmount = 2200f + ((generatedIndex % 4) * 260f); + for (var index = 0; index < nodeCount; index += 1) + { + yield return new ResourceNodeDefinition + { + SourceKind = "gas-cloud", + Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f), + RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f), + OreAmount = gasAmount, + ItemId = "gas", + ShardCount = 10 + index, + }; + } + } + + private static float ResolveAsteroidBeltRadius(IReadOnlyList planets, int generatedIndex) + { + var gap = planets + .Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius)) + .OrderByDescending((entry) => entry.Gap) + .FirstOrDefault(); + + if (gap.Gap > 1f) + { + return gap.LeftOrbitRadius + (gap.Gap * 0.52f); + } + + return 420f + ((generatedIndex % 5) * 60f); + } + + private static List BuildGeneratedPlanets( + SolarSystemDefinition template, + int generatedIndex) + { + var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); + var planets = new List(planetCount); + var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f); + var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null; + + for (var index = 0; index < planetCount; index += 1) + { + var profile = SelectPlanetProfile(generatedIndex, index); + var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0 + ? sourcePlanets[index % sourcePlanets.Count] + : null; + + orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin)); + var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f); + var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f); + var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f); + + planets.Add(new PlanetDefinition + { + Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}", + PlanetType = profile.Type, + Shape = profile.Shape, + MoonCount = profile.BaseMoonCount + moonVariance, + OrbitRadius = orbitRadius, + OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)), + OrbitEccentricity = orbitEccentricity, + OrbitInclination = orbitInclination, + OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f, + OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f, + OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f, + Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f), + Color = templatePlanet?.Color ?? profile.Color, + Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f), + HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f, + }); + } + + return planets; + } + + private static StarProfile SelectStarProfile(int generatedIndex) + { + var value = Hash01(generatedIndex, 80); + return value switch + { + < 0.32f => StarProfiles[0], + < 0.54f => StarProfiles[1], + < 0.68f => StarProfiles[5], + < 0.8f => StarProfiles[2], + < 0.9f => StarProfiles[3], + < 0.97f => StarProfiles[6], + _ => StarProfiles[4], + }; + } + + private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex) + { + var value = Hash01(generatedIndex, 90 + planetIndex); + return value switch + { + < 0.14f => PlanetProfiles[7], + < 0.28f => PlanetProfiles[0], + < 0.46f => PlanetProfiles[3], + < 0.62f => PlanetProfiles[1], + < 0.74f => PlanetProfiles[2], + < 0.86f => PlanetProfiles[4], + < 0.94f => PlanetProfiles[6], + _ => PlanetProfiles[5], + }; + } + + private static string BuildPlanetBaseName(int generatedIndex, int planetIndex) + { + var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length] + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0]; + return source[..Math.Min(source.Length, 6)]; + } + + private static float ComputeSystemHeight(float radius, int generatedIndex, int salt) + { + var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f)); + var band = 220f + (normalized * 760f); + return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band; + } + + private static float Jitter(int index, int salt, float amplitude) => + (Hash01(index, salt) * 2f - 1f) * amplitude; + + private static float Hash01(int index, int salt) + { + uint value = (uint)(index + 1); + value ^= (uint)(salt + 0x9e3779b9); + value *= 0x85ebca6b; + value ^= value >> 13; + value *= 0xc2b2ae35; + value ^= value >> 16; + return (value & 0x00ffffff) / 16777215f; + } + + private sealed record StarProfile( + string Kind, + string StarColor, + string StarGlow, + float BaseSize, + int StarCount); + + private sealed record PlanetProfile( + string Type, + string Shape, + string Color, + float BaseSize, + float OrbitGapMin, + int BaseMoonCount, + bool CanHaveRing) + { + public float OrbitGapMax => OrbitGapMin + 44f; + } + + private static SolarSystemDefinition CreateSolSystem() + { + return new SolarSystemDefinition + { + Id = "sol", + Label = "Sol", + Position = [18200f, 24f, -11800f], + StarKind = "main-sequence", + StarCount = 1, + StarColor = "#fff1b8", + StarGlow = "#ffd35a", + StarSize = 58f, + GravityWellRadius = 240f, + AsteroidField = new AsteroidFieldDefinition + { + DecorationCount = 240, + RadiusOffset = 780f, + RadiusVariance = 180f, + HeightVariance = 22f, + }, + ResourceNodes = + [ + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 }, + ], + Planets = + [ + CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false), + CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false), + CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false), + CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false), + CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true), + CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true), + CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true), + CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true) + ], + }; + } + + private static PlanetDefinition CreateSolPlanet( + string label, + string planetType, + string shape, + int moonCount, + float orbitRadius, + float orbitSpeed, + float orbitEccentricity, + float orbitInclination, + float ascendingNode, + float argumentOfPeriapsis, + float phaseAtEpoch, + string color, + float tilt, + bool hasRing) + { + return new PlanetDefinition + { + Label = label, + PlanetType = planetType, + Shape = shape, + MoonCount = moonCount, + OrbitRadius = orbitRadius, + OrbitSpeed = orbitSpeed, + OrbitEccentricity = orbitEccentricity, + OrbitInclination = orbitInclination, + OrbitLongitudeOfAscendingNode = ascendingNode, + OrbitArgumentOfPeriapsis = argumentOfPeriapsis, + OrbitPhaseAtEpoch = phaseAtEpoch, + Size = planetType switch + { + "gas-giant" => label == "Saturn" ? 66f : 72f, + "ice-giant" => 48f, + _ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f, + }, + Color = color, + Tilt = tilt, + HasRing = hasRing, + }; + } +} diff --git a/apps/backend/Simulation/ScenarioLoader.Seeding.cs b/apps/backend/Simulation/ScenarioLoader.Seeding.cs new file mode 100644 index 0000000..b0c65a4 --- /dev/null +++ b/apps/backend/Simulation/ScenarioLoader.Seeding.cs @@ -0,0 +1,413 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class ScenarioLoader +{ + private static List CreateFactions( + IReadOnlyCollection stations, + IReadOnlyCollection ships) + { + var factionIds = stations + .Select((station) => station.FactionId) + .Concat(ships.Select((ship) => ship.FactionId)) + .Where((factionId) => !string.IsNullOrWhiteSpace(factionId)) + .Distinct(StringComparer.Ordinal) + .OrderBy((factionId) => factionId, StringComparer.Ordinal) + .ToList(); + + if (factionIds.Count == 0) + { + factionIds.Add(DefaultFactionId); + } + + return factionIds.Select(CreateFaction).ToList(); + } + + private static FactionRuntime CreateFaction(string factionId) + { + return factionId switch + { + DefaultFactionId => new FactionRuntime + { + Id = factionId, + Label = "Sol Dominion", + Color = "#7ed4ff", + Credits = MinimumFactionCredits, + }, + _ => new FactionRuntime + { + Id = factionId, + Label = ToFactionLabel(factionId), + Color = "#c7d2e0", + Credits = MinimumFactionCredits, + }, + }; + } + + private static void BootstrapFactionEconomy( + IReadOnlyCollection factions, + IReadOnlyCollection stations) + { + foreach (var faction in factions) + { + faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); + + var ownedStations = stations + .Where((station) => station.FactionId == faction.Id) + .ToList(); + + var refineries = ownedStations + .Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")) + .ToList(); + + if (refineries.Count > 0) + { + foreach (var refinery in refineries) + { + refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock); + } + + if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre)) + { + refineries[0].Inventory["ore"] = MinimumRefineryOre; + } + } + + foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard")) + { + shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock); + } + } + } + + private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + + 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(" ", + factionId + .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..])); + } + + private static DefaultBehaviorRuntime CreateBehavior( + ShipDefinition definition, + string systemId, + ScenarioDefinition scenario, + IReadOnlyDictionary> patrolRoutes, + StationRuntime? refinery) + { + if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "construct-station", + StationId = refinery.Id, + Phase = "travel-to-station", + }; + } + + if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "auto-harvest-gas", + StationId = refinery.Id, + Phase = "travel-to-node", + }; + } + + if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "auto-mine", + AreaSystemId = scenario.MiningDefaults.NodeSystemId, + StationId = refinery.Id, + Phase = "travel-to-node", + }; + } + + if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route)) + { + return new DefaultBehaviorRuntime + { + Kind = "patrol", + PatrolPoints = route, + PatrolIndex = 0, + }; + } + + return new DefaultBehaviorRuntime + { + Kind = "idle", + }; + } + + 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, + }; +} diff --git a/apps/backend/Simulation/ScenarioLoader.Spatial.cs b/apps/backend/Simulation/ScenarioLoader.Spatial.cs new file mode 100644 index 0000000..65d0126 --- /dev/null +++ b/apps/backend/Simulation/ScenarioLoader.Spatial.cs @@ -0,0 +1,217 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class ScenarioLoader +{ + 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: SpatialNodeKind.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: SpatialNodeKind.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: SpatialNodeKind.LagrangePoint, + 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: SpatialNodeKind.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, + SpatialNodeKind 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 == SpatialNodeKind.LagrangePoint) + .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 == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(node.OccupyingStructureId)) + ?? graph.Nodes.First((node) => node.Kind == SpatialNodeKind.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 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/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index db66935..5b11e3c 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -3,7 +3,7 @@ using SpaceGame.Simulation.Api.Data; namespace SpaceGame.Simulation.Api.Simulation; -public sealed class ScenarioLoader +public sealed partial class ScenarioLoader { private const string DefaultFactionId = "sol-dominion"; private const int TargetSystemCount = 160; @@ -149,7 +149,7 @@ public sealed class ScenarioLoader { Id = resourceNode.Id, SystemId = resourceNode.SystemId, - Kind = "resource-site", + Kind = SpatialNodeKind.ResourceSite, Position = resourceNode.Position, BubbleId = bubbleId, }); @@ -192,7 +192,7 @@ public sealed class ScenarioLoader { Id = stationNodeId, SystemId = station.SystemId, - Kind = "station", + Kind = SpatialNodeKind.Station, Position = station.Position, BubbleId = stationBubbleId, ParentNodeId = placement.AnchorNode.Id, @@ -258,7 +258,7 @@ public sealed class ScenarioLoader TargetPosition = position, SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes), DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), - ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = "pending" }, + ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, Health = definition.MaxHealth, }); @@ -310,1033 +310,6 @@ public sealed class ScenarioLoader }; } - private static List InjectSpecialSystems(IReadOnlyList authoredSystems) - { - var systems = authoredSystems - .Select(CloneSystemDefinition) - .ToList(); - - if (systems.All((system) => system.Id != "sol")) - { - systems.Add(CreateSolSystem()); - } - - return systems; - } - - private static List ExpandSystems(IReadOnlyList authoredSystems) - { - var systems = authoredSystems - .Select(CloneSystemDefinition) - .ToList(); - - if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0) - { - return systems; - } - - var existingIds = systems - .Select((system) => system.Id) - .ToHashSet(StringComparer.Ordinal); - var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count); - - for (var index = systems.Count; index < TargetSystemCount; index += 1) - { - var template = authoredSystems[index % authoredSystems.Count]; - var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; - var id = BuildGeneratedSystemId(name, index + 1); - while (!existingIds.Add(id)) - { - id = $"{id}-x"; - } - - systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count])); - } - - return systems; - } - - private static SolarSystemDefinition CreateGeneratedSystem( - SolarSystemDefinition template, - string label, - string id, - int generatedIndex, - Vector3 position) - { - var starProfile = SelectStarProfile(generatedIndex); - var planets = BuildGeneratedPlanets(template, generatedIndex); - - var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) - .Select((node) => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - }) - .ToList(); - - return new SolarSystemDefinition - { - Id = id, - Label = label, - Position = [position.X, position.Y, position.Z], - StarKind = starProfile.Kind, - StarCount = starProfile.StarCount, - StarColor = starProfile.StarColor, - StarGlow = starProfile.StarGlow, - StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f), - GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f), - AsteroidField = new AsteroidFieldDefinition - { - DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10), - RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f), - RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f), - HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f), - }, - ResourceNodes = resourceNodes, - Planets = planets, - }; - } - - private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition) - { - return new SolarSystemDefinition - { - Id = definition.Id, - Label = definition.Label, - Position = definition.Position.ToArray(), - StarKind = definition.StarKind, - StarCount = definition.StarCount, - StarColor = definition.StarColor, - StarGlow = definition.StarGlow, - StarSize = definition.StarSize, - GravityWellRadius = definition.GravityWellRadius, - AsteroidField = new AsteroidFieldDefinition - { - DecorationCount = definition.AsteroidField.DecorationCount, - RadiusOffset = definition.AsteroidField.RadiusOffset, - RadiusVariance = definition.AsteroidField.RadiusVariance, - HeightVariance = definition.AsteroidField.HeightVariance, - }, - ResourceNodes = definition.ResourceNodes - .Select((node) => new ResourceNodeDefinition - { - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - }) - .ToList(), - Planets = definition.Planets - .Select((planet) => new PlanetDefinition - { - Label = planet.Label, - PlanetType = planet.PlanetType, - Shape = planet.Shape, - MoonCount = planet.MoonCount, - OrbitRadius = planet.OrbitRadius, - OrbitSpeed = planet.OrbitSpeed, - OrbitEccentricity = planet.OrbitEccentricity, - OrbitInclination = planet.OrbitInclination, - OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, - OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, - OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, - Size = planet.Size, - Color = planet.Color, - Tilt = planet.Tilt, - HasRing = planet.HasRing, - }) - .ToList(), - }; - } - - private static List BuildProceduralResourceNodes( - SolarSystemDefinition template, - IReadOnlyList planets, - int generatedIndex) - { - var nodes = new List(); - if (template.ResourceNodes.Count > 0) - { - nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - })); - } - - nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets)); - nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets)); - return nodes; - } - - private static List BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) - { - var allPositions = occupiedPositions.ToList(); - var generated = new List(count); - - for (var index = 0; index < count; index += 1) - { - Vector3? accepted = null; - for (var attempt = 0; attempt < 64; attempt += 1) - { - var candidate = ComputeGeneratedSystemPosition(index, attempt); - if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) - { - accepted = candidate; - break; - } - } - - accepted ??= ComputeFallbackGeneratedSystemPosition(index); - generated.Add(accepted.Value); - allPositions.Add(accepted.Value); - } - - return generated; - } - - private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt) - { - const int armCount = 4; - const float baseInnerRadius = 9000f; - const float radiusStep = 540f; - const float armOffset = MathF.PI * 2f / armCount; - - var armIndex = (generatedIndex + attempt) % armCount; - var armDepth = generatedIndex / armCount; - var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f); - var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f); - var x = MathF.Cos(angle) * radius; - var z = MathF.Sin(angle) * radius * 0.58f; - var y = ComputeSystemHeight(radius, generatedIndex, attempt); - return new Vector3(x, y, z); - } - - private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex) - { - const int ringCount = 5; - const float fallbackRadius = 42000f; - var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f; - var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f; - return new Vector3( - MathF.Cos(angle) * radius, - ComputeSystemHeight(radius, generatedIndex, 99), - MathF.Sin(angle) * radius * 0.6f); - } - - private static string BuildGeneratedSystemId(string label, int ordinal) - { - var slug = string.Concat(label - .ToLowerInvariant() - .Select((character) => char.IsLetterOrDigit(character) ? character : '-')) - .Trim('-'); - - return $"gen-{ordinal}-{slug}"; - } - - private static IEnumerable BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList planets) - { - var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex); - var nodeCount = 4 + (generatedIndex % 4); - var oreAmount = 2800f + ((generatedIndex % 5) * 320f); - - for (var index = 0; index < nodeCount; index += 1) - { - yield return new ResourceNodeDefinition - { - SourceKind = "asteroid-belt", - Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), - RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f), - OreAmount = oreAmount, - ItemId = "ore", - ShardCount = 6 + (index % 4), - }; - } - } - - private static IEnumerable BuildGasCloudNodes(int generatedIndex, IReadOnlyList planets) - { - var gasAnchor = planets - .Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant") - .OrderByDescending((planet) => planet.OrbitRadius) - .FirstOrDefault(); - - if (gasAnchor is null) - { - yield break; - } - - var nodeCount = 2 + (generatedIndex % 3); - var gasAmount = 2200f + ((generatedIndex % 4) * 260f); - for (var index = 0; index < nodeCount; index += 1) - { - yield return new ResourceNodeDefinition - { - SourceKind = "gas-cloud", - Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f), - RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f), - OreAmount = gasAmount, - ItemId = "gas", - ShardCount = 10 + index, - }; - } - } - - private static float ResolveAsteroidBeltRadius(IReadOnlyList planets, int generatedIndex) - { - var gap = planets - .Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius)) - .OrderByDescending((entry) => entry.Gap) - .FirstOrDefault(); - - if (gap.Gap > 1f) - { - return gap.LeftOrbitRadius + (gap.Gap * 0.52f); - } - - return 420f + ((generatedIndex % 5) * 60f); - } - - private static List BuildGeneratedPlanets( - SolarSystemDefinition template, - int generatedIndex) - { - var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); - var planets = new List(planetCount); - var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f); - var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null; - - for (var index = 0; index < planetCount; index += 1) - { - var profile = SelectPlanetProfile(generatedIndex, index); - var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0 - ? sourcePlanets[index % sourcePlanets.Count] - : null; - - orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin)); - var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f); - var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f); - var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f); - - planets.Add(new PlanetDefinition - { - Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}", - PlanetType = profile.Type, - Shape = profile.Shape, - MoonCount = profile.BaseMoonCount + moonVariance, - OrbitRadius = orbitRadius, - OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)), - OrbitEccentricity = orbitEccentricity, - OrbitInclination = orbitInclination, - OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f, - OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f, - OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f, - Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f), - Color = templatePlanet?.Color ?? profile.Color, - Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f), - HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f, - }); - } - - return planets; - } - - private static StarProfile SelectStarProfile(int generatedIndex) - { - var value = Hash01(generatedIndex, 80); - return value switch - { - < 0.32f => StarProfiles[0], - < 0.54f => StarProfiles[1], - < 0.68f => StarProfiles[5], - < 0.8f => StarProfiles[2], - < 0.9f => StarProfiles[3], - < 0.97f => StarProfiles[6], - _ => StarProfiles[4], - }; - } - - private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex) - { - var value = Hash01(generatedIndex, 90 + planetIndex); - return value switch - { - < 0.14f => PlanetProfiles[7], - < 0.28f => PlanetProfiles[0], - < 0.46f => PlanetProfiles[3], - < 0.62f => PlanetProfiles[1], - < 0.74f => PlanetProfiles[2], - < 0.86f => PlanetProfiles[4], - < 0.94f => PlanetProfiles[6], - _ => PlanetProfiles[5], - }; - } - - private static string BuildPlanetBaseName(int generatedIndex, int planetIndex) - { - var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length] - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0]; - return source[..Math.Min(source.Length, 6)]; - } - - private static float ComputeSystemHeight(float radius, int generatedIndex, int salt) - { - var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f)); - var band = 220f + (normalized * 760f); - return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band; - } - - private static float Jitter(int index, int salt, float amplitude) => - (Hash01(index, salt) * 2f - 1f) * amplitude; - - private static float Hash01(int index, int salt) - { - uint value = (uint)(index + 1); - value ^= (uint)(salt + 0x9e3779b9); - value *= 0x85ebca6b; - value ^= value >> 13; - value *= 0xc2b2ae35; - value ^= value >> 16; - return (value & 0x00ffffff) / 16777215f; - } - - private sealed record StarProfile( - string Kind, - string StarColor, - string StarGlow, - float BaseSize, - int StarCount); - - private sealed record PlanetProfile( - string Type, - string Shape, - string Color, - float BaseSize, - float OrbitGapMin, - int BaseMoonCount, - bool CanHaveRing) - { - public float OrbitGapMax => OrbitGapMin + 44f; - } - - private static SolarSystemDefinition CreateSolSystem() - { - return new SolarSystemDefinition - { - Id = "sol", - Label = "Sol", - Position = [18200f, 24f, -11800f], - StarKind = "main-sequence", - StarCount = 1, - StarColor = "#fff1b8", - StarGlow = "#ffd35a", - StarSize = 58f, - GravityWellRadius = 240f, - AsteroidField = new AsteroidFieldDefinition - { - DecorationCount = 240, - RadiusOffset = 780f, - RadiusVariance = 180f, - HeightVariance = 22f, - }, - ResourceNodes = - [ - new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, - new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, - new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, - new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, - new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, - new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, - new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 }, - ], - Planets = - [ - CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false), - CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false), - CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false), - CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false), - CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true), - CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true), - CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true), - CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true) - ], - }; - } - - private static PlanetDefinition CreateSolPlanet( - string label, - string planetType, - string shape, - int moonCount, - float orbitRadius, - float orbitSpeed, - float orbitEccentricity, - float orbitInclination, - float ascendingNode, - float argumentOfPeriapsis, - float phaseAtEpoch, - string color, - float tilt, - bool hasRing) - { - return new PlanetDefinition - { - Label = label, - PlanetType = planetType, - Shape = shape, - MoonCount = moonCount, - OrbitRadius = orbitRadius, - OrbitSpeed = orbitSpeed, - OrbitEccentricity = orbitEccentricity, - OrbitInclination = orbitInclination, - OrbitLongitudeOfAscendingNode = ascendingNode, - OrbitArgumentOfPeriapsis = argumentOfPeriapsis, - OrbitPhaseAtEpoch = phaseAtEpoch, - Size = planetType switch - { - "gas-giant" => label == "Saturn" ? 66f : 72f, - "ice-giant" => 48f, - _ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f, - }, - Color = color, - Tilt = tilt, - HasRing = hasRing, - }; - } - - private static List CreateFactions( - IReadOnlyCollection stations, - IReadOnlyCollection ships) - { - var factionIds = stations - .Select((station) => station.FactionId) - .Concat(ships.Select((ship) => ship.FactionId)) - .Where((factionId) => !string.IsNullOrWhiteSpace(factionId)) - .Distinct(StringComparer.Ordinal) - .OrderBy((factionId) => factionId, StringComparer.Ordinal) - .ToList(); - - if (factionIds.Count == 0) - { - factionIds.Add(DefaultFactionId); - } - - return factionIds.Select(CreateFaction).ToList(); - } - - private static FactionRuntime CreateFaction(string factionId) - { - return factionId switch - { - DefaultFactionId => new FactionRuntime - { - Id = factionId, - Label = "Sol Dominion", - Color = "#7ed4ff", - Credits = MinimumFactionCredits, - }, - _ => new FactionRuntime - { - Id = factionId, - Label = ToFactionLabel(factionId), - Color = "#c7d2e0", - Credits = MinimumFactionCredits, - }, - }; - } - - private static void BootstrapFactionEconomy( - IReadOnlyCollection factions, - IReadOnlyCollection stations) - { - foreach (var faction in factions) - { - faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); - - var ownedStations = stations - .Where((station) => station.FactionId == faction.Id) - .ToList(); - - var refineries = ownedStations - .Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")) - .ToList(); - - if (refineries.Count > 0) - { - foreach (var refinery in refineries) - { - refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock); - } - - if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre)) - { - refineries[0].Inventory["ore"] = MinimumRefineryOre; - } - } - - foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard")) - { - shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock); - } - } - } - - 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(" ", - factionId - .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..])); - } - private T Read(string fileName) { var path = Path.Combine(_dataRoot, fileName); @@ -1345,60 +318,6 @@ public sealed class ScenarioLoader ?? throw new InvalidOperationException($"Unable to read {fileName}."); } - private static DefaultBehaviorRuntime CreateBehavior( - ShipDefinition definition, - string systemId, - ScenarioDefinition scenario, - IReadOnlyDictionary> patrolRoutes, - StationRuntime? refinery) - { - if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null) - { - return new DefaultBehaviorRuntime - { - Kind = "construct-station", - StationId = refinery.Id, - Phase = "travel-to-station", - }; - } - - if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null) - { - return new DefaultBehaviorRuntime - { - Kind = "auto-harvest-gas", - StationId = refinery.Id, - Phase = "travel-to-node", - }; - } - - if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null) - { - return new DefaultBehaviorRuntime - { - Kind = "auto-mine", - AreaSystemId = scenario.MiningDefaults.NodeSystemId, - StationId = refinery.Id, - Phase = "travel-to-node", - }; - } - - if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route)) - { - return new DefaultBehaviorRuntime - { - Kind = "patrol", - PatrolPoints = route, - PatrolIndex = 0, - }; - } - - return new DefaultBehaviorRuntime - { - Kind = "idle", - }; - } - private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) @@ -1439,36 +358,6 @@ public sealed class ScenarioLoader 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); @@ -1484,13 +373,4 @@ public sealed class ScenarioLoader 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.MovementSystem.cs b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs new file mode 100644 index 0000000..92798f1 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs @@ -0,0 +1,276 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var task = ship.ControllerTask; + return task.Kind switch + { + "idle" => UpdateIdle(ship, world, deltaSeconds), + "travel" => UpdateTravel(ship, world, deltaSeconds), + "extract" => UpdateExtract(ship, world, deltaSeconds), + "dock" => UpdateDock(ship, world, deltaSeconds), + "unload" => UpdateUnload(ship, world, deltaSeconds), + "refuel" => UpdateRefuel(ship, world, deltaSeconds), + "deliver-construction" => UpdateDeliverConstruction(ship, world, deltaSeconds), + "build-construction-site" => UpdateBuildConstructionSite(ship, world, deltaSeconds), + "load-workers" => UpdateLoadWorkers(ship, world, deltaSeconds), + "unload-workers" => UpdateUnloadWorkers(ship, world, deltaSeconds), + "construct-module" => UpdateConstructModule(ship, world, deltaSeconds), + "undock" => UpdateUndock(ship, world, deltaSeconds), + _ => UpdateIdle(ship, world, deltaSeconds), + }; + } + + private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var task = ship.ControllerTask; + if (task.TargetPosition is null || task.TargetSystemId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + 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 = targetPosition; + ship.TargetPosition = ship.Position; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentNodeId = targetNode?.Id; + ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; + ship.State = "arriving"; + return "arrived"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + 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"; + } +} diff --git a/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs b/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs new file mode 100644 index 0000000..99592ee --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs @@ -0,0 +1,256 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + 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; + } + } + + for (var moonIndex = 0; moonIndex < planet.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.Where(ship => ship.DockedStationId is not null)) + { + 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 readonly record struct LagrangePointPlacement(string Designation, Vector3 Position); +} diff --git a/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs b/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs new file mode 100644 index 0000000..41f3a3d --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs @@ -0,0 +1,237 @@ +using SpaceGame.Simulation.Api.Contracts; +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial 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, ICollection events) + { + foreach (var station in world.Stations) + { + var previousEnergy = station.EnergyStored; + GenerateStationEnergy(station, deltaSeconds); + + if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) + { + events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); + } + } + } + + private static void UpdateShipPower(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ICollection events) + { + var previousEnergy = ship.EnergyStored; + GenerateShipEnergy(ship, world, deltaSeconds); + + if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); + } + } + + private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) + { + var powerCores = CountModules(station.InstalledModules, "power-core"); + var tanks = CountModules(station.InstalledModules, "liquid-tank"); + if (powerCores <= 0 || tanks <= 0) + { + station.EnergyStored = 0f; + station.Inventory.Remove("fuel"); + return; + } + + var energyCapacity = powerCores * StationEnergyPerPowerCore; + var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); + var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); + if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) + { + station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); + station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); + return; + } + + var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds); + var requiredFuel = generated / StationFuelToEnergyRatio; + var consumedFuel = MathF.Min(requiredFuel, fuelStored); + var actualGenerated = consumedFuel * StationFuelToEnergyRatio; + + RemoveInventory(station.Inventory, "fuel", consumedFuel); + station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); + } + + private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var reactors = CountModules(ship.Definition.Modules, "reactor-core"); + var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank"); + if (reactors <= 0 || capacitors <= 0) + { + ship.EnergyStored = 0f; + ship.Inventory.Remove("fuel"); + return; + } + + var energyCapacity = capacitors * CapacitorEnergyPerModule; + var fuelCapacity = reactors * ShipFuelPerReactor; + var fuelStored = GetInventoryAmount(ship.Inventory, "fuel"); + var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored); + if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) + { + ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity); + ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity); + return; + } + + var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds); + var requiredFuel = generated / ShipFuelToEnergyRatio; + var consumedFuel = MathF.Min(requiredFuel, fuelStored); + var actualGenerated = consumedFuel * ShipFuelToEnergyRatio; + + RemoveInventory(ship.Inventory, "fuel", consumedFuel); + ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated); + } + + private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount) + { + if (ship.EnergyStored + 0.0001f < amount) + { + return false; + } + + ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount); + return true; + } + + private static bool TryConsumeStationEnergy(StationRuntime station, float amount) + { + if (station.EnergyStored + 0.0001f < amount) + { + return false; + } + + station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount); + return true; + } + + private static int CountModules(IEnumerable modules, string moduleId) => + modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); + + private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + + private static void AddInventory(IDictionary inventory, string itemId, float amount) + { + if (amount <= 0f) + { + return; + } + + inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; + } + + private static float RemoveInventory(IDictionary inventory, string itemId, float amount) + { + var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); + var removed = MathF.Min(current, amount); + var remaining = current - removed; + if (remaining <= 0.001f) + { + inventory.Remove(itemId); + } + else + { + inventory[itemId] = remaining; + } + + return removed; + } + + private static bool HasStationModules(StationRuntime station, params string[] modules) => + modules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); + + private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) => + node.ItemId switch + { + "ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"), + "gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"), + _ => false, + }; + + private static float GetShipFuelCapacity(ShipRuntime ship) => + CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; + + internal 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 + { + "bulk-solid" => "bulk-bay", + "bulk-liquid" => "liquid-tank", + "bulk-gas" => "gas-tank", + _ => null, + }; + + private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) + { + if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + return 0f; + } + + var storageClass = itemDefinition.Storage; + var requiredModule = GetStorageRequirement(storageClass); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + { + return 0f; + } + + if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity)) + { + return 0f; + } + + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass) + .Sum(entry => entry.Value); + var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); + if (accepted <= 0.01f) + { + return 0f; + } + + AddInventory(station.Inventory, itemId, accepted); + return accepted; + } + + 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); +} diff --git a/apps/backend/Simulation/SimulationEngine.Replication.cs b/apps/backend/Simulation/SimulationEngine.Replication.cs new file mode 100644 index 0000000..07466b7 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.Replication.cs @@ -0,0 +1,680 @@ +using SpaceGame.Simulation.Api.Contracts; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) + { + PrimeDeltaBaseline(world); + + return new WorldSnapshot( + world.Label, + world.Seed, + sequence, + world.TickIntervalMs, + world.GeneratedAtUtc, + world.Systems.Select(system => new SystemSnapshot( + system.Definition.Id, + system.Definition.Label, + ToDto(system.Position), + system.Definition.StarKind, + system.Definition.StarCount, + system.Definition.StarColor, + system.Definition.StarSize, + system.Definition.Planets.Select(planet => new PlanetSnapshot( + planet.Label, + planet.PlanetType, + planet.Shape, + planet.MoonCount, + planet.OrbitRadius, + planet.OrbitSpeed, + planet.OrbitEccentricity, + planet.OrbitInclination, + planet.OrbitLongitudeOfAscendingNode, + planet.OrbitArgumentOfPeriapsis, + planet.OrbitPhaseAtEpoch, + 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, + node.LocalPosition, + node.SourceKind, + node.OreRemaining, + node.MaxOre, + node.ItemId)).ToList(), + world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot( + station.Id, + station.Label, + station.Category, + station.SystemId, + station.LocalPosition, + station.NodeId, + station.BubbleId, + station.AnchorNodeId, + station.Color, + station.DockedShips, + station.DockingPads, + station.EnergyStored, + station.Inventory, + 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, + ship.Role, + ship.ShipClass, + ship.SystemId, + ship.LocalPosition, + ship.LocalVelocity, + ship.TargetLocalPosition, + ship.State, + 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, + 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, + faction.DefaultPolicySetId)).ToList()); + } + + public void PrimeDeltaBaseline(SimulationWorld world) + { + foreach (var node in world.Nodes) + { + 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); + } + + foreach (var faction in world.Factions) + { + faction.LastDeltaSignature = BuildFactionSignature(faction); + } + } + + private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var node in world.Nodes) + { + var signature = BuildNodeSignature(node); + if (signature == node.LastDeltaSignature) + { + continue; + } + + node.LastDeltaSignature = signature; + deltas.Add(ToNodeDelta(node)); + } + + return deltas; + } + + private static IReadOnlyList 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(); + foreach (var station in world.Stations) + { + var signature = BuildStationSignature(station); + if (signature == station.LastDeltaSignature) + { + continue; + } + + station.LastDeltaSignature = signature; + deltas.Add(ToStationDelta(station)); + } + + return deltas; + } + + private static IReadOnlyList BuildClaimDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var claim in world.Claims) + { + var signature = BuildClaimSignature(claim); + if (signature == claim.LastDeltaSignature) + { + continue; + } + + claim.LastDeltaSignature = signature; + deltas.Add(ToClaimDelta(claim)); + } + + return deltas; + } + + private static IReadOnlyList BuildConstructionSiteDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var site in world.ConstructionSites) + { + var signature = BuildConstructionSiteSignature(site); + if (signature == site.LastDeltaSignature) + { + continue; + } + + site.LastDeltaSignature = signature; + deltas.Add(ToConstructionSiteDelta(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(); + foreach (var ship in world.Ships) + { + var signature = BuildShipSignature(ship); + if (signature == ship.LastDeltaSignature) + { + continue; + } + + ship.LastDeltaSignature = signature; + deltas.Add(ToShipDelta(ship)); + } + + return deltas; + } + + private static IReadOnlyList BuildFactionDeltas(SimulationWorld world) + { + var deltas = new List(); + foreach (var faction in world.Factions) + { + var signature = BuildFactionSignature(faction); + if (signature == faction.LastDeltaSignature) + { + continue; + } + + faction.LastDeltaSignature = signature; + deltas.Add(ToFactionDelta(faction)); + } + + return deltas; + } + + private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}"; + + private static string BuildSpatialNodeSignature(NodeRuntime node) => + $"{node.SystemId}|{node.Kind.ToContractValue()}|{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 string BuildStationSignature(StationRuntime station) => + $"{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("|", + ship.SystemId, + ship.Position.X.ToString("0.###"), + ship.Position.Y.ToString("0.###"), + ship.Position.Z.ToString("0.###"), + ship.Velocity.X.ToString("0.###"), + ship.Velocity.Y.ToString("0.###"), + ship.Velocity.Z.ToString("0.###"), + ship.TargetPosition.X.ToString("0.###"), + ship.TargetPosition.Y.ToString("0.###"), + ship.TargetPosition.Z.ToString("0.###"), + ship.State, + 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.###"), + ship.Health.ToString("0.###")); + + private static string BuildInventorySignature(IReadOnlyDictionary inventory) => + string.Join(",", + inventory + .Where(entry => entry.Value > 0.001f) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => $"{entry.Key}:{entry.Value:0.###}")); + + private static string BuildFactionSignature(FactionRuntime faction) => + $"{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, + node.SystemId, + ToDto(node.Position), + node.SourceKind, + node.OreRemaining, + node.MaxOre, + node.ItemId); + + private static SpatialNodeDelta ToSpatialNodeDelta(NodeRuntime node) => new( + node.Id, + node.SystemId, + node.Kind.ToContractValue(), + 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.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, + ship.Definition.Label, + ship.Definition.Role, + ship.Definition.ShipClass, + ship.SystemId, + ToDto(ship.Position), + ToDto(ship.Velocity), + ToDto(ship.TargetPosition), + ship.State, + 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(), + ToShipSpatialStateSnapshot(ship.SpatialState)); + + private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => + inventory + .Where(entry => entry.Value > 0.001f) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => new InventoryEntry(entry.Key, entry.Value)) + .ToList(); + + private static FactionDelta ToFactionDelta(FactionRuntime faction) => new( + faction.Id, + faction.Label, + faction.Color, + faction.Credits, + faction.PopulationTotal, + faction.OreMined, + faction.GoodsProduced, + faction.ShipsBuilt, + 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, + string previousState, + string previousBehavior, + string previousTask, + string controllerEvent, + ICollection events) + { + var occurredAtUtc = DateTimeOffset.UtcNow; + + if (previousState != ship.State) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc)); + } + + if (previousBehavior != ship.DefaultBehavior.Kind) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc)); + } + + if (previousTask != ship.ControllerTask.Kind) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc)); + } + + if (controllerEvent != "none") + { + events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc)); + } + } + + private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); +} diff --git a/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs b/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs new file mode 100644 index 0000000..79eac18 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs @@ -0,0 +1,262 @@ +using SpaceGame.Simulation.Api.Contracts; +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + 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)) + { + return true; + } + + if (station.ActiveConstruction is not null) + { + return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) + && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); + } + + if (!CanStartModuleConstruction(station, recipe)) + { + return false; + } + + foreach (var input in recipe.Inputs) + { + RemoveInventory(station.Inventory, input.ItemId, input.Amount); + } + + station.ActiveConstruction = new ModuleConstructionRuntime + { + ModuleId = recipe.ModuleId, + RequiredSeconds = recipe.Duration, + AssignedConstructorShipId = shipId, + }; + + return true; + } + + private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) + { + foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) + { + if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) + && world.ModuleRecipes.ContainsKey(moduleId)) + { + return moduleId; + } + } + + 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; + + private static int? ReserveDockingPad(StationRuntime station, string shipId) + { + if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing + && !string.IsNullOrEmpty(existing.Value)) + { + return existing.Key; + } + + var padCount = GetDockingPadCount(station); + for (var padIndex = 0; padIndex < padCount; padIndex += 1) + { + if (station.DockingPadAssignments.ContainsKey(padIndex)) + { + continue; + } + + station.DockingPadAssignments[padIndex] = shipId; + return padIndex; + } + + return null; + } + + private static void ReleaseDockingPad(StationRuntime station, string shipId) + { + var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); + if (!string.IsNullOrEmpty(assignment.Value)) + { + station.DockingPadAssignments.Remove(assignment.Key); + } + } + + private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) + { + var padCount = Math.Max(1, GetDockingPadCount(station)); + var angle = ((MathF.PI * 2f) / padCount) * padIndex; + var radius = station.Definition.Radius + 14f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + private static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + var radius = station.Definition.Radius + 34f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + private static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) + { + if (padIndex is null) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var pad = GetDockingPadPosition(station, padIndex.Value); + var dx = pad.X - station.Position.X; + var dz = pad.Z - station.Position.Z; + var length = MathF.Sqrt((dx * dx) + (dz * dz)); + if (length <= 0.001f) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var scale = distance / length; + return new Vector3( + pad.X + (dx * scale), + station.Position.Y, + pad.Z + (dz * scale)); + } + + private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => + ship.AssignedDockingPadIndex is int padIndex + ? GetDockingPadPosition(station, padIndex) + : station.Position; +} diff --git a/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs b/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs new file mode 100644 index 0000000..c9ca419 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs @@ -0,0 +1,519 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds) + { + ship.ActionTimer += deltaSeconds; + if (ship.ActionTimer < requiredSeconds) + { + return false; + } + + ship.ActionTimer = 0f; + return true; + } + + internal static float GetShipCargoAmount(ShipRuntime ship) + { + var cargoItemId = ship.Definition.CargoItemId; + return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId); + } + + private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var task = ship.ControllerTask; + var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); + if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node)) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.TargetPosition = task.TargetPosition.Value; + var distance = ship.Position.DistanceTo(task.TargetPosition.Value); + if (distance > task.Threshold) + { + ship.ActionTimer = 0f; + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "mining-approach"; + ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "mining"; + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) + { + return "none"; + } + + var cargoAmount = GetShipCargoAmount(ship); + var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount); + mined = MathF.Min(mined, node.OreRemaining); + if (ship.Definition.CargoItemId is not null) + { + AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined); + } + + node.OreRemaining -= mined; + if (node.OreRemaining <= 0f) + { + node.OreRemaining = node.MaxOre; + } + + return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; + } + + private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var task = ship.ControllerTask; + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); + if (station is null || task.TargetPosition is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); + if (padIndex is null) + { + ship.ActionTimer = 0f; + ship.State = "awaiting-dock"; + ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); + var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); + if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds); + } + + return "none"; + } + + ship.AssignedDockingPadIndex = padIndex; + var padPosition = GetDockingPadPosition(station, padIndex.Value); + ship.TargetPosition = padPosition; + var distance = ship.Position.DistanceTo(padPosition); + if (distance > 4f) + { + ship.ActionTimer = 0f; + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "docking-approach"; + ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds); + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "docking"; + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) + { + return "none"; + } + + ship.State = "docked"; + ship.DockedStationId = station.Id; + station.DockedShipIds.Add(ship.Id); + ship.Position = padPosition; + ship.TargetPosition = padPosition; + return "docked"; + } + + private string UpdateUnload(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); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + 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 = "transferring"; + var cargoItemId = ship.Definition.CargoItemId; + var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds); + if (cargoItemId is not null) + { + var accepted = TryAddStationInventory(world, station, cargoItemId, moved); + RemoveInventory(ship.Inventory, cargoItemId, accepted); + moved = accepted; + } + + var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId); + if (faction is not null && cargoItemId == "ore") + { + faction.OreMined += moved; + faction.Credits += moved * 0.4f; + } + + return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; + } + + private string UpdateRefuel(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); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + 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 = "refueling"; + var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel")); + var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); + if (moved > 0.01f) + { + RemoveInventory(station.Inventory, "fuel", moved); + AddInventory(ship.Inventory, "fuel", moved); + } + + return !NeedsRefuel(ship) ? "refueled" : "none"; + } + + private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); + if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) + { + ship.AssignedDockingPadIndex = null; + ship.State = "idle"; + ship.TargetPosition = ship.Position; + 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"; + } + + if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) + { + ship.ActionTimer = 0f; + ship.State = "waiting-materials"; + ship.TargetPosition = GetShipDockedPosition(ship, station); + return "none"; + } + + if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) + { + ship.State = "construction-blocked"; + ship.TargetPosition = GetShipDockedPosition(ship, station); + return "none"; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = "constructing"; + station.ActiveConstruction.ProgressSeconds += deltaSeconds; + if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) + { + return "none"; + } + + station.InstalledModules.Add(station.ActiveConstruction.ModuleId); + station.ActiveConstruction = null; + 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; + if (ship.DockedStationId is null || task.TargetPosition is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); + var undockTarget = station is null + ? task.TargetPosition.Value + : GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + ship.TargetPosition = undockTarget; + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "undocking"; + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) + { + if (station is not null) + { + ship.Position = GetShipDockedPosition(ship, station); + } + + return "none"; + } + + ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); + if (ship.Position.DistanceTo(undockTarget) > task.Threshold) + { + return "none"; + } + + if (station is not null) + { + station.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(station, ship.Id); + } + + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return "undocked"; + } +} diff --git a/apps/backend/Simulation/SimulationEngine.ShipControl.cs b/apps/backend/Simulation/SimulationEngine.ShipControl.cs new file mode 100644 index 0000000..53ff569 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.ShipControl.cs @@ -0,0 +1,483 @@ +using SpaceGame.Simulation.Api.Data; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + 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 == OrderStatus.Queued) + { + ship.Order.Status = OrderStatus.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 + { + Kind = "travel", + Status = WorkStatus.Active, + CommanderId = commander?.Id, + TargetSystemId = ship.Order.DestinationSystemId, + TargetNodeId = ship.SpatialState.DestinationNodeId, + TargetPosition = ship.Order.DestinationPosition, + Threshold = world.Balance.ArrivalThreshold, + }; + SyncCommanderTask(commander, ship.ControllerTask); + return; + } + + _shipBehaviorStateMachine.Plan(this, ship, world); + SyncCommanderTask(commander, ship.ControllerTask); + } + + internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) + { + var behavior = ship.DefaultBehavior; + 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) + .OrderByDescending(candidate => candidate.OreRemaining) + .FirstOrDefault() + : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId); + + if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) + { + behavior.Kind = "idle"; + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + behavior.NodeId ??= node.Id; + if (ship.DockedStationId == refinery.Id) + { + if (GetShipCargoAmount(ship) > 0.01f) + { + behavior.Phase = "unload"; + } + else if (NeedsRefuel(ship)) + { + behavior.Phase = "refuel"; + } + else if (behavior.Phase is "dock" or "unload" or "refuel") + { + behavior.Phase = "undock"; + } + } + else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") + { + behavior.Phase = "travel-to-station"; + } + + switch (behavior.Phase) + { + case "extract": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "extract", + TargetEntityId = node.Id, + TargetSystemId = node.SystemId, + TargetPosition = node.Position, + Threshold = 14f, + }; + break; + case "travel-to-station": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "travel", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = refinery.Position, + Threshold = refinery.Definition.Radius + 8f, + }; + break; + case "dock": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "dock", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = refinery.Position, + Threshold = refinery.Definition.Radius + 4f, + }; + break; + case "unload": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "unload", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = refinery.Position, + Threshold = 0f, + }; + break; + case "refuel": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "refuel", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = refinery.Position, + Threshold = 0f, + }; + break; + case "undock": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "undock", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), + Threshold = 8f, + }; + break; + default: + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "travel", + TargetEntityId = node.Id, + TargetSystemId = node.SystemId, + TargetPosition = node.Position, + Threshold = 18f, + }; + behavior.Phase = "travel-to-node"; + break; + } + } + + internal 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; + } + + internal 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"; + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); + behavior.ModuleId = moduleId; + if (moduleId is null) + { + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + if (ship.DockedStationId == station.Id) + { + if (NeedsRefuel(ship)) + { + 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"; + } + else + { + behavior.Phase = "wait-for-materials"; + } + } + else if (behavior.Phase is not "travel-to-station" and not "dock") + { + behavior.Phase = "travel-to-station"; + } + + switch (behavior.Phase) + { + case "dock": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "dock", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = station.Definition.Radius + 4f, + }; + break; + case "refuel": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "refuel", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + case "construct-module": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "construct-module", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + 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 + { + Kind = "idle", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + default: + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "travel", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = station.Definition.Radius + 8f, + }; + behavior.Phase = "travel-to-station"; + break; + } + } + + 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 = WorkStatus.Completed, + TargetSystemId = ship.SystemId, + Threshold = 0f, + }; + } + + return; + } + + _shipBehaviorStateMachine.ApplyEvent(this, ship, world, controllerEvent); + if (commander is not null) + { + SyncShipToCommander(ship, commander); + if (commander.ActiveTask is not null) + { + commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed; + } + } + } + + private static void TrackHistory(ShipRuntime ship) + { + var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}"; + if (signature == ship.LastSignature) + { + return; + } + + ship.LastSignature = signature; + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}"); + if (ship.History.Count > 18) + { + ship.History.RemoveAt(0); + } + } + + private static ControllerTaskRuntime CreateIdleTask(float threshold) => + new() + { + Kind = "idle", + Threshold = threshold, + }; + + private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task) + { + if (commander is null) + { + return; + } + + commander.ActiveTask = new CommanderTaskRuntime + { + Kind = task.Kind, + Status = task.Status, + TargetEntityId = task.TargetEntityId, + TargetNodeId = task.TargetNodeId, + TargetPosition = task.TargetPosition, + TargetSystemId = task.TargetSystemId, + Threshold = task.Threshold, + }; + } +} diff --git a/apps/backend/Simulation/SimulationEngine.StationSystems.cs b/apps/backend/Simulation/SimulationEngine.StationSystems.cs new file mode 100644 index 0000000..c97dba4 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.StationSystems.cs @@ -0,0 +1,263 @@ +using SpaceGame.Simulation.Api.Data; +using SpaceGame.Simulation.Api.Contracts; + +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed partial class SimulationEngine +{ + private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) + { + var factionPopulation = new Dictionary(StringComparer.Ordinal); + foreach (var station in world.Stations) + { + 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) + { + station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); + } + } + else if (station.Population > 0f) + { + var previous = station.Population; + station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds)); + if (MathF.Floor(previous) > MathF.Floor(station.Population)) + { + events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Definition.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); + } + } + + station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); + } + + 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; + 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) => + 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.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId))) + { + order.RemainingAmount = 0f; + order.State = MarketOrderStateKinds.Cancelled; + } + } + + private static bool HasRefineryCapability(StationRuntime station) => + HasStationModules(station, "refinery-stack", "power-core", "bulk-bay"); + + private static bool CanProcessFuel(StationRuntime station) => + HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); + + private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); +} diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index c34242e..a4c5a9e 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -1,3144 +1,100 @@ -using SpaceGame.Simulation.Api.Data; using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; -public sealed class SimulationEngine +public sealed partial class SimulationEngine { - private const float ShipFuelToEnergyRatio = 12f; - private const float StationFuelToEnergyRatio = 18f; - private const float CapacitorEnergyPerModule = 120f; - 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; + private const float ShipFuelToEnergyRatio = 12f; + private const float StationFuelToEnergyRatio = 18f; + private const float CapacitorEnergyPerModule = 120f; + 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; + private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault(); + private static readonly IReadOnlyList _worldUpdatePipeline = + [ + new((engine, world, deltaSeconds, nowUtc, events) => UpdateOrbitalState(world, nowUtc)), + new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)), + new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)), + new((engine, world, deltaSeconds, nowUtc, events) => UpdateStationPower(world, deltaSeconds, events)), + new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)), + ]; + private static readonly IReadOnlyList _shipUpdatePipeline = + [ + new((engine, ship, world, deltaSeconds, events) => UpdateShipPower(ship, world, deltaSeconds, events)), + new((engine, ship, world, deltaSeconds, events) => engine.RefreshControlLayers(ship, world)), + new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)), + ]; - 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); - - foreach (var ship in world.Ships) + public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { - var previousPosition = ship.Position; - var previousState = ship.State; - var previousBehavior = ship.DefaultBehavior.Kind; - var previousTask = ship.ControllerTask.Kind; + var events = new List(); + var nowUtc = DateTimeOffset.UtcNow; - UpdateShipPower(ship, world, deltaSeconds, events); - RefreshControlLayers(ship, world); - PlanControllerTask(ship, world); - var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); - AdvanceControlState(ship, world, controllerEvent); - ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); - TrackHistory(ship); - - EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); - } - - SyncSpatialState(world); - world.GeneratedAtUtc = nowUtc; - - return new WorldDelta( - sequence, - world.TickIntervalMs, - world.GeneratedAtUtc, - false, - events, - BuildSpatialNodeDeltas(world), - BuildLocalBubbleDeltas(world), - BuildNodeDeltas(world), - BuildStationDeltas(world), - BuildClaimDeltas(world), - BuildConstructionSiteDeltas(world), - BuildMarketOrderDeltas(world), - BuildPolicyDeltas(world), - BuildShipDeltas(world), - BuildFactionDeltas(world)); - } - - public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) - { - PrimeDeltaBaseline(world); - - return new WorldSnapshot( - world.Label, - world.Seed, - sequence, - world.TickIntervalMs, - world.GeneratedAtUtc, - world.Systems.Select((system) => new SystemSnapshot( - system.Definition.Id, - system.Definition.Label, - ToDto(system.Position), - system.Definition.StarKind, - system.Definition.StarCount, - system.Definition.StarColor, - system.Definition.StarSize, - system.Definition.Planets.Select((planet) => new PlanetSnapshot( - planet.Label, - planet.PlanetType, - planet.Shape, - planet.MoonCount, - planet.OrbitRadius, - planet.OrbitSpeed, - planet.OrbitEccentricity, - planet.OrbitInclination, - planet.OrbitLongitudeOfAscendingNode, - planet.OrbitArgumentOfPeriapsis, - planet.OrbitPhaseAtEpoch, - 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, - node.LocalPosition, - node.SourceKind, - node.OreRemaining, - node.MaxOre, - node.ItemId)).ToList(), - world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot( - station.Id, - station.Label, - station.Category, - station.SystemId, - station.LocalPosition, - station.NodeId, - station.BubbleId, - station.AnchorNodeId, - station.Color, - station.DockedShips, - station.DockingPads, - station.EnergyStored, - station.Inventory, - 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, - ship.Role, - ship.ShipClass, - ship.SystemId, - ship.LocalPosition, - ship.LocalVelocity, - ship.TargetLocalPosition, - ship.State, - 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, - 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, - faction.DefaultPolicySetId)).ToList()); - } - - public void PrimeDeltaBaseline(SimulationWorld world) - { - foreach (var node in world.Nodes) - { - 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); - } - - foreach (var faction in world.Factions) - { - faction.LastDeltaSignature = BuildFactionSignature(faction); - } - } - - private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var node in world.Nodes) - { - var signature = BuildNodeSignature(node); - if (signature == node.LastDeltaSignature) - { - continue; - } - - node.LastDeltaSignature = signature; - deltas.Add(ToNodeDelta(node)); - } - - return deltas; - } - - private static IReadOnlyList 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(); - foreach (var station in world.Stations) - { - var signature = BuildStationSignature(station); - if (signature == station.LastDeltaSignature) - { - continue; - } - - station.LastDeltaSignature = signature; - deltas.Add(ToStationDelta(station)); - } - - return deltas; - } - - private static IReadOnlyList BuildClaimDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var claim in world.Claims) - { - var signature = BuildClaimSignature(claim); - if (signature == claim.LastDeltaSignature) - { - continue; - } - - claim.LastDeltaSignature = signature; - deltas.Add(ToClaimDelta(claim)); - } - - return deltas; - } - - private static IReadOnlyList BuildConstructionSiteDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var site in world.ConstructionSites) - { - var signature = BuildConstructionSiteSignature(site); - if (signature == site.LastDeltaSignature) - { - continue; - } - - site.LastDeltaSignature = signature; - deltas.Add(ToConstructionSiteDelta(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(); - foreach (var ship in world.Ships) - { - var signature = BuildShipSignature(ship); - if (signature == ship.LastDeltaSignature) - { - continue; - } - - ship.LastDeltaSignature = signature; - deltas.Add(ToShipDelta(ship)); - } - - return deltas; - } - - private static IReadOnlyList BuildFactionDeltas(SimulationWorld world) - { - var deltas = new List(); - foreach (var faction in world.Factions) - { - var signature = BuildFactionSignature(faction); - if (signature == faction.LastDeltaSignature) - { - continue; - } - - faction.LastDeltaSignature = signature; - deltas.Add(ToFactionDelta(faction)); - } - - return deltas; - } - - 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)) + foreach (var step in _worldUpdatePipeline) { - continue; + step.Execute(this, world, deltaSeconds, nowUtc, events); } - var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds); - planetNode.Position = planetPosition; - - foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) + foreach (var ship in world.Ships) { - var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}"; - if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode)) - { - lagrangeNode.Position = lagrange.Position; - } + var previousPosition = ship.Position; + var previousState = ship.State; + var previousBehavior = ship.DefaultBehavior.Kind; + var previousTask = ship.ControllerTask.Kind; + + foreach (var step in _shipUpdatePipeline) + { + step.Execute(this, ship, world, deltaSeconds, events); + } + + var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); + AdvanceControlState(ship, world, controllerEvent); + ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); + TrackHistory(ship); + + EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } - 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}|{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("|", - ship.SystemId, - ship.Position.X.ToString("0.###"), - ship.Position.Y.ToString("0.###"), - ship.Position.Z.ToString("0.###"), - ship.Velocity.X.ToString("0.###"), - ship.Velocity.Y.ToString("0.###"), - ship.Velocity.Z.ToString("0.###"), - ship.TargetPosition.X.ToString("0.###"), - ship.TargetPosition.Y.ToString("0.###"), - ship.TargetPosition.Z.ToString("0.###"), - ship.State, - 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.###"), - ship.Health.ToString("0.###")); - - private static string BuildInventorySignature(IReadOnlyDictionary inventory) => - string.Join(",", - inventory - .Where((entry) => entry.Value > 0.001f) - .OrderBy((entry) => entry.Key, StringComparer.Ordinal) - .Select((entry) => $"{entry.Key}:{entry.Value:0.###}")); - - private static string BuildFactionSignature(FactionRuntime faction) => - $"{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, - node.SystemId, - ToDto(node.Position), - node.SourceKind, - node.OreRemaining, - 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.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, - ship.Definition.Label, - ship.Definition.Role, - ship.Definition.ShipClass, - ship.SystemId, - ToDto(ship.Position), - ToDto(ship.Velocity), - ToDto(ship.TargetPosition), - ship.State, - 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(), - ToShipSpatialStateSnapshot(ship.SpatialState)); - - private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => - inventory - .Where((entry) => entry.Value > 0.001f) - .OrderBy((entry) => entry.Key, StringComparer.Ordinal) - .Select((entry) => new InventoryEntry(entry.Key, entry.Value)) - .ToList(); - - private static FactionDelta ToFactionDelta(FactionRuntime faction) => new( - faction.Id, - faction.Label, - faction.Color, - faction.Credits, - faction.PopulationTotal, - faction.OreMined, - faction.GoodsProduced, - faction.ShipsBuilt, - 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, - string previousState, - string previousBehavior, - string previousTask, - string controllerEvent, - ICollection events) - { - var occurredAtUtc = DateTimeOffset.UtcNow; - - if (previousState != ship.State) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc)); - } - - if (previousBehavior != ship.DefaultBehavior.Kind) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc)); - } - - if (previousTask != ship.ControllerTask.Kind) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc)); - } - - if (controllerEvent != "none") - { - events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc)); - } - } - - private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) - { - var factionPopulation = new Dictionary(StringComparer.Ordinal); - foreach (var station in world.Stations) - { - 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) - { - station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); - } - } - else if (station.Population > 0f) - { - var previous = station.Population; - station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds)); - if (MathF.Floor(previous) > MathF.Floor(station.Population)) - { - events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Definition.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); - } - } - - station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); - } - - 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; - 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; - } - - order.RemainingAmount = 0f; - order.State = MarketOrderStateKinds.Cancelled; - } - } - - private static bool HasRefineryCapability(StationRuntime station) => - HasStationModules(station, "refinery-stack", "power-core", "bulk-bay"); - - private static bool CanProcessFuel(StationRuntime station) => - HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); - - 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, - ICollection events) - { - foreach (var station in world.Stations) - { - var previousEnergy = station.EnergyStored; - GenerateStationEnergy(station, deltaSeconds); - - if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) - { - events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); - } - } - } - - private static void UpdateShipPower( - ShipRuntime ship, - SimulationWorld world, - float deltaSeconds, - ICollection events) - { - var previousEnergy = ship.EnergyStored; - GenerateShipEnergy(ship, world, deltaSeconds); - - if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); - } - } - - private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) - { - var powerCores = CountModules(station.InstalledModules, "power-core"); - var tanks = CountModules(station.InstalledModules, "liquid-tank"); - if (powerCores <= 0 || tanks <= 0) - { - station.EnergyStored = 0f; - station.Inventory.Remove("fuel"); - return; - } - - var energyCapacity = powerCores * StationEnergyPerPowerCore; - var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); - var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); - if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) - { - station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); - station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); - return; - } - - var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds); - var requiredFuel = generated / StationFuelToEnergyRatio; - var consumedFuel = MathF.Min(requiredFuel, fuelStored); - var actualGenerated = consumedFuel * StationFuelToEnergyRatio; - - RemoveInventory(station.Inventory, "fuel", consumedFuel); - station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); - } - - private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var reactors = CountModules(ship.Definition.Modules, "reactor-core"); - var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank"); - if (reactors <= 0 || capacitors <= 0) - { - ship.EnergyStored = 0f; - ship.Inventory.Remove("fuel"); - return; - } - - var energyCapacity = capacitors * CapacitorEnergyPerModule; - var fuelCapacity = reactors * ShipFuelPerReactor; - var fuelStored = GetInventoryAmount(ship.Inventory, "fuel"); - var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored); - if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) - { - ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity); - ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity); - return; - } - - var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds); - var requiredFuel = generated / ShipFuelToEnergyRatio; - var consumedFuel = MathF.Min(requiredFuel, fuelStored); - var actualGenerated = consumedFuel * ShipFuelToEnergyRatio; - - RemoveInventory(ship.Inventory, "fuel", consumedFuel); - ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated); - } - - private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount) - { - if (ship.EnergyStored + 0.0001f < amount) - { - return false; - } - - ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount); - return true; - } - - private static bool TryConsumeStationEnergy(StationRuntime station, float amount) - { - if (station.EnergyStored + 0.0001f < amount) - { - return false; - } - - station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount); - return true; - } - - private static int CountModules(IEnumerable modules, string moduleId) => - modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); - - private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => - inventory.TryGetValue(itemId, out var amount) ? amount : 0f; - - private static void AddInventory(IDictionary inventory, string itemId, float amount) - { - if (amount <= 0f) - { - return; - } - - inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; - } - - private static float RemoveInventory(IDictionary inventory, string itemId, float amount) - { - var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); - var removed = MathF.Min(current, amount); - var remaining = current - removed; - if (remaining <= 0.001f) - { - inventory.Remove(itemId); - } - else - { - inventory[itemId] = remaining; - } - - return removed; - } - - private static bool HasStationModules(StationRuntime station, params string[] modules) => - modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); - - private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) => - node.ItemId switch - { - "ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"), - "gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"), - _ => false, - }; - - private static float GetShipFuelCapacity(ShipRuntime ship) => - CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; - - 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 - { - "bulk-solid" => "bulk-bay", - "bulk-liquid" => "liquid-tank", - "bulk-gas" => "gas-tank", - _ => null, - }; - - private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) - { - if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - return 0f; - } - - var storageClass = itemDefinition.Storage; - var requiredModule = GetStorageRequirement(storageClass); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) - { - return 0f; - } - - if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity)) - { - return 0f; - } - - var used = station.Inventory - .Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass) - .Sum((entry) => entry.Value); - var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); - if (accepted <= 0.01f) - { - return 0f; - } - - AddInventory(station.Inventory, itemId, accepted); - return accepted; - } - - 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)) - { - return true; - } - - if (station.ActiveConstruction is not null) - { - return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) - && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); - } - - if (!CanStartModuleConstruction(station, recipe)) - { - return false; - } - - foreach (var input in recipe.Inputs) - { - RemoveInventory(station.Inventory, input.ItemId, input.Amount); - } - - station.ActiveConstruction = new ModuleConstructionRuntime - { - ModuleId = recipe.ModuleId, - RequiredSeconds = recipe.Duration, - AssignedConstructorShipId = shipId, - }; - - return true; - } - - private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) - { - foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) - { - if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) - && world.ModuleRecipes.ContainsKey(moduleId)) - { - return moduleId; - } - } - - 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; - - private static int? ReserveDockingPad(StationRuntime station, string shipId) - { - if (station.DockingPadAssignments.FirstOrDefault((entry) => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing - && !string.IsNullOrEmpty(existing.Value)) - { - return existing.Key; - } - - var padCount = GetDockingPadCount(station); - for (var padIndex = 0; padIndex < padCount; padIndex += 1) - { - if (station.DockingPadAssignments.ContainsKey(padIndex)) - { - continue; - } - - station.DockingPadAssignments[padIndex] = shipId; - return padIndex; - } - - return null; - } - - private static void ReleaseDockingPad(StationRuntime station, string shipId) - { - var assignment = station.DockingPadAssignments.FirstOrDefault((entry) => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); - if (!string.IsNullOrEmpty(assignment.Value)) - { - station.DockingPadAssignments.Remove(assignment.Key); - } - } - - private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) - { - var padCount = Math.Max(1, GetDockingPadCount(station)); - var angle = ((MathF.PI * 2f) / padCount) * padIndex; - var radius = station.Definition.Radius + 14f; - return new Vector3( - station.Position.X + (MathF.Cos(angle) * radius), - station.Position.Y, - station.Position.Z + (MathF.Sin(angle) * radius)); - } - - private static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) - { - var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); - var angle = (hash % 360) * (MathF.PI / 180f); - var radius = station.Definition.Radius + 34f; - return new Vector3( - station.Position.X + (MathF.Cos(angle) * radius), - station.Position.Y, - station.Position.Z + (MathF.Sin(angle) * radius)); - } - - private static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) - { - if (padIndex is null) - { - return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); - } - - var pad = GetDockingPadPosition(station, padIndex.Value); - var dx = pad.X - station.Position.X; - var dz = pad.Z - station.Position.Z; - var length = MathF.Sqrt((dx * dx) + (dz * dz)); - if (length <= 0.001f) - { - return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); - } - - var scale = distance / length; - return new Vector3( - pad.X + (dx * scale), - station.Position.Y, - pad.Z + (dz * scale)); - } - - private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => - ship.AssignedDockingPadIndex is int padIndex - ? GetDockingPadPosition(station, padIndex) - : station.Position; - - private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds) - { - ship.ActionTimer += deltaSeconds; - if (ship.ActionTimer < requiredSeconds) - { - return false; - } - - ship.ActionTimer = 0f; - return true; - } - - private static float GetShipCargoAmount(ShipRuntime ship) - { - var cargoItemId = ship.Definition.CargoItemId; - return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId); - } - - 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) - { - 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; - } - - if (ship.DefaultBehavior.Kind == "auto-mine") - { - PlanResourceHarvest(ship, world, "ore", "mining-turret"); - return; - } - - if (ship.DefaultBehavior.Kind == "auto-harvest-gas") - { - PlanResourceHarvest(ship, world, "gas", "gas-extractor"); - return; - } - - if (ship.DefaultBehavior.Kind == "construct-station") - { - PlanStationConstruction(ship, world); - return; - } - - if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0) - { - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "travel", - TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], - TargetSystemId = ship.SystemId, - Threshold = 18f, - }; - return; - } - - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "idle", - Threshold = world.Balance.ArrivalThreshold, - }; - } - - private void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) - { - var behavior = ship.DefaultBehavior; - 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) - .OrderByDescending((candidate) => candidate.OreRemaining) - .FirstOrDefault() - : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId); - - if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) - { - behavior.Kind = "idle"; - ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; - return; - } - - behavior.NodeId ??= node.Id; - if (ship.DockedStationId == refinery.Id) - { - if (GetShipCargoAmount(ship) > 0.01f) - { - behavior.Phase = "unload"; - } - else if (NeedsRefuel(ship)) - { - behavior.Phase = "refuel"; - } - else if (behavior.Phase is "dock" or "unload" or "refuel") - { - behavior.Phase = "undock"; - } - } - else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") - { - behavior.Phase = "travel-to-station"; - } - - switch (behavior.Phase) - { - case "extract": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "extract", - TargetEntityId = node.Id, - TargetSystemId = node.SystemId, - TargetPosition = node.Position, - Threshold = 14f, - }; - break; - case "travel-to-station": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "travel", - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = refinery.Definition.Radius + 8f, - }; - break; - case "dock": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "dock", - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = refinery.Definition.Radius + 4f, - }; - break; - case "unload": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "unload", - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = 0f, - }; - break; - case "refuel": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "refuel", - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = 0f, - }; - break; - case "undock": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "undock", - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), - Threshold = 8f, - }; - break; - default: - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "travel", - TargetEntityId = node.Id, - TargetSystemId = node.SystemId, - TargetPosition = node.Position, - Threshold = 18f, - }; - behavior.Phase = "travel-to-node"; - break; - } - } - - 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"; - ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; - return; - } - - var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); - behavior.ModuleId = moduleId; - if (moduleId is null) - { - ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; - return; - } - - if (ship.DockedStationId == station.Id) - { - if (NeedsRefuel(ship)) - { - 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"; - } - else - { - behavior.Phase = "wait-for-materials"; - } - } - else if (behavior.Phase is not "travel-to-station" and not "dock") - { - behavior.Phase = "travel-to-station"; - } - - switch (behavior.Phase) - { - case "dock": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "dock", - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = station.Definition.Radius + 4f, - }; - break; - case "refuel": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "refuel", - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, - }; - break; - case "construct-module": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "construct-module", - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - 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 - { - Kind = "idle", - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, - }; - break; - default: - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = "travel", - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = station.Definition.Radius + 8f, - }; - behavior.Phase = "travel-to-station"; - break; - } - } - - private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - switch (task.Kind) - { - case "idle": - TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - case "travel": - return UpdateTravel(ship, world, deltaSeconds); - case "extract": - return UpdateExtract(ship, world, deltaSeconds); - case "dock": - return UpdateDock(ship, world, deltaSeconds); - case "unload": - 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": - return UpdateUndock(ship, world, deltaSeconds); - default: - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - } - - private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - if (task.TargetPosition is null || task.TargetSystemId is null) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - - 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 = targetPosition; - ship.TargetPosition = ship.Position; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentNodeId = targetNode?.Id; - ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; - ship.State = "arriving"; - return "arrived"; - } - - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - 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; - var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); - if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node)) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.TargetPosition = task.TargetPosition.Value; - var distance = ship.Position.DistanceTo(task.TargetPosition.Value); - if (distance > task.Threshold) - { - ship.ActionTimer = 0f; - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "mining-approach"; - ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); - return "none"; - } - - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "mining"; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) - { - return "none"; - } - - var cargoAmount = GetShipCargoAmount(ship); - var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount); - mined = MathF.Min(mined, node.OreRemaining); - if (ship.Definition.CargoItemId is not null) - { - AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined); - } - node.OreRemaining -= mined; - if (node.OreRemaining <= 0f) - { - node.OreRemaining = node.MaxOre; - } - - return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; - } - - private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); - if (station is null || task.TargetPosition is null) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - - var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); - if (padIndex is null) - { - ship.ActionTimer = 0f; - ship.State = "awaiting-dock"; - ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); - var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); - if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds); - } - - return "none"; - } - - ship.AssignedDockingPadIndex = padIndex; - var padPosition = GetDockingPadPosition(station, padIndex.Value); - ship.TargetPosition = padPosition; - var distance = ship.Position.DistanceTo(padPosition); - if (distance > 4f) - { - ship.ActionTimer = 0f; - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "docking-approach"; - ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds); - return "none"; - } - - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "docking"; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) - { - return "none"; - } - - ship.State = "docked"; - ship.DockedStationId = station.Id; - station.DockedShipIds.Add(ship.Id); - ship.Position = padPosition; - ship.TargetPosition = padPosition; - return "docked"; - } - - private string UpdateUnload(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); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - 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 = "transferring"; - var cargoItemId = ship.Definition.CargoItemId; - var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds); - if (cargoItemId is not null) - { - var accepted = TryAddStationInventory(world, station, cargoItemId, moved); - RemoveInventory(ship.Inventory, cargoItemId, accepted); - moved = accepted; - } - var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); - if (faction is not null && cargoItemId == "ore") - { - faction.OreMined += moved; - faction.Credits += moved * 0.4f; - } - - return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; - } - - private string UpdateRefuel(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); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - 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 = "refueling"; - var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel")); - var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); - if (moved > 0.01f) - { - RemoveInventory(station.Inventory, "fuel", moved); - AddInventory(ship.Inventory, "fuel", moved); - } - - return !NeedsRefuel(ship) ? "refueled" : "none"; - } - - private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - - var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); - if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) - { - ship.AssignedDockingPadIndex = null; - ship.State = "idle"; - ship.TargetPosition = ship.Position; - 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"; - } - - if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) - { - ship.ActionTimer = 0f; - ship.State = "waiting-materials"; - ship.TargetPosition = GetShipDockedPosition(ship, station); - return "none"; - } - - if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) - { - ship.State = "construction-blocked"; - ship.TargetPosition = GetShipDockedPosition(ship, station); - return "none"; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = "constructing"; - station.ActiveConstruction.ProgressSeconds += deltaSeconds; - if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) - { - return "none"; - } - - station.InstalledModules.Add(station.ActiveConstruction.ModuleId); - station.ActiveConstruction = null; - 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; - if (ship.DockedStationId is null || task.TargetPosition is null) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - - var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); - var undockTarget = station is null - ? task.TargetPosition.Value - : GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); - ship.TargetPosition = undockTarget; - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) - { - ship.State = "power-starved"; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.State = "undocking"; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) - { - if (station is not null) - { - ship.Position = GetShipDockedPosition(ship, station); - } - return "none"; - } - - ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); - if (ship.Position.DistanceTo(undockTarget) > task.Threshold) - { - return "none"; - } - - if (station is not null) - { - station.DockedShipIds.Remove(ship.Id); - ReleaseDockingPad(station, ship.Id); - } - - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return "undocked"; - } - - 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; - } - - if (ship.DefaultBehavior.Kind == "auto-mine") - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-node", "arrived"): - ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; - break; - case ("extract", "cargo-full"): - ship.DefaultBehavior.Phase = "travel-to-station"; - break; - case ("travel-to-station", "arrived"): - ship.DefaultBehavior.Phase = "dock"; - break; - case ("dock", "docked"): - ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; - break; - case ("unload", "unloaded"): - ship.DefaultBehavior.Phase = "refuel"; - break; - case ("refuel", "refueled"): - ship.DefaultBehavior.Phase = "undock"; - break; - case ("undock", "undocked"): - ship.DefaultBehavior.Phase = "travel-to-node"; - ship.DefaultBehavior.NodeId = null; - break; - } - } - - if (ship.DefaultBehavior.Kind == "auto-harvest-gas") - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-node", "arrived"): - ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; - break; - case ("extract", "cargo-full"): - ship.DefaultBehavior.Phase = "travel-to-station"; - break; - case ("travel-to-station", "arrived"): - ship.DefaultBehavior.Phase = "dock"; - break; - case ("dock", "docked"): - ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; - break; - case ("unload", "unloaded"): - ship.DefaultBehavior.Phase = "refuel"; - break; - case ("refuel", "refueled"): - ship.DefaultBehavior.Phase = "undock"; - break; - case ("undock", "undocked"): - ship.DefaultBehavior.Phase = "travel-to-node"; - ship.DefaultBehavior.NodeId = null; - break; - } - } - - if (ship.DefaultBehavior.Kind == "construct-station") - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-station", "arrived"): - ship.DefaultBehavior.Phase = "dock"; - break; - case ("dock", "docked"): - ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "deliver-to-site"; - break; - case ("refuel", "refueled"): - 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; - } - } - - if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0) - { - 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) - { - var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}"; - if (signature == ship.LastSignature) - { - return; - } - - ship.LastSignature = signature; - ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}"); - if (ship.History.Count > 18) - { - ship.History.RemoveAt(0); - } - } - - 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); + SyncSpatialState(world); + world.GeneratedAtUtc = nowUtc; + + return new WorldDelta( + sequence, + world.TickIntervalMs, + world.GeneratedAtUtc, + false, + events, + BuildSpatialNodeDeltas(world), + BuildLocalBubbleDeltas(world), + BuildNodeDeltas(world), + BuildStationDeltas(world), + BuildClaimDeltas(world), + BuildConstructionSiteDeltas(world), + BuildMarketOrderDeltas(world), + BuildPolicyDeltas(world), + BuildShipDeltas(world), + BuildFactionDeltas(world)); + } + + private delegate void WorldUpdateStepAction( + SimulationEngine engine, + SimulationWorld world, + float deltaSeconds, + DateTimeOffset nowUtc, + List events); + + private delegate void ShipUpdateStepAction( + SimulationEngine engine, + ShipRuntime ship, + SimulationWorld world, + float deltaSeconds, + List events); + + private sealed record WorldUpdateStep(WorldUpdateStepAction Execute); + + private sealed record ShipUpdateStep(ShipUpdateStepAction Execute); } diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 1d80e05..a46cb03 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -1,3645 +1,13 @@ -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, - Vector3Dto, - WorldDelta, - WorldSnapshot, -} from "./contracts"; - -type ZoomLevel = "local" | "system" | "universe"; -type SelectionGroup = "ships" | "structures" | "celestials"; -type DragMode = "orbit" | "marquee"; -type CameraMode = "tactical" | "follow"; -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 }; - -interface ShipVisual { - systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; - startPosition: THREE.Vector3; - authoritativePosition: THREE.Vector3; - targetPosition: THREE.Vector3; - velocity: THREE.Vector3; - receivedAtMs: number; - blendDurationMs: number; -} - -interface PlanetVisual { - systemId: string; - planet: PlanetSnapshot; - orbit: THREE.LineLoop; - mesh: THREE.Mesh; - icon: THREE.Sprite; - ring?: THREE.Mesh; - moons: MoonVisual[]; -} - -interface MoonVisual { - mesh: THREE.Mesh; - orbit: THREE.LineLoop; -} - -type OrbitalAnchor = - | { kind: "star" } - | { kind: "planet"; planetIndex: number } - | { kind: "moon"; planetIndex: number; moonIndex: number }; - -interface NodeVisual { - systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; - sourceKind: string; - anchor: OrbitalAnchor; - localPosition: THREE.Vector3; - orbitRadius: number; - orbitPhase: number; - 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; - anchor: OrbitalAnchor; - orbitRadius: number; - orbitPhase: number; - orbitInclination: number; - localPosition: THREE.Vector3; -} - -interface SystemVisual { - root: THREE.Group; - starCluster: THREE.Group; - icon: THREE.Sprite; - shellReticle: THREE.Sprite; - shellReticleBaseScale: number; - detailGroup: THREE.Group; - summary: SystemSummaryVisual; - galaxyPosition: THREE.Vector3; -} - -interface WorldState { - label: string; - seed: number; - sequence: number; - 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[]; -} - -interface NetworkSample { - atMs: number; - bytes: number; -} - -interface NetworkStats { - snapshotBytes: number; - deltasReceived: number; - deltaBytes: number; - lastDeltaBytes: number; - lastEntityChanges: number; - eventsReceived: number; - streamConnected: boolean; - streamOpenedAtMs?: number; - lastDeltaAtMs?: number; - throughputSamples: NetworkSample[]; -} - -interface PerformanceSample { - atMs: number; - frameMs: number; -} - -interface PerformanceStats { - frameSamples: PerformanceSample[]; - lastFrameMs: number; - lastPanelUpdateAtMs: number; -} - -interface PresentationEntry { - detail: THREE.Object3D; - icon: THREE.Sprite; - systemId?: string; - hideDetailInUniverse?: boolean; - hideIconInUniverse?: boolean; -} - -interface SystemSummaryVisual { - sprite: THREE.Sprite; - texture: THREE.CanvasTexture; - anchor: THREE.Vector3; -} - -interface HistoryWindowState { - id: string; - target: Selectable; - root: HTMLElement; - titleEl: HTMLHeadingElement; - bodyEl: HTMLDivElement; - copyButtonEl: HTMLButtonElement; - text: string; -} - -const ZOOM_DISTANCE: Record = { - local: 900, - system: 3200, - universe: 26000, -}; -const ACTIVE_SYSTEM_DETAIL_SCALE = 10; -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; - -interface ZoomBlend { - localWeight: number; - systemWeight: number; - universeWeight: number; -} +import { ViewerAppController } from "./ViewerAppController"; export class GameViewer { - private readonly container: HTMLElement; - private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); - private readonly scene = new THREE.Scene(); - private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000); - private readonly clock = new THREE.Clock(); - private readonly raycaster = new THREE.Raycaster(); - private readonly mouse = new THREE.Vector2(); - private readonly galaxyFocus = new THREE.Vector3(2200, 0, 300); - private readonly systemFocusLocal = new THREE.Vector3(); - 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(); - private readonly planetVisuals: PlanetVisual[] = []; - private readonly orbitLines: THREE.Object3D[] = []; - private readonly statusEl: HTMLDivElement; - private readonly systemPanelEl: HTMLDivElement; - private readonly systemTitleEl: HTMLHeadingElement; - private readonly systemBodyEl: HTMLDivElement; - private readonly detailTitleEl: HTMLHeadingElement; - private readonly detailBodyEl: HTMLDivElement; - private readonly factionStripEl: HTMLDivElement; - private readonly networkPanelEl: HTMLDivElement; - private readonly performancePanelEl: HTMLDivElement; - private readonly errorEl: HTMLDivElement; - private readonly historyLayerEl: HTMLDivElement; - private readonly marqueeEl: HTMLDivElement; - private readonly hoverLabelEl: HTMLDivElement; - - private world?: WorldState; - private worldTimeSyncMs = performance.now(); - private stream?: EventSource; - private currentStreamScopeKey = ""; - private readonly networkStats: NetworkStats = { - snapshotBytes: 0, - deltasReceived: 0, - deltaBytes: 0, - lastDeltaBytes: 0, - lastEntityChanges: 0, - eventsReceived: 0, - streamConnected: false, - throughputSamples: [], - }; - private readonly performanceStats: PerformanceStats = { - frameSamples: [], - lastFrameMs: 0, - lastPanelUpdateAtMs: 0, - }; - - private selectedItems: Selectable[] = []; - private worldSignature = ""; - private zoomLevel: ZoomLevel = "system"; - private currentDistance = ZOOM_DISTANCE.system; - private desiredDistance = ZOOM_DISTANCE.system; - private orbitYaw = -2.3; - private orbitPitch = 0.62; - private cameraMode: CameraMode = "tactical"; - private dragMode?: DragMode; - private dragPointerId?: number; - private dragStart = new THREE.Vector2(); - private dragLast = new THREE.Vector2(); - private marqueeActive = false; - private suppressClickSelection = false; - private activeSystemId?: string; - private cameraTargetShipId?: string; - private readonly followCameraPosition = new THREE.Vector3(); - private readonly followCameraFocus = new THREE.Vector3(); - private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1); - private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1); - private readonly followCameraOffset = new THREE.Vector3(); - private readonly historyWindows: HistoryWindowState[] = []; - private historyWindowCounter = 0; - private historyWindowZCounter = 10; - private historyWindowDragId?: string; - private historyWindowDragPointerId?: number; - private historyWindowDragOffset = new THREE.Vector2(); + private readonly controller: ViewerAppController; constructor(container: HTMLElement) { - this.container = container; - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.outputColorSpace = THREE.SRGBColorSpace; - - this.scene.background = new THREE.Color(0x040912); - this.scene.fog = new THREE.FogExp2(0x040912, 0.00011); - this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55)); - const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); - keyLight.position.set(1000, 1200, 800); - this.scene.add(keyLight); - this.initializeAmbience(); - 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"; - hud.innerHTML = ` -
-
-

Game

-
Bootstrapping
-
- - -
-
- - - -
-
-
-
- - `; - - this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement; - this.systemPanelEl = hud.querySelector(".system-panel-section") as HTMLDivElement; - this.systemTitleEl = hud.querySelector(".system-title") as HTMLHeadingElement; - this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement; - this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement; - this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; - this.factionStripEl = hud.querySelector(".ship-strip") as HTMLDivElement; - this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement; - this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement; - this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement; - this.historyLayerEl = hud.querySelector(".history-layer") as HTMLDivElement; - this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement; - this.hoverLabelEl = hud.querySelector(".hover-label") as HTMLDivElement; - - this.container.append(this.renderer.domElement, hud); - - this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown); - this.renderer.domElement.addEventListener("pointermove", this.onPointerMove); - this.renderer.domElement.addEventListener("pointerup", this.onPointerUp); - this.renderer.domElement.addEventListener("pointerleave", this.onPointerUp); - this.renderer.domElement.addEventListener("click", this.onClick); - this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick); - this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false }); - this.factionStripEl.addEventListener("click", this.onShipStripClick); - this.factionStripEl.addEventListener("dblclick", this.onShipStripDoubleClick); - this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick); - this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown); - window.addEventListener("pointermove", this.onHistoryWindowPointerMove); - window.addEventListener("pointerup", this.onHistoryWindowPointerUp); - window.addEventListener("keydown", this.onKeyDown); - window.addEventListener("keyup", this.onKeyUp); - window.addEventListener("resize", this.onResize); - this.onResize(); - this.updateCamera(0); + this.controller = new ViewerAppController(container); } async start() { - await this.bootstrapWorld(); - this.renderer.setAnimationLoop(() => this.render()); - } - - private async bootstrapWorld() { - try { - const snapshot = await fetchWorldSnapshot(); - this.world = this.createWorldState(snapshot); - this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size; - this.updateGamePanel("Bootstrapped"); - this.errorEl.hidden = true; - this.applySnapshot(snapshot); - this.openDeltaStream(snapshot.sequence); - this.updatePanels(); - } catch (error) { - this.updateGamePanel("Backend offline"); - this.errorEl.hidden = false; - this.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot."; - } - } - - 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; - this.networkStats.streamOpenedAtMs = performance.now(); - this.updateGamePanel("Stream live"); - this.updateNetworkPanel(); - }, - onError: () => { - this.networkStats.streamConnected = false; - this.updateGamePanel("Stream reconnecting"); - this.updateNetworkPanel(); - }, - onDelta: (delta, rawBytes) => { - void this.handleDelta(delta, rawBytes); - }, - }, scope); - } - - private async handleDelta(delta: WorldDelta, rawBytes: number) { - if (!this.world) { - return; - } - - if (delta.requiresSnapshotRefresh) { - await this.bootstrapWorld(); - return; - } - - this.applyDelta(delta); - this.recordDeltaStats(delta, rawBytes); - 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 { - return { - label: snapshot.label, - seed: snapshot.seed, - sequence: snapshot.sequence, - 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: [], - }; - } - - private applySnapshot(snapshot: WorldSnapshot) { - this.worldTimeSyncMs = performance.now(); - const signature = `${snapshot.seed}|${snapshot.systems.length}`; - if (signature !== this.worldSignature) { - this.worldSignature = signature; - 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(); - this.applyZoomPresentation(); - this.updateNetworkPanel(); - } - - private applyDelta(delta: WorldDelta) { - if (!this.world) { - return; - } - - this.worldTimeSyncMs = performance.now(); - this.world.sequence = delta.sequence; - this.world.tickIntervalMs = delta.tickIntervalMs; - 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); - } - for (const faction of delta.factions) { - 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()]); - } - this.updateSystemSummaries(); - } - - private rebuildSystems(systems: SystemSnapshot[]) { - this.systemGroup.clear(); - this.selectableTargets.clear(); - this.presentationEntries.length = 0; - this.planetVisuals.length = 0; - this.orbitLines.length = 0; - this.systemVisuals.clear(); - this.systemSummaryVisuals.clear(); - - for (const system of systems) { - const root = new THREE.Group(); - root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z); - const detailGroup = new THREE.Group(); - const renderedStarSize = this.celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); - - const starCluster = this.createStarCluster(system); - const systemIcon = this.createTacticalIcon(system.starColor, 96); - const shellReticle = this.createShellReticle("#ff3b30", 400); - const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z)); - summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0); - root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); - this.registerPresentation(starCluster, systemIcon, true); - this.systemVisuals.set(system.id, { - root, - starCluster, - icon: systemIcon, - shellReticle, - shellReticleBaseScale: 400, - detailGroup, - summary: summaryVisual, - galaxyPosition: this.toThreeVector(system.galaxyPosition), - }); - this.systemSummaryVisuals.set(system.id, summaryVisual); - starCluster.traverse((child) => { - if (child instanceof THREE.Mesh) { - this.selectableTargets.set(child, { kind: "system", id: system.id }); - } - }); - this.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); - this.selectableTargets.set(shellReticle, { kind: "system", id: system.id }); - - 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(renderedPlanetRadius, 18, 18), - new THREE.MeshStandardMaterial({ - color: planet.color, - roughness: 0.92, - metalness: 0.08, - emissive: new THREE.Color(planet.color).multiplyScalar(0.04), - }), - ); - planetMesh.position.copy(this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds())); - 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) { - ring.position.copy(planetMesh.position); - } - const moons = this.createMoonVisuals(planet); - detailGroup.add(orbit, planetMesh, planetIcon); - if (ring) { - detailGroup.add(ring); - } - for (const moon of moons) { - moon.orbit.position.copy(planetMesh.position); - moon.mesh.position.copy(planetMesh.position); - detailGroup.add(moon.orbit, moon.mesh); - this.orbitLines.push(moon.orbit); - this.registerPresentation(moon.mesh, planetIcon, true, true, system.id); - } - this.orbitLines.push(orbit); - this.registerPresentation(planetMesh, planetIcon, true, true, system.id); - if (ring) { - this.registerPresentation(ring, planetIcon, true, true, system.id); - } - this.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); - this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex }); - this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); - } - - this.systemGroup.add(root); - } - } - - 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(); - - for (const node of nodes) { - const mesh = this.createNodeMesh(node); - const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); - icon.position.copy(mesh.position); - const localPosition = this.toThreeVector(node.localPosition); - const anchor = this.resolveOrbitalAnchor(node.systemId, localPosition); - const orbital = this.deriveNodeOrbital(node, anchor); - this.nodeVisuals.set(node.id, { - systemId: node.systemId, - mesh, - icon, - sourceKind: node.sourceKind, - anchor, - localPosition, - orbitRadius: orbital.radius, - orbitPhase: orbital.phase, - orbitInclination: orbital.inclination, - }); - this.nodeGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true, true, node.systemId); - this.selectableTargets.set(mesh, { kind: "node", id: node.id }); - this.selectableTargets.set(icon, { kind: "node", id: node.id }); - } - } - - private syncStations(stations: StationSnapshot[]) { - this.stationGroup.clear(); - this.stationVisuals.clear(); - - for (const station of stations) { - const mesh = this.createStationMesh(station); - const icon = this.createTacticalIcon(station.color, 26); - icon.position.copy(mesh.position); - const localPosition = this.toThreeVector(station.localPosition); - 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, - anchor, - orbitRadius: orbital.radius, - orbitPhase: orbital.phase, - orbitInclination: orbital.inclination, - localPosition, - }); - this.stationGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true, true, station.systemId); - this.selectableTargets.set(mesh, { kind: "station", id: station.id }); - this.selectableTargets.set(icon, { kind: "station", id: station.id }); - } - } - - 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 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 }); - this.registerPresentation(mesh, icon, true, true, ship.systemId); - this.shipVisuals.set(ship.id, { - systemId: ship.systemId, - mesh, - icon, - startPosition: position.clone(), - authoritativePosition: position.clone(), - targetPosition: this.toThreeVector(ship.targetLocalPosition), - velocity: this.toThreeVector(ship.localVelocity), - receivedAtMs: performance.now(), - blendDurationMs: Math.max(tickIntervalMs, 80), - }); - } - } - - 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); - if (!visual) { - continue; - } - - visual.systemId = node.systemId; - visual.sourceKind = node.sourceKind; - visual.localPosition.copy(this.toThreeVector(node.localPosition)); - visual.anchor = this.resolveOrbitalAnchor(node.systemId, visual.localPosition); - const orbital = this.deriveNodeOrbital(node, visual.anchor); - visual.orbitRadius = orbital.radius; - visual.orbitPhase = orbital.phase; - visual.orbitInclination = orbital.inclination; - visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); - } - } - - private applyStationDeltas(stations: StationDelta[]) { - for (const station of stations) { - const visual = this.stationVisuals.get(station.id); - if (!visual) { - continue; - } - - visual.systemId = station.systemId; - visual.localPosition.copy(this.toThreeVector(station.localPosition)); - visual.anchor = this.resolveOrbitalAnchor(station.systemId, visual.localPosition); - const orbital = this.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor); - visual.orbitRadius = orbital.radius; - visual.orbitPhase = orbital.phase; - visual.orbitInclination = orbital.inclination; - const material = visual.mesh.material as THREE.MeshStandardMaterial; - material.color.set(station.color); - material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); - } - } - - 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); - if (!visual) { - continue; - } - - visual.systemId = ship.systemId; - visual.startPosition.copy(visual.authoritativePosition); - visual.authoritativePosition.copy(this.toThreeVector(ship.localPosition)); - visual.targetPosition.copy(this.toThreeVector(ship.targetLocalPosition)); - visual.velocity.copy(this.toThreeVector(ship.localVelocity)); - visual.receivedAtMs = performance.now(); - visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); - 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); - } - } - - private rebuildFactions(_factions: FactionSnapshot[]) { - if (!this.world) { - this.factionStripEl.innerHTML = ""; - return; - } - - const ships = [...this.world.ships.values()] - .sort((left, right) => left.label.localeCompare(right.label)); - - this.factionStripEl.innerHTML = ships - .map((ship) => { - const fuel = this.inventoryAmount(ship.inventory, "gas"); - const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id; - const isFollowed = this.cameraMode === "follow" && this.cameraTargetShipId === ship.id; - return ` -
-
-

${ship.label}

-
- ${ship.shipClass} - -
-
-

${ship.systemId}

-

Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}

-

State ${ship.state}

-
-

Order ${ship.orderKind ?? "none"}

-

Behavior ${ship.defaultBehaviorKind}

-

Task ${ship.controllerTaskKind}

-
-
- `; - }) - .join(""); - } - - private updatePanels() { - if (!this.world) { - return; - } - - 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} - `; - return; - } - - if (this.selectedItems.length > 1) { - const group = this.getSelectionGroup(this.selectedItems[0]); - this.detailTitleEl.textContent = `${this.selectedItems.length} selected`; - this.detailBodyEl.innerHTML = ` - Type ${group}
- ${this.selectedItems.slice(0, 8).map((item) => this.describeSelectable(item)).join("
")} - `; - return; - } - - const selected = this.selectedItems[0]; - if (selected.kind === "ship") { - const ship = this.world.ships.get(selected.id); - if (!ship) { - return; - } - const parent = this.describeSelectionParent(selected); - this.detailTitleEl.textContent = ship.label; - const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); - this.detailBodyEl.innerHTML = ` -

Parent ${parent}

-

State ${ship.state}

-

Energy ${ship.energyStored.toFixed(0)}
Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

-

Inventory ${this.formatInventory(ship.inventory)}

-

Velocity ${this.formatVector(ship.localVelocity)}

-

Camera ${this.cameraMode === "follow" && this.cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}
Press C to toggle follow

- `; - return; - } - - if (selected.kind === "station") { - const station = this.world.stations.get(selected.id); - if (!station) { - return; - } - const parent = this.describeSelectionParent(selected); - this.detailTitleEl.textContent = station.label; - this.detailBodyEl.innerHTML = ` -

${station.category} · ${station.systemId}

-

Parent ${parent}

-

Energy ${station.energyStored.toFixed(0)}
Docked ${station.dockedShips} / ${station.dockingPads}

-

Inventory ${this.formatInventory(station.inventory)}

-

History available in the separate history window.

- `; - return; - } - - if (selected.kind === "node") { - const node = this.world.nodes.get(selected.id); - if (!node) { - return; - } - const parent = this.describeSelectionParent(selected); - this.detailTitleEl.textContent = `Node ${node.id}`; - this.detailBodyEl.innerHTML = ` -

${node.systemId}

-

Parent ${parent}

-

Source ${node.sourceKind}
Resource ${node.itemId}

-

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

- `; - 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]; - if (!system || !planet) { - return; - } - const parent = this.describeSelectionParent(selected); - this.detailTitleEl.textContent = planet.label; - this.detailBodyEl.innerHTML = ` -

${system.label}

-

Parent ${parent}

-

${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}

-

Orbit ${planet.orbitRadius.toFixed(0)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

-

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

- `; - return; - } - - const system = this.world.systems.get(selected.id); - if (!system) { - return; - } - this.detailTitleEl.textContent = system.label; - this.detailBodyEl.innerHTML = ` -

Parent galaxy

- ${this.renderSystemDetails(system, false)} - `; - } - - private formatInventory(entries: InventoryEntry[]): string { - if (entries.length === 0) { - return "empty"; - } - - return entries - .map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`) - .join("
"); - } - - private inventoryAmount(entries: InventoryEntry[], itemId: string): number { - return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0; - } - - private render() { - const frameStartedAtMs = performance.now(); - const delta = Math.min(this.clock.getDelta(), 0.033); - this.updateCamera(delta); - this.updateAmbience(delta); - this.updatePlanetPresentation(); - this.updateShipPresentation(); - this.updateNetworkPanel(); - this.applyZoomPresentation(); - this.renderer.render(this.scene, this.camera); - this.recordPerformanceStats(performance.now() - frameStartedAtMs); - this.updatePerformancePanel(); - } - - private updateAmbience(delta: number) { - this.ambienceGroup.position.copy(this.camera.position); - this.ambienceGroup.rotation.y += delta * 0.005; - this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; - } - - private updateCamera(delta: number) { - this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); - this.zoomLevel = this.classifyZoomLevel(this.currentDistance); - this.updateActiveSystem(); - if (this.cameraMode === "follow" && this.updateFollowCamera(delta)) { - return; - } - - this.updatePanFromKeyboard(delta); - this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); - - const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); - const focus = this.getCameraFocusWorldPosition(); - this.cameraOffset.set( - Math.cos(this.orbitYaw) * horizontalDistance, - this.currentDistance * Math.sin(this.orbitPitch), - Math.sin(this.orbitYaw) * horizontalDistance, - ); - this.camera.position.copy(focus).add(this.cameraOffset); - this.camera.lookAt(focus); - } - - private updatePanFromKeyboard(delta: number) { - const move = new THREE.Vector3(); - if (this.keyState.has("w")) { - move.z -= 1; - } - if (this.keyState.has("s")) { - move.z += 1; - } - if (this.keyState.has("a")) { - move.x += 1; - } - if (this.keyState.has("d")) { - move.x -= 1; - } - if (move.lengthSq() === 0) { - return; - } - - move.normalize(); - const forward = new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw)); - const right = new THREE.Vector3(-forward.z, 0, forward.x); - const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); - const speed = THREE.MathUtils.mapLinear(this.currentDistance, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, 320, 6800); - if (this.activeSystemId) { - this.systemFocusLocal.addScaledVector(pan, speed * delta); - return; - } - this.galaxyFocus.addScaledVector(pan, speed * delta); - } - - private applyZoomPresentation() { - const blend = this.computeZoomBlend(this.currentDistance); - - for (const entry of this.presentationEntries) { - const systemId = entry.systemId; - const isActiveDetail = !systemId || systemId === this.activeSystemId; - const isProjectedSystemIcon = !!this.activeSystemId - && !!systemId - && systemId !== this.activeSystemId - && this.systemVisuals.get(systemId)?.icon === entry.icon; - const detailAlpha = entry.hideDetailInUniverse - ? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0) - : 1; - const iconAlpha = isProjectedSystemIcon - ? 0 - : entry.hideIconInUniverse - ? blend.systemWeight * (isActiveDetail ? 1 : 0) - : Math.max(blend.systemWeight, blend.universeWeight); - - this.setObjectOpacity(entry.detail, detailAlpha); - this.setObjectOpacity(entry.icon, iconAlpha); - } - - for (const orbitLine of this.orbitLines) { - const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (this.activeSystemId ? 1 : 0); - this.setObjectOpacity(orbitLine, alpha); - } - - for (const [systemId, summaryVisual] of this.systemSummaryVisuals.entries()) { - const summaryOpacity = systemId === this.activeSystemId - ? 0 - : (this.activeSystemId ? 0.72 : 0.96); - this.setObjectOpacity(summaryVisual.sprite, summaryOpacity); - } - - this.scene.fog = new THREE.FogExp2(0x040912, 0.000035); - } - - private recordDeltaStats(delta: WorldDelta, rawBytes: number) { - 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; - this.networkStats.lastEntityChanges = changedEntities; - this.networkStats.eventsReceived += delta.events.length; - this.networkStats.lastDeltaAtMs = performance.now(); - this.networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes }); - const cutoff = performance.now() - 4000; - this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff); - } - - private updateNetworkPanel() { - const now = performance.now(); - const uptimeSeconds = this.networkStats.streamOpenedAtMs - ? (now - this.networkStats.streamOpenedAtMs) / 1000 - : 0; - const recentBytes = this.networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0); - const recentWindowSeconds = this.networkStats.throughputSamples.length > 1 - ? Math.max((now - this.networkStats.throughputSamples[0].atMs) / 1000, 1) - : 1; - const kbPerSecond = recentBytes / 1024 / recentWindowSeconds; - const averageDeltaBytes = this.networkStats.deltasReceived > 0 - ? this.networkStats.deltaBytes / this.networkStats.deltasReceived - : 0; - const secondsSinceLastDelta = this.networkStats.lastDeltaAtMs - ? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1) - : "n/a"; - - this.networkPanelEl.textContent = [ - `snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`, - `stream: ${this.networkStats.streamConnected ? "live" : "offline"}`, - `deltas: ${this.networkStats.deltasReceived}`, - `events: ${this.networkStats.eventsReceived}`, - `avg delta: ${this.formatBytes(averageDeltaBytes)}`, - `last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`, - `recent rate: ${kbPerSecond.toFixed(1)} KB/s`, - `changed: ${this.networkStats.lastEntityChanges}`, - `uptime: ${uptimeSeconds.toFixed(1)}s`, - `last packet: ${secondsSinceLastDelta}s`, - ].join("\n"); - } - - private recordPerformanceStats(frameMs: number) { - const now = performance.now(); - this.performanceStats.lastFrameMs = frameMs; - this.performanceStats.frameSamples.push({ atMs: now, frameMs }); - const cutoff = now - 4000; - this.performanceStats.frameSamples = this.performanceStats.frameSamples.filter((sample) => sample.atMs >= cutoff); - } - - private updatePerformancePanel() { - const now = performance.now(); - if ( - this.performanceStats.lastPanelUpdateAtMs > 0 && - now - this.performanceStats.lastPanelUpdateAtMs < 250 - ) { - return; - } - - const samples = this.performanceStats.frameSamples; - const elapsedWindowSeconds = samples.length > 1 - ? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25) - : 1; - const averageFrameMs = samples.length > 0 - ? samples.reduce((sum, sample) => sum + sample.frameMs, 0) / samples.length - : 0; - const worstFrameMs = samples.length > 0 - ? samples.reduce((max, sample) => Math.max(max, sample.frameMs), 0) - : 0; - const fps = samples.length > 1 - ? (samples.length - 1) / elapsedWindowSeconds - : 0; - const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0; - const renderInfo = this.renderer.info; - - this.performancePanelEl.textContent = [ - `fps: ${fps.toFixed(1)}`, - `frame avg: ${averageFrameMs.toFixed(2)} ms`, - `frame last: ${this.performanceStats.lastFrameMs.toFixed(2)} ms`, - `frame worst: ${worstFrameMs.toFixed(2)} ms`, - `recent low: ${recentLowFps.toFixed(1)}`, - `draw calls: ${renderInfo.render.calls}`, - `triangles: ${renderInfo.render.triangles}`, - `points: ${renderInfo.render.points}`, - `lines: ${renderInfo.render.lines}`, - `geometries: ${renderInfo.memory.geometries}`, - `textures: ${renderInfo.memory.textures}`, - `pixel ratio: ${this.renderer.getPixelRatio().toFixed(2)}`, - ].join("\n"); - this.performanceStats.lastPanelUpdateAtMs = now; - } - - private updateShipPresentation() { - const now = performance.now(); - const worldTimeSeconds = this.currentWorldTimeSeconds(); - for (const visual of this.shipVisuals.values()) { - const worldPosition = this.getAnimatedShipLocalPosition(visual, now); - - visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId)); - visual.icon.position.copy(visual.mesh.position); - const shipVisible = visual.systemId === this.activeSystemId; - visual.mesh.visible = shipVisible; - visual.icon.visible = shipVisible && visual.icon.visible; - const desiredHeading = this.resolveShipHeading(visual, worldPosition); - if (desiredHeading.lengthSq() > 0.01) { - visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); - } - } - - for (const visual of this.nodeVisuals.values()) { - const animatedLocalPosition = this.computeNodeLocalPosition(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.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(); - this.updateSystemDetailVisibility(); - this.updateSystemSummaryPresentation(); - } - - private getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { - const elapsedMs = now - visual.receivedAtMs; - const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); - return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); - } - - private resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3) { - const desiredHeading = visual.targetPosition.clone().sub(worldPosition); - if (desiredHeading.lengthSq() > 0.01) { - return desiredHeading; - } - - if (visual.velocity.lengthSq() > 0.01) { - return visual.velocity.clone(); - } - - return new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw)); - } - - private updatePlanetPresentation() { - const nowSeconds = this.currentWorldTimeSeconds(); - for (const visual of this.planetVisuals) { - const scale = visual.systemId === this.activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; - const localPosition = this.computePlanetLocalPosition(visual.planet, nowSeconds); - const orbitOffset = visual.systemId === this.activeSystemId - ? this.systemFocusLocal.clone().multiplyScalar(-scale) - : new THREE.Vector3(); - const position = visual.systemId === this.activeSystemId - ? localPosition.clone().sub(this.systemFocusLocal).multiplyScalar(scale) - : localPosition.multiplyScalar(scale); - visual.orbit.scale.setScalar(scale); - visual.orbit.position.copy(orbitOffset); - visual.mesh.position.copy(position); - visual.icon.position.copy(position); - if (visual.ring) { - visual.ring.position.copy(position); - } - for (const [moonIndex, moon] of visual.moons.entries()) { - moon.orbit.position.copy(position); - moon.orbit.scale.setScalar(scale); - moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds).multiplyScalar(scale)); - } - } - } - - private createNodeMesh(node: ResourceNodeSnapshot) { - const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas"; - const mesh = new THREE.Mesh( - isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0), - new THREE.MeshStandardMaterial({ - color: isGas ? 0x7fd6ff : 0xd2b07a, - flatShading: !isGas, - transparent: isGas, - opacity: isGas ? 0.68 : 1, - emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05), - }), - ); - mesh.position.copy(this.toThreeVector(node.localPosition)); - mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); - 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 = 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)]; - - for (const [index, offset] of offsets.entries()) { - const sizeScale = index === 0 ? 1 : 0.72; - const star = new THREE.Mesh( - new THREE.SphereGeometry(renderedStarSize * sizeScale, 24, 24), - new THREE.MeshBasicMaterial({ color: system.starColor }), - ); - const halo = new THREE.Mesh( - new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20), - new THREE.MeshBasicMaterial({ - color: system.starColor, - transparent: true, - opacity: this.starHaloOpacity(system.starKind), - side: THREE.BackSide, - }), - ); - star.position.copy(offset); - halo.position.copy(offset); - root.add(star, halo); - } - - return root; - } - - private createPlanetOrbit(planet: PlanetSnapshot) { - const points = Array.from({ length: 120 }, (_, index) => { - const phaseDegrees = (index / 120) * 360; - return this.computePlanetLocalPosition(planet, 0, phaseDegrees); - }); - - return new THREE.LineLoop( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }), - ); - } - - private createPlanetRing(planet: PlanetSnapshot) { - const renderedPlanetRadius = this.celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); - const ring = new THREE.Mesh( - new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48), - new THREE.MeshBasicMaterial({ - color: 0xdac89a, - transparent: true, - opacity: 0.42, - side: THREE.DoubleSide, - }), - ); - ring.rotation.x = Math.PI / 2; - ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25); - return ring; - } - - private createMoonVisuals(planet: PlanetSnapshot) { - const moonCount = Math.min(planet.moonCount, 12); - const moons: MoonVisual[] = []; - - for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) { - const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex); - const orbit = new THREE.LineLoop( - new THREE.BufferGeometry().setFromPoints( - Array.from({ length: 48 }, (_, index) => { - const angle = (index / 48) * Math.PI * 2; - return new THREE.Vector3( - Math.cos(angle) * orbitRadius, - 0, - Math.sin(angle) * orbitRadius, - ); - }), - ), - new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }), - ); - orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35); - - const moonSize = this.computeMoonRenderRadius(planet, moonIndex); - const mesh = new THREE.Mesh( - new THREE.SphereGeometry(moonSize, 12, 12), - new THREE.MeshStandardMaterial({ - color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55), - roughness: 0.96, - metalness: 0.02, - }), - ); - - moons.push({ mesh, orbit }); - } - - return moons; - } - - private createStationMesh(station: StationSnapshot) { - const mesh = new THREE.Mesh( - new THREE.CylinderGeometry(24, 24, 18, 10), - new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), - ); - mesh.rotation.x = Math.PI / 2; - mesh.position.copy(this.toThreeVector(station.localPosition)); - return mesh; - } - - 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: shipColor, - emissive: new THREE.Color(shipColor).multiplyScalar(0.18), - }), - ); - mesh.position.copy(this.toThreeVector(ship.localPosition)); - return mesh; - } - - private initializeAmbience() { - this.ambienceGroup.renderOrder = -10; - this.ambienceGroup.add(this.createBackdropStars()); - this.ambienceGroup.add(...this.createNebulaClouds()); - } - - private createBackdropStars() { - const starCount = 1800; - const radius = 36000; - const positions = new Float32Array(starCount * 3); - const colors = new Float32Array(starCount * 3); - const color = new THREE.Color(); - - for (let index = 0; index < starCount; index += 1) { - const direction = new THREE.Vector3( - THREE.MathUtils.randFloatSpread(2), - THREE.MathUtils.randFloatSpread(2), - THREE.MathUtils.randFloatSpread(2), - ).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1)); - positions[index * 3] = direction.x; - positions[index * 3 + 1] = direction.y; - positions[index * 3 + 2] = direction.z; - - const tint = THREE.MathUtils.randFloat(0, 1); - color.setRGB( - THREE.MathUtils.lerp(0.68, 1, tint), - THREE.MathUtils.lerp(0.76, 0.94, tint), - THREE.MathUtils.lerp(0.9, 1, tint), - ); - if (Math.random() < 0.08) { - color.lerp(new THREE.Color(0xffd6a0), 0.45); - } - colors[index * 3] = color.r; - colors[index * 3 + 1] = color.g; - colors[index * 3 + 2] = color.b; - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); - - return new THREE.Points( - geometry, - new THREE.PointsMaterial({ - size: 2.2, - sizeAttenuation: false, - vertexColors: true, - transparent: true, - opacity: 0.9, - depthWrite: false, - blending: THREE.AdditiveBlending, - }), - ); - } - - private createNebulaClouds() { - const texture = this.createNebulaTexture(); - const directions = [ - new THREE.Vector3(0.74, 0.34, -0.58), - new THREE.Vector3(-0.62, 0.18, -0.77), - new THREE.Vector3(0.22, -0.44, -0.87), - new THREE.Vector3(-0.38, 0.56, 0.73), - ]; - - return directions.map((direction, index) => { - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: 0.14, - depthWrite: false, - color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff", - blending: THREE.AdditiveBlending, - })); - sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600)); - const scale = 15000 + index * 2400; - sprite.scale.set(scale, scale * 0.62, 1); - return sprite; - }); - } - - private createNebulaTexture() { - const canvas = document.createElement("canvas"); - canvas.width = 256; - canvas.height = 256; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Unable to create nebula texture"); - } - - const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118); - gradient.addColorStop(0, "rgba(255,255,255,0.95)"); - gradient.addColorStop(0.2, "rgba(255,255,255,0.48)"); - gradient.addColorStop(0.55, "rgba(140,180,255,0.14)"); - gradient.addColorStop(1, "rgba(0,0,0,0)"); - context.fillStyle = gradient; - context.fillRect(0, 0, 256, 256); - - for (let index = 0; index < 10; index += 1) { - const x = THREE.MathUtils.randFloat(30, 226); - const y = THREE.MathUtils.randFloat(30, 226); - const radius = THREE.MathUtils.randFloat(24, 72); - const puff = context.createRadialGradient(x, y, 0, x, y, radius); - puff.addColorStop(0, "rgba(255,255,255,0.16)"); - puff.addColorStop(0.45, "rgba(255,255,255,0.08)"); - puff.addColorStop(1, "rgba(0,0,0,0)"); - context.fillStyle = puff; - context.beginPath(); - context.arc(x, y, radius, 0, Math.PI * 2); - context.fill(); - } - - const texture = new THREE.CanvasTexture(canvas); - texture.needsUpdate = true; - return texture; - } - - private createTacticalIcon(color: string, size: number) { - const canvas = document.createElement("canvas"); - canvas.width = 64; - canvas.height = 64; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Unable to create tactical icon"); - } - - context.clearRect(0, 0, 64, 64); - context.strokeStyle = color; - context.lineWidth = 5; - context.beginPath(); - context.arc(32, 32, 18, 0, Math.PI * 2); - context.stroke(); - context.beginPath(); - context.moveTo(32, 8); - context.lineTo(32, 56); - context.moveTo(8, 32); - context.lineTo(56, 32); - context.stroke(); - - const texture = new THREE.CanvasTexture(canvas); - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ - map: texture, - transparent: true, - depthWrite: false, - depthTest: false, - color: "#ffffff", - })); - sprite.scale.setScalar(size); - sprite.visible = false; - return sprite; - } - - private createSystemSummaryVisual(anchor: THREE.Vector3): SystemSummaryVisual { - const canvas = document.createElement("canvas"); - canvas.width = 512; - canvas.height = 160; - const texture = new THREE.CanvasTexture(canvas); - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ - map: texture, - transparent: true, - depthWrite: false, - depthTest: false, - })); - sprite.scale.set(520, 160, 1); - sprite.visible = false; - return { sprite, texture, anchor }; - } - - private createShellReticle(color: string, size: number) { - const canvas = document.createElement("canvas"); - canvas.width = 128; - canvas.height = 128; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Unable to create shell reticle"); - } - - context.clearRect(0, 0, 128, 128); - context.strokeStyle = color; - context.lineWidth = 6; - context.globalAlpha = 0.58; - context.beginPath(); - context.arc(64, 64, 48, 0.12 * Math.PI, 0.34 * Math.PI); - context.stroke(); - context.beginPath(); - context.arc(64, 64, 48, 0.62 * Math.PI, 0.84 * Math.PI); - context.stroke(); - context.beginPath(); - context.arc(64, 64, 48, 1.12 * Math.PI, 1.34 * Math.PI); - context.stroke(); - context.beginPath(); - context.arc(64, 64, 48, 1.62 * Math.PI, 1.84 * Math.PI); - context.stroke(); - - const texture = new THREE.CanvasTexture(canvas); - const material = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - depthWrite: false, - depthTest: false, - color, - opacity: 1, - blending: THREE.AdditiveBlending, - fog: false, - }); - const sprite = new THREE.Sprite(material); - sprite.scale.setScalar(size); - sprite.visible = false; - sprite.renderOrder = 1000; - return sprite; - } - - private updateSystemSummaries() { - if (!this.world) { - return; - } - - const shipCounts = new Map(); - const stationCounts = new Map(); - const structureCounts = new Map(); - - for (const ship of this.world.ships.values()) { - shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1); - } - for (const station of this.world.stations.values()) { - stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1); - structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1); - } - for (const node of this.world.nodes.values()) { - structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1); - } - - for (const [systemId, system] of this.world.systems.entries()) { - const visual = this.systemSummaryVisuals.get(systemId); - if (!visual) { - continue; - } - - const canvas = visual.texture.image as HTMLCanvasElement; - const context = canvas.getContext("2d"); - if (!context) { - continue; - } - - context.clearRect(0, 0, canvas.width, canvas.height); - context.fillStyle = "#eaf4ff"; - context.font = "600 34px Space Grotesk, sans-serif"; - context.textAlign = "center"; - context.fillText(system.label, canvas.width / 2, 40); - - const ships = shipCounts.get(systemId) ?? 0; - const stations = stationCounts.get(systemId) ?? 0; - const structures = structureCounts.get(systemId) ?? 0; - const gasClouds = [...this.world.nodes.values()].filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud").length; - const total = ships + stations + structures; - if (total > 0) { - context.fillStyle = "rgba(3, 8, 18, 0.72)"; - context.fillRect(56, 64, canvas.width - 112, 68); - context.strokeStyle = "rgba(132, 196, 255, 0.22)"; - context.strokeRect(56, 64, canvas.width - 112, 68); - - this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff"); - this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69"); - this.drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4"); - } - - visual.texture.needsUpdate = true; - } - } - - private drawCountIcon( - context: CanvasRenderingContext2D, - kind: "ship" | "station" | "structure", - x: number, - y: number, - value: number, - color: string, - ) { - context.save(); - context.strokeStyle = color; - context.fillStyle = color; - context.lineWidth = 3; - - if (kind === "ship") { - context.beginPath(); - context.moveTo(x - 14, y + 10); - context.lineTo(x, y - 14); - context.lineTo(x + 14, y + 10); - context.closePath(); - context.stroke(); - } else if (kind === "station") { - context.strokeRect(x - 14, y - 14, 28, 28); - } else { - context.beginPath(); - context.arc(x, y, 14, 0, Math.PI * 2); - context.stroke(); - context.beginPath(); - context.moveTo(x - 8, y); - context.lineTo(x + 8, y); - context.moveTo(x, y - 8); - context.lineTo(x, y + 8); - context.stroke(); - } - - context.fillStyle = "#eaf4ff"; - context.font = "600 26px IBM Plex Mono, monospace"; - context.textAlign = "left"; - context.fillText(String(value), x + 24, y + 9); - context.restore(); - } - - private updateSystemSummaryPresentation() { - const distanceScale = this.activeSystemId ? 0.05 : 0.085; - for (const [systemId, visual] of this.systemSummaryVisuals.entries()) { - const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3()); - const distance = this.camera.position.distanceTo(worldPosition); - const minimumScale = this.activeSystemId && systemId !== this.activeSystemId ? 1200 : 1400; - const scale = Math.max(minimumScale, distance * distanceScale); - visual.sprite.scale.set(scale, scale * 0.3125, 1); - } - } - - private registerPresentation( - detail: THREE.Object3D, - icon: THREE.Sprite, - hideDetailInUniverse: boolean, - hideIconInUniverse = false, - systemId?: string, - ) { - this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse }); - } - - private renderRecentEvents(entityKind: string, entityId: string) { - if (!this.world) { - return ""; - } - - return this.world.recentEvents - .filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId)) - .slice(0, 8) - .map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`) - .join("
"); - } - - private describeSelectable(item: Selectable) { - if (!this.world) { - return item.kind; - } - if (item.kind === "ship") { - return this.world.ships.get(item.id)?.label ?? item.id; - } - if (item.kind === "station") { - return this.world.stations.get(item.id)?.label ?? item.id; - } - 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}`; - } - return this.world.systems.get(item.id)?.label ?? item.id; - } - - private getSelectionGroup(item: Selectable): SelectionGroup { - if (item.kind === "ship") { - return "ships"; - } - 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"; - } - - private formatVector(vector: Vector3Dto) { - return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`; - } - - private formatBytes(bytes: number) { - if (bytes >= 1024 * 1024) { - return `${(bytes / 1024 / 1024).toFixed(2)} MB`; - } - if (bytes >= 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${Math.round(bytes)} B`; - } - - private updateGamePanel(mode: string) { - const sequence = this.world?.sequence ?? 0; - const generatedAt = this.world?.generatedAtUtc - ? new Date(this.world.generatedAtUtc).toLocaleTimeString() - : "n/a"; - const activeSystem = this.activeSystemId ?? "deep-space"; - const cameraModeLabel = this.cameraMode === "follow" ? "camera-follow" : "tactical"; - this.statusEl.textContent = [ - `mode: ${mode}`, - `camera: ${cameraModeLabel}`, - `zoom: ${this.zoomLevel}`, - `system: ${activeSystem}`, - `sequence: ${sequence}`, - `snapshot: ${generatedAt}`, - ].join("\n"); - } - - private classifyZoomLevel(distance: number): ZoomLevel { - const blend = this.computeZoomBlend(distance); - if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) { - return "local"; - } - if (blend.systemWeight >= blend.universeWeight) { - return "system"; - } - return "universe"; - } - - private computeZoomBlend(distance: number): ZoomBlend { - const localToSystem = this.smoothBand(distance, 1200, 5200); - const systemToUniverse = this.smoothBand(distance, 9000, 22000); - - return { - localWeight: 1 - localToSystem, - systemWeight: Math.min(localToSystem, 1 - systemToUniverse), - universeWeight: systemToUniverse, - }; - } - - private smoothBand(value: number, start: number, end: number) { - const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1); - return t * t * (3 - (2 * t)); - } - - private setObjectOpacity(object: THREE.Object3D, opacity: number) { - const visible = opacity > 0.02; - object.visible = visible; - object.traverse((child) => { - if (!("material" in child)) { - return; - } - const materials = Array.isArray(child.material) ? child.material : [child.material]; - for (const material of materials) { - if (!("opacity" in material)) { - continue; - } - material.transparent = true; - material.opacity = opacity; - material.needsUpdate = true; - } - }); - } - - private toThreeVector(vector: Vector3Dto) { - return new THREE.Vector3(vector.x, vector.y, vector.z); - } - - private currentWorldTimeSeconds() { - if (!this.world) { - return 0; - } - - const baseUtcMs = Date.parse(this.world.generatedAtUtc); - const elapsedMs = performance.now() - this.worldTimeSyncMs; - return ((baseUtcMs + elapsedMs) / 1000) + (this.world.seed * 97); - } - - private computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number) { - const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85); - const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed); - const eccentricAnomaly = meanAnomaly - + (eccentricity * Math.sin(meanAnomaly)) - + (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly)); - const semiMajorAxis = planet.orbitRadius; - const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05)); - const local = new THREE.Vector3( - semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity), - 0, - semiMinorAxis * Math.sin(eccentricAnomaly), - ); - - local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis)); - local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination)); - local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode)); - return local; - } - - private computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number) { - const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex); - const speed = this.computeMoonOrbitSpeed(planet, moonIndex); - const phase = this.hashUnit(`${planet.label}:${moonIndex}:phase`) * Math.PI * 2; - const inclination = THREE.MathUtils.degToRad((this.hashUnit(`${planet.label}:${moonIndex}:inclination`) - 0.5) * 28); - const node = THREE.MathUtils.degToRad(this.hashUnit(`${planet.label}:${moonIndex}:node`) * 360); - const angle = phase + (timeSeconds * speed); - - const local = new THREE.Vector3( - Math.cos(angle) * orbitRadius, - 0, - Math.sin(angle) * orbitRadius, - ); - local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination); - local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node); - return local; - } - - private computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number) { - const spacing = planet.size * 1.4; - const variance = this.hashUnit(`${planet.label}:${moonIndex}:radius`) * planet.size * 0.9; - return (planet.size * 1.8) + (moonIndex * spacing) + variance; - } - - private computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number) { - const radius = this.computeMoonOrbitRadius(planet, moonIndex); - return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003); - } - - private computeMoonSize(planet: PlanetSnapshot, moonIndex: number) { - const base = Math.max(2.2, planet.size * 0.11); - const variance = this.hashUnit(`${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5); - 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); - } - - private deriveOrbitalFromLocalPosition(localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) { - const anchorPosition = this.resolveOrbitalAnchorPosition(systemId, anchor, this.currentWorldTimeSeconds()); - const relativePosition = localPosition.clone().sub(anchorPosition); - const radius = Math.max(Math.sqrt((relativePosition.x * relativePosition.x) + (relativePosition.z * relativePosition.z)), 24); - const phase = Math.atan2(relativePosition.z, relativePosition.x); - const inclination = Math.atan2(relativePosition.y, radius); - return { radius, phase, inclination }; - } - - private computeNodeLocalPosition(node: NodeVisual, timeSeconds: number) { - const speed = this.computeNodeOrbitSpeed(node); - const angle = node.orbitPhase + (timeSeconds * speed); - const orbit = new THREE.Vector3( - Math.cos(angle) * node.orbitRadius, - 0, - Math.sin(angle) * node.orbitRadius, - ); - orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), node.orbitInclination); - return orbit.add(this.resolveOrbitalAnchorPosition(node.systemId, node.anchor, timeSeconds)); - } - - private computeNodeOrbitSpeed(node: NodeVisual) { - const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24; - return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4)); - } - - private computeStructureLocalPosition(structure: StructureVisual, timeSeconds: number, baseSpeed: number) { - const angle = structure.orbitPhase + (timeSeconds * (baseSpeed / Math.sqrt(Math.max(structure.orbitRadius / 180, 0.45)))); - const orbit = new THREE.Vector3( - Math.cos(angle) * structure.orbitRadius, - 0, - Math.sin(angle) * structure.orbitRadius, - ); - orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), structure.orbitInclination); - return orbit.add(this.resolveOrbitalAnchorPosition(structure.systemId, structure.anchor, timeSeconds)); - } - - private resolveOrbitalAnchor(systemId: string, localPosition: THREE.Vector3): OrbitalAnchor { - if (!this.world) { - return { kind: "star" }; - } - - const system = this.world.systems.get(systemId); - if (!system) { - return { kind: "star" }; - } - - const nowSeconds = this.currentWorldTimeSeconds(); - let bestAnchor: OrbitalAnchor = { kind: "star" }; - let bestDistance = Number.POSITIVE_INFINITY; - - for (const [planetIndex, planet] of system.planets.entries()) { - const planetPosition = this.computePlanetLocalPosition(planet, nowSeconds); - const planetDistance = localPosition.distanceTo(planetPosition); - const planetThreshold = Math.max(planet.size * 10, 180); - if (planetDistance < planetThreshold && planetDistance < bestDistance) { - bestDistance = planetDistance; - bestAnchor = { kind: "planet", planetIndex }; - } - - const moonCount = Math.min(planet.moonCount, 12); - for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) { - const moonPosition = planetPosition.clone().add(this.computeMoonLocalPosition(planet, moonIndex, nowSeconds)); - const moonDistance = localPosition.distanceTo(moonPosition); - const moonThreshold = Math.max(this.computeMoonSize(planet, moonIndex) * 14, 80); - if (moonDistance < moonThreshold && moonDistance < bestDistance) { - bestDistance = moonDistance; - bestAnchor = { kind: "moon", planetIndex, moonIndex }; - } - } - } - - return bestAnchor; - } - - private resolveOrbitalAnchorPosition(systemId: string, anchor: OrbitalAnchor, timeSeconds: number) { - if (!this.world || anchor.kind === "star") { - return new THREE.Vector3(); - } - - const system = this.world.systems.get(systemId); - const planet = system?.planets[anchor.planetIndex]; - if (!system || !planet) { - return new THREE.Vector3(); - } - - const planetPosition = this.computePlanetLocalPosition(planet, timeSeconds); - if (anchor.kind === "planet") { - return planetPosition; - } - - return planetPosition.add(this.computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds)); - } - - private hashUnit(value: string) { - let hash = this.world?.seed ?? 1; - for (let index = 0; index < value.length; index += 1) { - hash = ((hash << 5) - hash) + value.charCodeAt(index); - hash |= 0; - } - - return (hash >>> 0) / 0xffffffff; - } - - private starHaloOpacity(starKind: string) { - if (starKind.includes("neutron")) { - return 0.22; - } - if (starKind.includes("white-dwarf")) { - return 0.18; - } - if (starKind.includes("brown-dwarf")) { - return 0.1; - } - return 0.14; - } - - private screenPointFromClient(clientX: number, clientY: number) { - const bounds = this.renderer.domElement.getBoundingClientRect(); - return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); - } - - private onPointerDown = (event: PointerEvent) => { - if (event.button === 1) { - this.dragMode = "orbit"; - this.dragPointerId = event.pointerId; - this.dragLast.copy(this.screenPointFromClient(event.clientX, event.clientY)); - this.renderer.domElement.setPointerCapture(event.pointerId); - return; - } - - if (event.button !== 0) { - return; - } - - this.dragMode = "marquee"; - this.dragPointerId = event.pointerId; - this.dragStart.copy(this.screenPointFromClient(event.clientX, event.clientY)); - this.dragLast.copy(this.dragStart); - this.marqueeActive = false; - this.renderer.domElement.setPointerCapture(event.pointerId); - }; - - private onPointerMove = (event: PointerEvent) => { - this.updateHoverLabel(event); - - if (this.dragPointerId !== event.pointerId || !this.dragMode) { - return; - } - - const point = this.screenPointFromClient(event.clientX, event.clientY); - if (this.dragMode === "orbit") { - const delta = point.clone().sub(this.dragLast); - this.orbitYaw += delta.x * 0.008; - this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch + delta.y * 0.004, 0.18, 1.3); - this.dragLast.copy(point); - return; - } - - const dragDistance = point.distanceTo(this.dragStart); - if (!this.marqueeActive && dragDistance > 8) { - this.marqueeActive = true; - this.suppressClickSelection = true; - this.marqueeEl.style.display = "block"; - } - - if (!this.marqueeActive) { - return; - } - - this.dragLast.copy(point); - this.updateMarqueeBox(); - }; - - private onPointerUp = (event: PointerEvent) => { - if (this.dragPointerId !== event.pointerId) { - return; - } - - if (this.renderer.domElement.hasPointerCapture(event.pointerId)) { - this.renderer.domElement.releasePointerCapture(event.pointerId); - } - - if (this.dragMode === "marquee" && this.marqueeActive) { - this.completeMarqueeSelection(); - this.hideMarqueeBox(); - } - - this.dragMode = undefined; - this.dragPointerId = undefined; - this.marqueeActive = false; - }; - - private onClick = (event: MouseEvent) => { - if (this.suppressClickSelection) { - this.suppressClickSelection = false; - return; - } - - const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); - this.selectedItems = picked ? [picked] : []; - this.syncFollowStateFromSelection(); - this.updatePanels(); - }; - - private onShipStripClick = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const historyButton = target.closest("[data-history-ship-id]"); - const historyShipId = historyButton?.dataset.historyShipId; - if (historyShipId) { - this.openHistoryWindow({ kind: "ship", id: historyShipId }); - return; - } - - const card = target.closest("[data-ship-id]"); - const shipId = card?.dataset.shipId; - if (!shipId) { - return; - } - - this.selectedItems = [{ kind: "ship", id: shipId }]; - this.syncFollowStateFromSelection(); - this.updatePanels(); - }; - - private onShipStripDoubleClick = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - if (target.closest("[data-history-ship-id]")) { - return; - } - - const card = target.closest("[data-ship-id]"); - const shipId = card?.dataset.shipId; - if (!shipId) { - return; - } - - this.selectedItems = [{ kind: "ship", id: shipId }]; - this.syncFollowStateFromSelection(); - this.focusOnSelection(this.selectedItems[0]); - this.toggleCameraMode("follow"); - this.updatePanels(); - this.updateGamePanel("Live"); - }; - - private openHistoryWindow(target: Selectable) { - const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target)); - if (existing) { - this.bringHistoryWindowToFront(existing); - this.refreshHistoryWindows(); - return; - } - - const id = `history-${++this.historyWindowCounter}`; - const root = document.createElement("aside"); - root.className = "history-window"; - root.dataset.historyWindowId = id; - root.innerHTML = ` -
-

History

-
- - -
-
-
No history selected.
- `; - - root.style.width = `${Math.min(520, window.innerWidth - 40)}px`; - root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`; - root.style.left = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerWidth - 580)))}px`; - root.style.top = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerHeight - 420)))}px`; - - const windowState: HistoryWindowState = { - id, - target, - root, - titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement, - bodyEl: root.querySelector(".history-window-body") as HTMLDivElement, - copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement, - text: "", - }; - - this.historyWindows.push(windowState); - this.historyLayerEl.append(root); - this.bringHistoryWindowToFront(windowState); - this.refreshHistoryWindows(); - } - - private refreshHistoryWindows() { - if (!this.world) { - return; - } - - for (const windowState of [...this.historyWindows]) { - if (windowState.target.kind === "ship") { - const ship = this.world.ships.get(windowState.target.id); - if (!ship) { - this.destroyHistoryWindow(windowState.id); - continue; - } - - windowState.titleEl.textContent = `${ship.label} History`; - windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet."; - windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); - continue; - } - - if (windowState.target.kind === "station") { - const station = this.world.stations.get(windowState.target.id); - if (!station) { - this.destroyHistoryWindow(windowState.id); - continue; - } - - windowState.titleEl.textContent = `${station.label} History`; - windowState.text = this.renderRecentEvents("station", station.id).replaceAll("
", "\n") || "No history yet."; - windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); - continue; - } - - this.destroyHistoryWindow(windowState.id); - } - } - - private destroyHistoryWindow(id: string) { - const index = this.historyWindows.findIndex((windowState) => windowState.id === id); - if (index < 0) { - return; - } - - const [removed] = this.historyWindows.splice(index, 1); - removed.root.remove(); - if (this.historyWindowDragId === id) { - this.historyWindowDragId = undefined; - this.historyWindowDragPointerId = undefined; - } - } - - private onHistoryLayerClick = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const windowEl = target.closest("[data-history-window-id]"); - const windowId = windowEl?.dataset.historyWindowId; - if (!windowId) { - return; - } - - const copyButton = target.closest(".history-window-copy"); - if (copyButton) { - void this.copyHistoryWindowContent(windowId); - return; - } - - const closeButton = target.closest(".history-window-close"); - if (closeButton) { - this.destroyHistoryWindow(windowId); - return; - } - - const windowState = this.historyWindows.find((candidate) => candidate.id === windowId); - if (windowState) { - this.bringHistoryWindowToFront(windowState); - } - }; - - private onHistoryLayerPointerDown = (event: PointerEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const windowEl = target.closest("[data-history-window-id]"); - const windowId = windowEl?.dataset.historyWindowId; - if (!windowEl || !windowId) { - return; - } - - const windowState = this.historyWindows.find((candidate) => candidate.id === windowId); - if (!windowState) { - return; - } - - this.bringHistoryWindowToFront(windowState); - if (!target.closest(".history-window-header") || target.closest("button")) { - return; - } - - const bounds = windowState.root.getBoundingClientRect(); - this.historyWindowDragId = windowId; - this.historyWindowDragPointerId = event.pointerId; - this.historyWindowDragOffset.set(event.clientX - bounds.left, event.clientY - bounds.top); - windowState.root.setPointerCapture?.(event.pointerId); - }; - - private onHistoryWindowPointerMove = (event: PointerEvent) => { - if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) { - return; - } - - const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId); - if (!windowState) { - return; - } - - const width = windowState.root.offsetWidth; - const height = windowState.root.offsetHeight; - const left = THREE.MathUtils.clamp(event.clientX - this.historyWindowDragOffset.x, 20, window.innerWidth - width - 20); - const top = THREE.MathUtils.clamp(event.clientY - this.historyWindowDragOffset.y, 20, window.innerHeight - height - 20); - - windowState.root.style.left = `${left}px`; - windowState.root.style.top = `${top}px`; - }; - - private onHistoryWindowPointerUp = (event: PointerEvent) => { - if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) { - return; - } - - const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId); - this.historyWindowDragPointerId = undefined; - this.historyWindowDragId = undefined; - windowState?.root.releasePointerCapture?.(event.pointerId); - }; - - private async copyHistoryWindowContent(windowId: string) { - const windowState = this.historyWindows.find((candidate) => candidate.id === windowId); - if (!windowState?.text) { - return; - } - - try { - await this.copyTextToClipboard(windowState.text); - windowState.copyButtonEl.textContent = "Copied"; - window.setTimeout(() => { - windowState.copyButtonEl.textContent = "Copy"; - }, 1200); - } catch { - windowState.copyButtonEl.textContent = "Failed"; - window.setTimeout(() => { - windowState.copyButtonEl.textContent = "Copy"; - }, 1200); - } - } - - private async copyTextToClipboard(text: string) { - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text); - return; - } catch { - } - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", "true"); - textarea.style.position = "fixed"; - textarea.style.top = "0"; - textarea.style.left = "0"; - textarea.style.width = "1px"; - textarea.style.height = "1px"; - textarea.style.opacity = "0"; - document.body.append(textarea); - textarea.focus(); - textarea.select(); - - try { - const copied = document.execCommand("copy"); - if (!copied) { - throw new Error("execCommand copy failed"); - } - } finally { - textarea.remove(); - } - } - - private bringHistoryWindowToFront(windowState: HistoryWindowState) { - windowState.root.style.zIndex = `${++this.historyWindowZCounter}`; - } - - private updateHoverLabel(event: PointerEvent) { - if (this.dragMode) { - this.hoverLabelEl.hidden = true; - return; - } - - const selection = this.pickSelectableAtClientPosition(event.clientX, event.clientY); - if (!selection || selection.kind !== "system" || selection.id === this.activeSystemId) { - this.hoverLabelEl.hidden = true; - return; - } - - const system = this.world?.systems.get(selection.id); - if (!system) { - this.hoverLabelEl.hidden = true; - return; - } - - this.hoverLabelEl.hidden = false; - this.hoverLabelEl.textContent = system.label; - const point = this.screenPointFromClient(event.clientX, event.clientY); - this.hoverLabelEl.style.left = `${point.x + 14}px`; - this.hoverLabelEl.style.top = `${point.y + 14}px`; - } - - private pickSelectableAtClientPosition(clientX: number, clientY: number) { - const bounds = this.renderer.domElement.getBoundingClientRect(); - this.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; - this.mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1); - this.raycaster.setFromCamera(this.mouse, this.camera); - const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0]; - return hit ? this.selectableTargets.get(hit.object) : undefined; - } - - private onDoubleClick = () => { - if (this.selectedItems.length !== 1) { - return; - } - this.focusOnSelection(this.selectedItems[0]); - this.syncFollowStateFromSelection(); - }; - - private onWheel = (event: WheelEvent) => { - event.preventDefault(); - const deltaY = THREE.MathUtils.clamp(event.deltaY, -180, 180); - const zoomFactor = Math.exp(deltaY * 0.00135); - this.desiredDistance = THREE.MathUtils.clamp(this.desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); - this.updateGamePanel("Live"); - }; - - private onKeyDown = (event: KeyboardEvent) => { - if (event.repeat) { - return; - } - const key = event.key.toLowerCase(); - this.keyState.add(key); - if (["w", "a", "s", "d"].includes(key)) { - this.cameraMode = "tactical"; - } - if (key === "c") { - this.toggleCameraMode(); - } - if (key === "1") { - this.desiredDistance = ZOOM_DISTANCE.local; - } else if (key === "2") { - this.desiredDistance = ZOOM_DISTANCE.system; - } else if (key === "3") { - this.desiredDistance = ZOOM_DISTANCE.universe; - } - this.updateGamePanel("Live"); - }; - - private onKeyUp = (event: KeyboardEvent) => { - this.keyState.delete(event.key.toLowerCase()); - }; - - private toggleCameraMode(forceMode?: CameraMode) { - const nextMode = forceMode ?? (this.cameraMode === "follow" ? "tactical" : "follow"); - if (nextMode === "tactical") { - this.cameraMode = "tactical"; - return; - } - - if (!this.cameraTargetShipId && this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { - this.cameraTargetShipId = this.selectedItems[0].id; - } - - if (!this.cameraTargetShipId) { - return; - } - - this.cameraMode = "follow"; - this.desiredDistance = Math.min(this.desiredDistance, 1800); - this.followCameraPosition.set(0, 0, 0); - this.followCameraFocus.set(0, 0, 0); - } - - private focusOnSelection(selection: Selectable) { - const nextFocus = this.resolveSelectionPosition(selection); - if (!nextFocus) { - return; - } - - const selectionSystemId = this.resolveSelectableSystemId(selection); - if (selectionSystemId && selection.kind !== "system" && this.world) { - const system = this.world.systems.get(selectionSystemId); - if (system) { - this.galaxyFocus.copy(this.toThreeVector(system.galaxyPosition)); - this.systemFocusLocal.copy(nextFocus); - return; - } - } - - if (this.activeSystemId && this.isSelectionInActiveSystem(selection)) { - this.systemFocusLocal.copy(nextFocus); - return; - } - - this.galaxyFocus.copy(nextFocus); - } - - private resolveSelectionPosition(selection: Selectable) { - if (!this.world) { - return undefined; - } - - if (selection.kind === "ship") { - const ship = this.world.ships.get(selection.id); - return ship ? this.toThreeVector(ship.localPosition) : undefined; - } - if (selection.kind === "station") { - const station = this.world.stations.get(selection.id); - return station ? this.toThreeVector(station.localPosition) : undefined; - } - if (selection.kind === "node") { - const node = this.world.nodes.get(selection.id); - const visual = node ? this.nodeVisuals.get(node.id) : undefined; - return visual - ? 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]; - if (!system || !planet) { - return undefined; - } - const visual = this.planetVisuals.find((candidate) => - candidate.systemId === selection.systemId && candidate.planet === planet); - return visual?.mesh.position.clone() ?? this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds()); - } - const system = this.world.systems.get(selection.id); - return system ? this.toThreeVector(system.galaxyPosition) : undefined; - } - - private updateMarqueeBox() { - const minX = Math.min(this.dragStart.x, this.dragLast.x); - const minY = Math.min(this.dragStart.y, this.dragLast.y); - const maxX = Math.max(this.dragStart.x, this.dragLast.x); - const maxY = Math.max(this.dragStart.y, this.dragLast.y); - this.marqueeEl.style.left = `${minX}px`; - this.marqueeEl.style.top = `${minY}px`; - this.marqueeEl.style.width = `${maxX - minX}px`; - this.marqueeEl.style.height = `${maxY - minY}px`; - } - - private hideMarqueeBox() { - this.marqueeEl.style.display = "none"; - this.marqueeEl.style.width = "0"; - this.marqueeEl.style.height = "0"; - } - - private completeMarqueeSelection() { - const bounds = this.renderer.domElement.getBoundingClientRect(); - const minX = Math.min(this.dragStart.x, this.dragLast.x); - const minY = Math.min(this.dragStart.y, this.dragLast.y); - const maxX = Math.max(this.dragStart.x, this.dragLast.x); - const maxY = Math.max(this.dragStart.y, this.dragLast.y); - const grouped = new Map(); - - for (const [object, selectable] of this.selectableTargets.entries()) { - if (object instanceof THREE.Sprite && !object.visible) { - continue; - } - if (!object.visible) { - continue; - } - const worldPosition = new THREE.Vector3(); - object.getWorldPosition(worldPosition); - worldPosition.project(this.camera); - const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width; - const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height; - if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) { - continue; - } - const group = this.getSelectionGroup(selectable); - const list = grouped.get(group) ?? []; - if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) { - list.push(selectable); - } - grouped.set(group, list); - } - - const selection = [...grouped.entries()] - .sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? []; - this.selectedItems = selection; - this.syncFollowStateFromSelection(); - this.updatePanels(); - } - - private shipSize(ship: ShipSnapshot) { - switch (ship.shipClass) { - case "capital": - return 18; - case "cruiser": - return 13; - case "destroyer": - return 10; - case "industrial": - return 11; - default: - return 8; - } - } - - private shipLength(ship: ShipSnapshot) { - return this.shipSize(ship) * 2.6; - } - - private shipColor(role: ShipSnapshot["role"]) { - if (role === "mining") { - return "#ffcf6e"; - } - if (role === "transport") { - return "#9ff0aa"; - } - 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; - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height); - }; - - private updateActiveSystem() { - const nextActiveSystemId = this.determineActiveSystemId(); - if (nextActiveSystemId === this.activeSystemId) { - return; - } - - if (nextActiveSystemId) { - this.seedSystemFocusLocal(nextActiveSystemId); - } - this.activeSystemId = nextActiveSystemId; - this.updateSystemDetailVisibility(); - this.updatePanels(); - this.updateGamePanel("Live"); - } - - private determineActiveSystemId() { - if (!this.world) { - return undefined; - } - - if (this.cameraMode === "follow" && this.cameraTargetShipId) { - return this.world.ships.get(this.cameraTargetShipId)?.systemId; - } - - if (this.currentDistance >= 12000) { - return undefined; - } - - const selected = this.selectedItems[0]; - if (selected && this.selectedItems.length === 1) { - if (selected.kind === "system") { - return selected.id; - } - if (selected.kind === "planet") { - return selected.systemId; - } - const selectedSystemId = this.resolveSelectableSystemId(selected); - if (selectedSystemId) { - return selectedSystemId; - } - } - - let nearestSystemId: string | undefined; - let nearestDistance = Number.POSITIVE_INFINITY; - for (const system of this.world.systems.values()) { - const center = this.toThreeVector(system.galaxyPosition); - const distance = center.distanceTo(this.galaxyFocus); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestSystemId = system.id; - } - } - - return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, this.currentDistance * 2.2) - ? nearestSystemId - : undefined; - } - - private updateFollowCamera(delta: number) { - if (!this.cameraTargetShipId || !this.world) { - this.cameraMode = "tactical"; - return false; - } - - const ship = this.world.ships.get(this.cameraTargetShipId); - const visual = this.shipVisuals.get(this.cameraTargetShipId); - if (!ship || !visual) { - this.cameraTargetShipId = undefined; - this.cameraMode = "tactical"; - return false; - } - - const shipLocalPosition = this.getAnimatedShipLocalPosition(visual); - const shipWorldPosition = this.toDisplayLocalPosition(shipLocalPosition, ship.systemId); - this.systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); - - this.followCameraDesiredDirection.copy(this.resolveShipHeading(visual, shipLocalPosition)).normalize(); - this.followCameraDirection.lerp(this.followCameraDesiredDirection, 1 - Math.exp(-delta * 5)); - this.followCameraDirection.normalize(); - - const distance = THREE.MathUtils.clamp(this.currentDistance * 0.72, 320, 6800); - const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100); - const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400); - this.followCameraOffset.copy(this.followCameraDirection).multiplyScalar(-distance); - this.followCameraOffset.y += height; - - const desiredPosition = shipWorldPosition.clone().add(this.followCameraOffset); - const desiredFocus = shipWorldPosition.clone().addScaledVector(this.followCameraDirection, lookAhead); - desiredFocus.y += height * 0.28; - - const positionLerp = 1 - Math.exp(-delta * 6); - const focusLerp = 1 - Math.exp(-delta * 8); - if (this.followCameraPosition.lengthSq() === 0) { - this.followCameraPosition.copy(desiredPosition); - this.followCameraFocus.copy(desiredFocus); - } else { - this.followCameraPosition.lerp(desiredPosition, positionLerp); - this.followCameraFocus.lerp(desiredFocus, focusLerp); - } - - this.camera.position.copy(this.followCameraPosition); - this.camera.lookAt(this.followCameraFocus); - return true; - } - - private syncFollowStateFromSelection() { - if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { - this.cameraTargetShipId = this.selectedItems[0].id; - return; - } - - this.cameraTargetShipId = undefined; - if (this.cameraMode === "follow") { - this.cameraMode = "tactical"; - } - } - - private updateSystemDetailVisibility() { - for (const [systemId, visual] of this.systemVisuals.entries()) { - visual.detailGroup.visible = systemId === this.activeSystemId; - } - } - - private updateSystemStarPresentation() { - const activeSystem = this.activeSystemId ? this.systemVisuals.get(this.activeSystemId) : undefined; - - for (const [systemId, visual] of this.systemVisuals.entries()) { - visual.root.position.copy(visual.galaxyPosition); - visual.shellReticle.scale.setScalar(visual.shellReticleBaseScale); - - if (!activeSystem) { - visual.starCluster.position.set(0, 0, 0); - visual.icon.position.set(0, 0, 0); - visual.icon.visible = true; - visual.shellReticle.position.set(0, 0, 0); - visual.shellReticle.visible = false; - this.setShellReticleOpacity(visual.shellReticle, 0); - continue; - } - - if (systemId !== this.activeSystemId) { - visual.starCluster.position.set(0, 0, 0); - visual.icon.position.set(0, 0, 0); - visual.icon.visible = false; - visual.shellReticle.position.set(0, 0, 0); - visual.shellReticle.visible = true; - this.setShellReticleOpacity(visual.shellReticle, 1); - const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); - if (direction.lengthSq() > 0.0001) { - visual.root.position.copy( - activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), - ); - } - const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); - const reticleDistance = this.camera.position.distanceTo(reticleWorldPosition); - const reticleScale = Math.max(900, reticleDistance * 0.032); - visual.shellReticle.scale.setScalar(reticleScale); - continue; - } - - const offset = this.systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); - visual.starCluster.position.copy(offset); - visual.icon.position.copy(offset); - visual.icon.visible = true; - visual.shellReticle.visible = false; - this.setShellReticleOpacity(visual.shellReticle, 0); - } - } - - private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { - sprite.visible = opacity > 0.02; - sprite.material.opacity = opacity; - sprite.material.needsUpdate = true; - } - - private resolveSelectableSystemId(selection: Selectable) { - if (!this.world) { - return undefined; - } - - if (selection.kind === "ship") { - return this.world.ships.get(selection.id)?.systemId; - } - if (selection.kind === "station") { - return this.world.stations.get(selection.id)?.systemId; - } - 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; - } - return selection.id; - } - - private describeSelectionParent(selection: Selectable) { - if (!this.world) { - return "unknown"; - } - - if (selection.kind === "system") { - return "galaxy"; - } - - if (selection.kind === "planet") { - const system = this.world.systems.get(selection.systemId); - return system ? `${system.label} star` : selection.systemId; - } - - if (selection.kind === "ship") { - const ship = this.world.ships.get(selection.id); - if (!ship) { - return "unknown"; - } - const system = this.world.systems.get(ship.systemId); - return system ? `${system.label} system` : ship.systemId; - } - - if (selection.kind === "station") { - const station = this.world.stations.get(selection.id); - 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"; - } - - return "unknown"; - } - - private describeOrbitalParent(systemId?: string, anchor?: OrbitalAnchor) { - if (!this.world || !systemId) { - return "unknown"; - } - - const system = this.world.systems.get(systemId); - if (!system) { - return systemId; - } - - if (!anchor || anchor.kind === "star") { - return `${system.label} star`; - } - - const planet = system.planets[anchor.planetIndex]; - if (!planet) { - return `${system.label} star`; - } - - if (anchor.kind === "planet") { - return planet.label; - } - - return `${planet.label} moon ${anchor.moonIndex + 1}`; - } - - private isSelectionInActiveSystem(selection: Selectable) { - return !!this.activeSystemId && this.resolveSelectableSystemId(selection) === this.activeSystemId; - } - - private getCameraFocusWorldPosition() { - if (!this.activeSystemId || !this.world) { - return this.galaxyFocus; - } - - const system = this.world.systems.get(this.activeSystemId); - return system - ? this.toThreeVector(system.galaxyPosition).add(this.systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR)) - : this.galaxyFocus; - } - - private seedSystemFocusLocal(systemId: string) { - if (!this.world) { - return; - } - - if (this.cameraMode === "follow" && this.cameraTargetShipId) { - const followedShip = this.world.ships.get(this.cameraTargetShipId); - if (followedShip?.systemId === systemId) { - this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition)); - return; - } - } - - const selected = this.selectedItems[0]; - if (selected && this.resolveSelectableSystemId(selected) === systemId) { - const selectedPosition = this.resolveSelectionPosition(selected); - if (selectedPosition) { - this.systemFocusLocal.copy(selectedPosition); - return; - } - } - - this.systemFocusLocal.set(0, 0, 0); - } - - private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { - if (!this.world || !systemId) { - return localPosition.clone(); - } - - const system = this.world.systems.get(systemId); - if (!system) { - return localPosition.clone(); - } - - const center = this.toThreeVector(system.galaxyPosition); - if (systemId !== this.activeSystemId) { - return center.clone().add(localPosition); - } - - return center.clone().add(localPosition.clone().sub(this.systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE)); - } - - private renderSystemDetails(system: SystemSnapshot, activeContext: boolean) { - if (!this.world) { - return ""; - } - - 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()) { - if (ship.systemId === system.id) { - shipCount += 1; - } - } - for (const station of this.world.stations.values()) { - if (station.systemId === system.id) { - stationCount += 1; - } - } - for (const node of this.world.nodes.values()) { - if (node.systemId === system.id) { - 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; - } - - const followText = activeContext && this.cameraMode === "follow" && this.cameraTargetShipId - ? `

Camera locked to ${this.world.ships.get(this.cameraTargetShipId)?.label ?? this.cameraTargetShipId}

` - : ""; - - 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}

-

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} - `; - } - - private updateSystemPanel() { - if (!this.world) { - return; - } - - const activeSystem = this.activeSystemId ? this.world.systems.get(this.activeSystemId) : undefined; - const showSystemPanel = !!activeSystem; - this.systemPanelEl.hidden = !showSystemPanel; - - if (!activeSystem) { - this.systemTitleEl.textContent = "Deep Space"; - this.systemBodyEl.innerHTML = ""; - return; - } - - this.systemTitleEl.textContent = activeSystem.label; - this.systemBodyEl.innerHTML = ""; + await this.controller.start(); } } diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts new file mode 100644 index 0000000..192ad96 --- /dev/null +++ b/apps/viewer/src/ViewerAppController.ts @@ -0,0 +1,362 @@ +import * as THREE from "three"; +import { + MAX_CAMERA_DISTANCE, + MIN_CAMERA_DISTANCE, + ZOOM_DISTANCE, +} from "./viewerConstants"; +import { createViewerHud } from "./viewerHud"; +import { + classifyZoomLevel, + computeZoomBlend, + formatBytes, + inventoryAmount, + smoothBand, +} from "./viewerMath"; +import { updatePanFromKeyboard } from "./viewerCamera"; +import { + createCirclePoints, + shipLength, + shipPresentationColor, + shipSize, + spatialNodeColor, +} from "./viewerSceneAppearance"; +import { + createBackdropStars, + createNebulaClouds, + createNebulaTexture, +} from "./viewerSceneFactory"; +import { + setShellReticleOpacity, +} from "./viewerControls"; +import { + recordPerformanceStats, + updateNetworkPanel as renderNetworkPanel, + updatePerformancePanel as renderPerformancePanel, +} from "./viewerTelemetry"; +import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop"; +import { updatePlanetPresentation } from "./viewerPresentation"; +import { + renderRecentEvents, + updateGameStatus, + updateSystemSummaries, + updateWorldPresentation, +} from "./viewerWorldPresentation"; +import { + resolveFocusedBubbleId, +} from "./viewerSelection"; +import { describeSelectionParent, updateSystemPanel } from "./viewerPanels"; +import { + createInitialNetworkStats, + createInitialPerformanceStats, +} from "./viewerState"; +import { ViewerWorldLifecycle } from "./viewerWorldLifecycle"; +import { ViewerInteractionController } from "./viewerInteractionController"; +import { ViewerNavigationController } from "./viewerNavigationController"; +import { ViewerSceneDataController } from "./viewerSceneDataController"; +import { ViewerPresentationController } from "./viewerPresentationController"; +import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory"; +import type { FactionSnapshot, ShipSnapshot } from "./contracts"; +import type { + BubbleVisual, + CameraMode, + ClaimVisual, + ConstructionSiteVisual, + DragMode, + HistoryWindowState, + MoonVisual, + NetworkStats, + NodeVisual, + OrbitalAnchor, + PerformanceStats, + PlanetVisual, + PresentationEntry, + Selectable, + ShipVisual, + SpatialNodeVisual, + StructureVisual, + SystemSummaryVisual, + SystemVisual, + WorldState, + ZoomLevel, +} from "./viewerTypes"; + +export class ViewerAppController { + private readonly container: HTMLElement; + private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); + private readonly scene = new THREE.Scene(); + private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000); + private readonly clock = new THREE.Clock(); + private readonly raycaster = new THREE.Raycaster(); + private readonly mouse = new THREE.Vector2(); + private readonly galaxyFocus = new THREE.Vector3(2200, 0, 300); + private readonly systemFocusLocal = new THREE.Vector3(); + 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(); + private readonly planetVisuals: PlanetVisual[] = []; + private readonly orbitLines: THREE.Object3D[] = []; + private readonly statusEl: HTMLDivElement; + private readonly systemPanelEl: HTMLDivElement; + private readonly systemTitleEl: HTMLHeadingElement; + private readonly systemBodyEl: HTMLDivElement; + private readonly detailTitleEl: HTMLHeadingElement; + private readonly detailBodyEl: HTMLDivElement; + private readonly factionStripEl: HTMLDivElement; + private readonly networkPanelEl: HTMLDivElement; + private readonly performancePanelEl: HTMLDivElement; + private readonly errorEl: HTMLDivElement; + private readonly historyLayerEl: HTMLDivElement; + private readonly marqueeEl: HTMLDivElement; + private readonly hoverLabelEl: HTMLDivElement; + + private world?: WorldState; + private worldTimeSyncMs = performance.now(); + private stream?: EventSource; + private currentStreamScopeKey = ""; + private readonly networkStats: NetworkStats = createInitialNetworkStats(); + private readonly performanceStats: PerformanceStats = createInitialPerformanceStats(); + + private selectedItems: Selectable[] = []; + private worldSignature = ""; + private zoomLevel: ZoomLevel = "system"; + private currentDistance = ZOOM_DISTANCE.system; + private desiredDistance = ZOOM_DISTANCE.system; + private orbitYaw = -2.3; + private orbitPitch = 0.62; + private cameraMode: CameraMode = "tactical"; + private dragMode?: DragMode; + private dragPointerId?: number; + private dragStart = new THREE.Vector2(); + private dragLast = new THREE.Vector2(); + private marqueeActive = false; + private suppressClickSelection = false; + private activeSystemId?: string; + private cameraTargetShipId?: string; + private readonly followCameraPosition = new THREE.Vector3(); + private readonly followCameraFocus = new THREE.Vector3(); + private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1); + private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1); + private readonly followCameraOffset = new THREE.Vector3(); + private readonly historyWindows: HistoryWindowState[] = []; + private historyWindowCounter = 0; + private historyWindowZCounter = 10; + private historyWindowDragId?: string; + private historyWindowDragPointerId?: number; + private historyWindowDragOffset = new THREE.Vector2(); + private readonly worldLifecycle: ViewerWorldLifecycle; + private readonly interactionController: ViewerInteractionController; + private readonly navigationController: ViewerNavigationController; + private readonly sceneDataController: ViewerSceneDataController; + private readonly presentationController: ViewerPresentationController; + + constructor(container: HTMLElement) { + this.container = container; + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + + this.scene.background = new THREE.Color(0x040912); + this.scene.fog = new THREE.FogExp2(0x040912, 0.00011); + this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55)); + const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); + keyLight.position.set(1000, 1200, 800); + this.scene.add(keyLight); + const hud = createViewerHud(document); + this.statusEl = hud.statusEl; + this.systemPanelEl = hud.systemPanelEl; + this.systemTitleEl = hud.systemTitleEl; + this.systemBodyEl = hud.systemBodyEl; + this.detailTitleEl = hud.detailTitleEl; + this.detailBodyEl = hud.detailBodyEl; + this.factionStripEl = hud.factionStripEl; + this.networkPanelEl = hud.networkPanelEl; + this.performancePanelEl = hud.performancePanelEl; + this.errorEl = hud.errorEl; + this.historyLayerEl = hud.historyLayerEl; + this.marqueeEl = hud.marqueeEl; + this.hoverLabelEl = hud.hoverLabelEl; + ({ + sceneDataController: this.sceneDataController, + navigationController: this.navigationController, + presentationController: this.presentationController, + worldLifecycle: this.worldLifecycle, + interactionController: this.interactionController, + } = createViewerControllers(this)); + + this.container.append(this.renderer.domElement, hud.root); + wireViewerEvents(this); + this.onResize(); + this.updateCamera(0); + } + + async start() { + await this.worldLifecycle.bootstrapWorld(); + this.renderer.setAnimationLoop(() => this.render()); + } + + private refreshStreamScopeIfNeeded() { + this.worldLifecycle.refreshStreamScopeIfNeeded(); + } + + private createWorldPresentationContext() { + return this.sceneDataController.createWorldPresentationContext({ + world: this.world, + activeSystemId: this.activeSystemId, + orbitYaw: this.orbitYaw, + camera: this.camera, + systemFocusLocal: this.systemFocusLocal, + toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this), + updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(), + setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity), + }); + } + + private rebuildFactions(_factions: FactionSnapshot[]) { + this.worldLifecycle.rebuildFactions(_factions); + } + + private updatePanels() { + this.worldLifecycle.updatePanels(); + } + + private render() { + renderFrame({ + clock: this.clock, + renderer: this.renderer, + scene: this.scene, + camera: this.camera, + updateCamera: (delta) => this.updateCamera(delta), + updateAmbience: (delta) => this.presentationController.updateAmbience(delta), + updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(), + updateShipPresentation: () => this.presentationController.updateShipPresentation(), + updateNetworkPanel: () => this.presentationController.updateNetworkPanel(), + applyZoomPresentation: () => this.presentationController.applyZoomPresentation(), + recordPerformanceStats: (frameMs) => this.presentationController.recordPerformanceStats(frameMs), + updatePerformancePanel: () => this.presentationController.updatePerformancePanel(), + }); + } + + private updateAmbience(delta: number) { + this.ambienceGroup.position.copy(this.camera.position); + this.ambienceGroup.rotation.y += delta * 0.005; + this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; + } + + private updateCamera(delta: number) { + const nextState = stepCamera({ + currentDistance: this.currentDistance, + desiredDistance: this.desiredDistance, + orbitPitch: this.orbitPitch, + delta, + }); + this.currentDistance = nextState.currentDistance; + this.zoomLevel = nextState.zoomLevel; + this.orbitPitch = nextState.orbitPitch; + this.navigationController.updateActiveSystem(); + if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { + return; + } + + this.updatePanFromKeyboard(delta); + this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); + + const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); + const focus = this.navigationController.getCameraFocusWorldPosition(); + this.cameraOffset.set( + Math.cos(this.orbitYaw) * horizontalDistance, + this.currentDistance * Math.sin(this.orbitPitch), + Math.sin(this.orbitYaw) * horizontalDistance, + ); + this.camera.position.copy(focus).add(this.cameraOffset); + this.camera.lookAt(focus); + } + + private updatePanFromKeyboard(delta: number) { + updatePanFromKeyboard( + this.keyState, + this.orbitYaw, + this.currentDistance, + this.activeSystemId, + this.systemFocusLocal, + this.galaxyFocus, + delta, + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ); + } + + private updateSystemSummaries() { + this.presentationController.updateSystemSummaries(); + } + + private registerPresentation( + detail: THREE.Object3D, + icon: THREE.Sprite, + hideDetailInUniverse: boolean, + hideIconInUniverse = false, + systemId?: string, + ) { + this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse }); + } + + private renderRecentEvents(entityKind: string, entityId: string) { + return this.presentationController.renderRecentEvents(entityKind, entityId); + } + + private updateGamePanel(mode: string) { + this.presentationController.updateGamePanel(mode); + } + + private screenPointFromClient(clientX: number, clientY: number) { + return this.presentationController.screenPointFromClient(clientX, clientY); + } + + private refreshHistoryWindows() { + this.interactionController.refreshHistoryWindows(); + } + + private resolveFocusedBubbleId() { + return resolveFocusedBubbleId(this.world, this.selectedItems); + } + + private onResize = () => { + resizeViewer({ + renderer: this.renderer, + camera: this.camera, + }); + }; + + private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { + setShellReticleOpacity(sprite, opacity); + } + + private describeSelectionParent(selection: Selectable) { + return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals); + } + + private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { + return this.navigationController.toDisplayLocalPosition(localPosition, systemId); + } + + private updateSystemPanel() { + this.presentationController.updateSystemPanel(); + } +} diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 29c99da..ae70b51 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -1,290 +1,38 @@ -export interface WorldSnapshot { - label: string; - seed: number; - sequence: number; - 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[]; -} - -export interface WorldDelta { - sequence: number; - tickIntervalMs: number; - 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 { - entityKind: string; - entityId: string; - 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 { - x: number; - y: number; - z: number; -} - -export interface SystemSnapshot { - id: string; - label: string; - galaxyPosition: Vector3Dto; - starKind: string; - starCount: number; - starColor: string; - starSize: number; - planets: PlanetSnapshot[]; -} - -export interface PlanetSnapshot { - label: string; - planetType: string; - shape: string; - moonCount: number; - orbitRadius: number; - orbitSpeed: number; - orbitEccentricity: number; - orbitInclination: number; - orbitLongitudeOfAscendingNode: number; - orbitArgumentOfPeriapsis: number; - orbitPhaseAtEpoch: number; - size: number; - color: string; - hasRing: boolean; -} - -export interface ResourceNodeSnapshot { - id: string; - systemId: string; - localPosition: Vector3Dto; - sourceKind: string; - oreRemaining: number; - maxOre: number; - itemId: string; -} - -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; -} - -export interface StationSnapshot { - id: string; - label: string; - 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; - role: string; - shipClass: string; - systemId: string; - localPosition: Vector3Dto; - localVelocity: Vector3Dto; - targetLocalPosition: Vector3Dto; - state: string; - 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 {} +export type { Vector3Dto, InventoryEntry } from "./contractsCommon"; +export type { + WorldSnapshot, + WorldDelta, + SimulationEventRecord, + ObserverScope, +} from "./contractsWorld"; +export type { + SystemSnapshot, + PlanetSnapshot, + ResourceNodeSnapshot, + ResourceNodeDelta, + SpatialNodeSnapshot, + SpatialNodeDelta, + LocalBubbleSnapshot, + LocalBubbleDelta, +} from "./contractsCelestial"; +export type { + StationSnapshot, + StationDelta, + ClaimSnapshot, + ClaimDelta, + ConstructionSiteSnapshot, + ConstructionSiteDelta, +} from "./contractsInfrastructure"; +export type { + MarketOrderSnapshot, + MarketOrderDelta, + PolicySetSnapshot, + PolicySetDelta, +} from "./contractsEconomy"; +export type { + ShipSnapshot, + ShipDelta, + ShipSpatialStateSnapshot, + ShipTransitSnapshot, +} from "./contractsShips"; +export type { FactionSnapshot, FactionDelta } from "./contractsFactions"; diff --git a/apps/viewer/src/contractsCelestial.ts b/apps/viewer/src/contractsCelestial.ts new file mode 100644 index 0000000..b1b2771 --- /dev/null +++ b/apps/viewer/src/contractsCelestial.ts @@ -0,0 +1,67 @@ +import type { Vector3Dto } from "./contractsCommon"; + +export interface SystemSnapshot { + id: string; + label: string; + galaxyPosition: Vector3Dto; + starKind: string; + starCount: number; + starColor: string; + starSize: number; + planets: PlanetSnapshot[]; +} + +export interface PlanetSnapshot { + label: string; + planetType: string; + shape: string; + moonCount: number; + orbitRadius: number; + orbitSpeed: number; + orbitEccentricity: number; + orbitInclination: number; + orbitLongitudeOfAscendingNode: number; + orbitArgumentOfPeriapsis: number; + orbitPhaseAtEpoch: number; + size: number; + color: string; + hasRing: boolean; +} + +export interface ResourceNodeSnapshot { + id: string; + systemId: string; + localPosition: Vector3Dto; + sourceKind: string; + oreRemaining: number; + maxOre: number; + itemId: string; +} + +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 {} diff --git a/apps/viewer/src/contractsCommon.ts b/apps/viewer/src/contractsCommon.ts new file mode 100644 index 0000000..4f0b4e6 --- /dev/null +++ b/apps/viewer/src/contractsCommon.ts @@ -0,0 +1,10 @@ +export interface Vector3Dto { + x: number; + y: number; + z: number; +} + +export interface InventoryEntry { + itemId: string; + amount: number; +} diff --git a/apps/viewer/src/contractsEconomy.ts b/apps/viewer/src/contractsEconomy.ts new file mode 100644 index 0000000..95a2ce7 --- /dev/null +++ b/apps/viewer/src/contractsEconomy.ts @@ -0,0 +1,28 @@ +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 {} diff --git a/apps/viewer/src/contractsFactions.ts b/apps/viewer/src/contractsFactions.ts new file mode 100644 index 0000000..332fa12 --- /dev/null +++ b/apps/viewer/src/contractsFactions.ts @@ -0,0 +1,14 @@ +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 {} diff --git a/apps/viewer/src/contractsInfrastructure.ts b/apps/viewer/src/contractsInfrastructure.ts new file mode 100644 index 0000000..3977cbe --- /dev/null +++ b/apps/viewer/src/contractsInfrastructure.ts @@ -0,0 +1,64 @@ +import type { InventoryEntry, Vector3Dto } from "./contractsCommon"; + +export interface StationSnapshot { + id: string; + label: string; + 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 {} diff --git a/apps/viewer/src/contractsShips.ts b/apps/viewer/src/contractsShips.ts new file mode 100644 index 0000000..d7dfce4 --- /dev/null +++ b/apps/viewer/src/contractsShips.ts @@ -0,0 +1,52 @@ +import type { InventoryEntry, Vector3Dto } from "./contractsCommon"; + +export interface ShipSnapshot { + id: string; + label: string; + role: string; + shipClass: string; + systemId: string; + localPosition: Vector3Dto; + localVelocity: Vector3Dto; + targetLocalPosition: Vector3Dto; + state: string; + 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; +} diff --git a/apps/viewer/src/contractsWorld.ts b/apps/viewer/src/contractsWorld.ts new file mode 100644 index 0000000..273f09f --- /dev/null +++ b/apps/viewer/src/contractsWorld.ts @@ -0,0 +1,85 @@ +import type { + ClaimDelta, + ClaimSnapshot, + ConstructionSiteDelta, + ConstructionSiteSnapshot, +} from "./contractsInfrastructure"; +import type { + FactionDelta, + FactionSnapshot, +} from "./contractsFactions"; +import type { + LocalBubbleDelta, + LocalBubbleSnapshot, + ResourceNodeDelta, + ResourceNodeSnapshot, + SpatialNodeDelta, + SpatialNodeSnapshot, + SystemSnapshot, +} from "./contractsCelestial"; +import type { + MarketOrderDelta, + PolicySetDelta, + PolicySetSnapshot, + MarketOrderSnapshot, +} from "./contractsEconomy"; +import type { + ShipDelta, + ShipSnapshot, +} from "./contractsShips"; + +export interface WorldSnapshot { + label: string; + seed: number; + sequence: number; + tickIntervalMs: number; + generatedAtUtc: string; + systems: SystemSnapshot[]; + spatialNodes: SpatialNodeSnapshot[]; + localBubbles: LocalBubbleSnapshot[]; + nodes: ResourceNodeSnapshot[]; + stations: import("./contractsInfrastructure").StationSnapshot[]; + claims: ClaimSnapshot[]; + constructionSites: ConstructionSiteSnapshot[]; + marketOrders: MarketOrderSnapshot[]; + policies: PolicySetSnapshot[]; + ships: ShipSnapshot[]; + factions: FactionSnapshot[]; +} + +export interface WorldDelta { + sequence: number; + tickIntervalMs: number; + generatedAtUtc: string; + requiresSnapshotRefresh: boolean; + events: SimulationEventRecord[]; + spatialNodes: SpatialNodeDelta[]; + localBubbles: LocalBubbleDelta[]; + nodes: ResourceNodeDelta[]; + stations: import("./contractsInfrastructure").StationDelta[]; + claims: ClaimDelta[]; + constructionSites: ConstructionSiteDelta[]; + marketOrders: MarketOrderDelta[]; + policies: PolicySetDelta[]; + ships: ShipDelta[]; + factions: FactionDelta[]; + scope?: ObserverScope | null; +} + +export interface SimulationEventRecord { + entityKind: string; + entityId: string; + 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; +} diff --git a/apps/viewer/src/viewerCamera.ts b/apps/viewer/src/viewerCamera.ts new file mode 100644 index 0000000..3ebfbca --- /dev/null +++ b/apps/viewer/src/viewerCamera.ts @@ -0,0 +1,348 @@ +import * as THREE from "three"; +import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants"; +import { computePlanetLocalPosition, currentWorldTimeSeconds, toThreeVector } from "./viewerMath"; +import { resolveSelectableSystemId } from "./viewerSelection"; +import type { + BubbleVisual, + ClaimVisual, + ConstructionSiteVisual, + NodeVisual, + Selectable, + ShipVisual, + SpatialNodeVisual, + StructureVisual, + WorldState, +} from "./viewerTypes"; + +interface ResolveSelectionPositionParams { + world: WorldState | undefined; + selection: Selectable; + worldTimeSyncMs: number; + nodeVisuals: Map; + planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; + computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; + resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; + resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; +} + +interface FocusOnSelectionParams extends ResolveSelectionPositionParams { + activeSystemId?: string; + galaxyFocus: THREE.Vector3; + systemFocusLocal: THREE.Vector3; +} + +interface DetermineActiveSystemParams { + world: WorldState | undefined; + cameraMode: "tactical" | "follow"; + cameraTargetShipId?: string; + currentDistance: number; + selectedItems: Selectable[]; + galaxyFocus: THREE.Vector3; +} + +interface SeedSystemFocusParams { + world: WorldState | undefined; + systemId: string; + cameraMode: "tactical" | "follow"; + cameraTargetShipId?: string; + selectedItems: Selectable[]; + systemFocusLocal: THREE.Vector3; + worldTimeSyncMs: number; + nodeVisuals: Map; + planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; + computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; + resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; + resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; +} + +interface CameraFocusParams { + world: WorldState | undefined; + activeSystemId?: string; + galaxyFocus: THREE.Vector3; + systemFocusLocal: THREE.Vector3; +} + +interface DisplayLocalPositionParams { + world: WorldState | undefined; + systemId?: string; + activeSystemId?: string; + localPosition: THREE.Vector3; + systemFocusLocal: THREE.Vector3; +} + +export function updatePanFromKeyboard( + keyState: Set, + orbitYaw: number, + currentDistance: number, + activeSystemId: string | undefined, + systemFocusLocal: THREE.Vector3, + galaxyFocus: THREE.Vector3, + delta: number, + minimumDistance: number, + maximumDistance: number, +) { + const move = new THREE.Vector3(); + if (keyState.has("w")) { + move.z -= 1; + } + if (keyState.has("s")) { + move.z += 1; + } + if (keyState.has("a")) { + move.x += 1; + } + if (keyState.has("d")) { + move.x -= 1; + } + if (move.lengthSq() === 0) { + return; + } + + move.normalize(); + const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw)); + const right = new THREE.Vector3(-forward.z, 0, forward.x); + const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); + const speed = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 320, 6800); + if (activeSystemId) { + systemFocusLocal.addScaledVector(pan, speed * delta); + return; + } + + galaxyFocus.addScaledVector(pan, speed * delta); +} + +export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined { + const { + world, + cameraMode, + cameraTargetShipId, + currentDistance, + selectedItems, + galaxyFocus, + } = params; + + if (!world) { + return undefined; + } + + if (cameraMode === "follow" && cameraTargetShipId) { + return world.ships.get(cameraTargetShipId)?.systemId; + } + + if (currentDistance >= 12000) { + return undefined; + } + + const selected = selectedItems[0]; + if (selected && selectedItems.length === 1) { + if (selected.kind === "system") { + return selected.id; + } + if (selected.kind === "planet") { + return selected.systemId; + } + + const selectedSystemId = resolveSelectableSystemId(world, selected); + if (selectedSystemId) { + return selectedSystemId; + } + } + + let nearestSystemId: string | undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const system of world.systems.values()) { + const center = toThreeVector(system.galaxyPosition); + const distance = center.distanceTo(galaxyFocus); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestSystemId = system.id; + } + } + + return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, currentDistance * 2.2) + ? nearestSystemId + : undefined; +} + +export function resolveSelectionPosition(params: ResolveSelectionPositionParams): THREE.Vector3 | undefined { + const { + world, + selection, + worldTimeSyncMs, + nodeVisuals, + planetVisuals, + computeNodeLocalPosition, + resolveBubblePosition, + resolvePointPosition, + } = params; + + if (!world) { + return undefined; + } + + if (selection.kind === "ship") { + const ship = world.ships.get(selection.id); + return ship ? toThreeVector(ship.localPosition) : undefined; + } + if (selection.kind === "station") { + const station = world.stations.get(selection.id); + return station ? toThreeVector(station.localPosition) : undefined; + } + if (selection.kind === "node") { + const node = world.nodes.get(selection.id); + const visual = node ? nodeVisuals.get(node.id) : undefined; + return visual + ? computeNodeLocalPosition(visual, currentWorldTimeSeconds(world, worldTimeSyncMs)) + : (node ? toThreeVector(node.localPosition) : undefined); + } + if (selection.kind === "spatial-node") { + const node = world.spatialNodes.get(selection.id); + return node ? toThreeVector(node.localPosition) : undefined; + } + if (selection.kind === "bubble") { + return resolveBubblePosition(selection.id); + } + if (selection.kind === "claim") { + const claim = world.claims.get(selection.id); + return claim ? resolvePointPosition(claim.systemId, claim.nodeId) : undefined; + } + if (selection.kind === "construction-site") { + const site = world.constructionSites.get(selection.id); + return site ? resolvePointPosition(site.systemId, site.nodeId) : undefined; + } + if (selection.kind === "planet") { + const system = world.systems.get(selection.systemId); + const planet = system?.planets[selection.planetIndex]; + if (!system || !planet) { + return undefined; + } + + const visual = planetVisuals.find((candidate) => + candidate.systemId === selection.systemId && candidate.planet === planet); + return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs)); + } + + const system = world.systems.get(selection.id); + return system ? toThreeVector(system.galaxyPosition) : undefined; +} + +export function focusOnSelection(params: FocusOnSelectionParams) { + const { + world, + selection, + activeSystemId, + galaxyFocus, + systemFocusLocal, + } = params; + + const nextFocus = resolveSelectionPosition(params); + if (!nextFocus) { + return; + } + + const selectionSystemId = resolveSelectableSystemId(world, selection); + if (selectionSystemId && selection.kind !== "system" && world) { + const system = world.systems.get(selectionSystemId); + if (system) { + galaxyFocus.copy(toThreeVector(system.galaxyPosition)); + systemFocusLocal.copy(nextFocus); + return; + } + } + + if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) { + systemFocusLocal.copy(nextFocus); + return; + } + + galaxyFocus.copy(nextFocus); +} + +export function seedSystemFocusLocal(params: SeedSystemFocusParams) { + const { + world, + systemId, + cameraMode, + cameraTargetShipId, + selectedItems, + systemFocusLocal, + } = params; + + if (!world) { + return; + } + + if (cameraMode === "follow" && cameraTargetShipId) { + const followedShip = world.ships.get(cameraTargetShipId); + if (followedShip?.systemId === systemId) { + systemFocusLocal.copy(toThreeVector(followedShip.localPosition)); + return; + } + } + + const selected = selectedItems[0]; + if (selected && resolveSelectableSystemId(world, selected) === systemId) { + const selectedPosition = resolveSelectionPosition({ + world, + selection: selected, + worldTimeSyncMs: params.worldTimeSyncMs, + nodeVisuals: params.nodeVisuals, + planetVisuals: params.planetVisuals, + computeNodeLocalPosition: params.computeNodeLocalPosition, + resolveBubblePosition: params.resolveBubblePosition, + resolvePointPosition: params.resolvePointPosition, + }); + if (selectedPosition) { + systemFocusLocal.copy(selectedPosition); + return; + } + } + + systemFocusLocal.set(0, 0, 0); +} + +export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Vector3 { + const { + world, + activeSystemId, + galaxyFocus, + systemFocusLocal, + } = params; + + if (!activeSystemId || !world) { + return galaxyFocus; + } + + const system = world.systems.get(activeSystemId); + return system + ? toThreeVector(system.galaxyPosition).add( + systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR), + ) + : galaxyFocus; +} + +export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THREE.Vector3 { + const { + world, + systemId, + activeSystemId, + localPosition, + systemFocusLocal, + } = params; + + if (!world || !systemId) { + return localPosition.clone(); + } + + const system = world.systems.get(systemId); + if (!system) { + return localPosition.clone(); + } + + const center = toThreeVector(system.galaxyPosition); + if (systemId !== activeSystemId) { + return center.clone().add(localPosition); + } + + return center.clone().add(localPosition.clone().sub(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE)); +} diff --git a/apps/viewer/src/viewerConstants.ts b/apps/viewer/src/viewerConstants.ts new file mode 100644 index 0000000..9d8e464 --- /dev/null +++ b/apps/viewer/src/viewerConstants.ts @@ -0,0 +1,23 @@ +import type { ZoomLevel } from "./viewerTypes"; + +export const ZOOM_DISTANCE: Record = { + local: 900, + system: 3200, + universe: 26000, +}; + +export const ACTIVE_SYSTEM_DETAIL_SCALE = 10; +export const GALAXY_PARALLAX_FACTOR = 0.025; +export const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000; +export const PROJECTED_GALAXY_RADIUS = 65000; +export const STAR_RENDER_SCALE = 0.18; +export const PLANET_RENDER_SCALE = 0.95; +export const MOON_RENDER_SCALE = 1.1; +export const MIN_CAMERA_DISTANCE = 450; +export const MAX_CAMERA_DISTANCE = 42000; + +export interface ZoomBlend { + localWeight: number; + systemWeight: number; + universeWeight: number; +} diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts new file mode 100644 index 0000000..6bef273 --- /dev/null +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -0,0 +1,272 @@ +import * as THREE from "three"; +import { ViewerInteractionController } from "./viewerInteractionController"; +import { ViewerNavigationController } from "./viewerNavigationController"; +import { ViewerPresentationController } from "./viewerPresentationController"; +import { ViewerSceneDataController } from "./viewerSceneDataController"; +import { ViewerWorldLifecycle } from "./viewerWorldLifecycle"; +import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; + +export function createViewerControllers(host: any) { + const sceneDataController = new ViewerSceneDataController({ + documentRef: document, + getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc, + getWorldSeed: () => host.world?.seed ?? 1, + getWorldTimeSyncMs: () => host.worldTimeSyncMs, + getWorldPresentationContext: () => host.createWorldPresentationContext(), + systemGroup: host.systemGroup, + spatialNodeGroup: host.spatialNodeGroup, + bubbleGroup: host.bubbleGroup, + nodeGroup: host.nodeGroup, + stationGroup: host.stationGroup, + claimGroup: host.claimGroup, + constructionSiteGroup: host.constructionSiteGroup, + shipGroup: host.shipGroup, + selectableTargets: host.selectableTargets, + presentationEntries: host.presentationEntries, + systemVisuals: host.systemVisuals, + systemSummaryVisuals: host.systemSummaryVisuals, + planetVisuals: host.planetVisuals, + orbitLines: host.orbitLines, + spatialNodeVisuals: host.spatialNodeVisuals, + bubbleVisuals: host.bubbleVisuals, + nodeVisuals: host.nodeVisuals, + stationVisuals: host.stationVisuals, + claimVisuals: host.claimVisuals, + constructionSiteVisuals: host.constructionSiteVisuals, + shipVisuals: host.shipVisuals, + registerPresentation: host.registerPresentation.bind(host), + }); + + const navigationController = new ViewerNavigationController({ + getWorld: () => host.world, + getWorldTimeSyncMs: () => host.worldTimeSyncMs, + getActiveSystemId: () => host.activeSystemId, + setActiveSystemId: (value) => { + host.activeSystemId = value; + }, + getCameraMode: () => host.cameraMode, + setCameraMode: (value) => { + host.cameraMode = value; + }, + getCameraTargetShipId: () => host.cameraTargetShipId, + setCameraTargetShipId: (value) => { + host.cameraTargetShipId = value; + }, + getCurrentDistance: () => host.currentDistance, + getSelectedItems: () => host.selectedItems, + getOrbitYaw: () => host.orbitYaw, + galaxyFocus: host.galaxyFocus, + systemFocusLocal: host.systemFocusLocal, + camera: host.camera, + shipVisuals: host.shipVisuals, + nodeVisuals: host.nodeVisuals, + planetVisuals: host.planetVisuals, + systemVisuals: host.systemVisuals, + followCameraPosition: host.followCameraPosition, + followCameraFocus: host.followCameraFocus, + followCameraDirection: host.followCameraDirection, + followCameraDesiredDirection: host.followCameraDesiredDirection, + followCameraOffset: host.followCameraOffset, + createWorldPresentationContext: () => host.createWorldPresentationContext(), + updatePanels: () => host.updatePanels(), + updateGamePanel: (mode: string) => host.updateGamePanel(mode), + }); + + const presentationController = new ViewerPresentationController({ + renderer: host.renderer, + scene: host.scene, + camera: host.camera, + ambienceGroup: host.ambienceGroup, + statusEl: host.statusEl, + networkPanelEl: host.networkPanelEl, + performancePanelEl: host.performancePanelEl, + systemPanelEl: host.systemPanelEl, + systemTitleEl: host.systemTitleEl, + systemBodyEl: host.systemBodyEl, + networkStats: host.networkStats, + performanceStats: host.performanceStats, + getWorld: () => host.world, + getActiveSystemId: () => host.activeSystemId, + getCameraMode: () => host.cameraMode, + getCameraTargetShipId: () => host.cameraTargetShipId, + getZoomLevel: () => host.zoomLevel, + getWorldTimeSyncMs: () => host.worldTimeSyncMs, + getCurrentDistance: () => host.currentDistance, + systemFocusLocal: host.systemFocusLocal, + planetVisuals: host.planetVisuals, + systemSummaryVisuals: host.systemSummaryVisuals, + presentationEntries: host.presentationEntries, + orbitLines: host.orbitLines, + systemVisuals: host.systemVisuals, + createWorldPresentationContext: () => host.createWorldPresentationContext(), + }); + + const worldLifecycle = new ViewerWorldLifecycle({ + getWorld: () => host.world, + setWorld: (world) => { + host.world = world; + }, + getWorldTimeSyncMs: () => host.worldTimeSyncMs, + setWorldTimeSyncMs: (value) => { + host.worldTimeSyncMs = value; + }, + getWorldSignature: () => host.worldSignature, + setWorldSignature: (value) => { + host.worldSignature = value; + }, + getStream: () => host.stream, + setStream: (stream) => { + host.stream = stream; + }, + getCurrentStreamScopeKey: () => host.currentStreamScopeKey, + setCurrentStreamScopeKey: (value) => { + host.currentStreamScopeKey = value; + }, + getZoomLevel: () => host.zoomLevel, + getActiveSystemId: () => host.activeSystemId, + getSelectedItems: () => host.selectedItems, + getCameraMode: () => host.cameraMode, + getCameraTargetShipId: () => host.cameraTargetShipId, + getNetworkStats: () => host.networkStats, + getSystemSummaryVisuals: () => host.systemSummaryVisuals, + errorEl: host.errorEl, + factionStripEl: host.factionStripEl, + detailTitleEl: host.detailTitleEl, + detailBodyEl: host.detailBodyEl, + worldLabel: () => host.world?.label ?? "", + rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems), + syncSpatialNodes: (nodes) => sceneDataController.syncSpatialNodes(nodes), + syncLocalBubbles: (bubbles) => sceneDataController.syncLocalBubbles(bubbles), + syncNodes: (nodes) => sceneDataController.syncNodes(nodes), + syncStations: (stations) => sceneDataController.syncStations(stations), + syncClaims: (claims) => sceneDataController.syncClaims(claims), + syncConstructionSites: (sites) => sceneDataController.syncConstructionSites(sites), + syncShips: (ships, tickIntervalMs) => sceneDataController.syncShips(ships, tickIntervalMs), + applySpatialNodeDeltas: (nodes) => sceneDataController.applySpatialNodeDeltas(nodes), + applyLocalBubbleDeltas: (bubbles) => sceneDataController.applyLocalBubbleDeltas(bubbles), + applyNodeDeltas: (nodes) => sceneDataController.applyNodeDeltas(nodes), + applyStationDeltas: (stations) => sceneDataController.applyStationDeltas(stations), + applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims), + applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites), + applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs), + refreshHistoryWindows: () => host.refreshHistoryWindows(), + resolveFocusedBubbleId: () => host.resolveFocusedBubbleId(), + updateSystemSummaries: () => host.updateSystemSummaries(), + applyZoomPresentation: () => presentationController.applyZoomPresentation(), + updateNetworkPanel: () => presentationController.updateNetworkPanel(), + updateSystemPanel: () => host.updateSystemPanel(), + updateGamePanel: (mode) => host.updateGamePanel(mode), + describeSelectionParent: (selection) => host.describeSelectionParent(selection), + }); + + const historyController = new ViewerHistoryWindowController({ + historyLayerEl: host.historyLayerEl, + historyWindows: host.historyWindows, + getWorld: () => host.world, + getHistoryWindowCounter: () => host.historyWindowCounter, + setHistoryWindowCounter: (value) => { + host.historyWindowCounter = value; + }, + getHistoryWindowZCounter: () => host.historyWindowZCounter, + setHistoryWindowZCounter: (value) => { + host.historyWindowZCounter = value; + }, + getHistoryWindowDragId: () => host.historyWindowDragId, + setHistoryWindowDragId: (value) => { + host.historyWindowDragId = value; + }, + getHistoryWindowDragPointerId: () => host.historyWindowDragPointerId, + setHistoryWindowDragPointerId: (value) => { + host.historyWindowDragPointerId = value; + }, + historyWindowDragOffset: host.historyWindowDragOffset, + renderRecentEvents: (entityKind, entityId) => presentationController.renderRecentEvents(entityKind, entityId), + }); + + const interactionController = new ViewerInteractionController({ + renderer: host.renderer, + raycaster: host.raycaster, + mouse: host.mouse, + camera: host.camera, + selectableTargets: host.selectableTargets, + hoverLabelEl: host.hoverLabelEl, + marqueeEl: host.marqueeEl, + keyState: host.keyState, + getWorld: () => host.world, + getActiveSystemId: () => host.activeSystemId, + getSelectedItems: () => host.selectedItems, + setSelectedItems: (items) => { + host.selectedItems = items; + }, + getDragMode: () => host.dragMode, + setDragMode: (mode) => { + host.dragMode = mode; + }, + getDragPointerId: () => host.dragPointerId, + setDragPointerId: (pointerId) => { + host.dragPointerId = pointerId; + }, + dragStart: host.dragStart, + dragLast: host.dragLast, + getMarqueeActive: () => host.marqueeActive, + setMarqueeActive: (value) => { + host.marqueeActive = value; + }, + getSuppressClickSelection: () => host.suppressClickSelection, + setSuppressClickSelection: (value) => { + host.suppressClickSelection = value; + }, + getDesiredDistance: () => host.desiredDistance, + setDesiredDistance: (value) => { + host.desiredDistance = value; + }, + getCameraMode: () => host.cameraMode, + setCameraMode: (value) => { + host.cameraMode = value; + }, + getCameraTargetShipId: () => host.cameraTargetShipId, + setCameraTargetShipId: (value) => { + host.cameraTargetShipId = value; + }, + getFollowCameraPosition: () => host.followCameraPosition, + getFollowCameraFocus: () => host.followCameraFocus, + screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y), + applyOrbitDelta: (delta: THREE.Vector2) => { + host.orbitYaw += delta.x * 0.008; + host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3); + }, + syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(), + updatePanels: () => host.updatePanels(), + focusOnSelection: (selection) => navigationController.focusOnSelection(selection), + updateGamePanel: (mode) => host.updateGamePanel(mode), + historyController, + }); + + return { + historyController, + sceneDataController, + navigationController, + presentationController, + worldLifecycle, + interactionController, + }; +} + +export function wireViewerEvents(host: any) { + host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown); + host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove); + host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp); + host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp); + host.renderer.domElement.addEventListener("click", host.interactionController.onClick); + host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick); + host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false }); + host.factionStripEl.addEventListener("click", host.interactionController.onShipStripClick); + host.factionStripEl.addEventListener("dblclick", host.interactionController.onShipStripDoubleClick); + host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick); + host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown); + window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove); + window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp); + window.addEventListener("keydown", host.interactionController.onKeyDown); + window.addEventListener("keyup", host.interactionController.onKeyUp); + window.addEventListener("resize", host.onResize); +} diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts new file mode 100644 index 0000000..8982a40 --- /dev/null +++ b/apps/viewer/src/viewerControls.ts @@ -0,0 +1,210 @@ +import * as THREE from "three"; +import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants"; +import type { + CameraMode, + Selectable, + ShipVisual, + SystemVisual, + WorldState, +} from "./viewerTypes"; + +export function syncFollowStateFromSelection( + selectedItems: Selectable[], + cameraMode: CameraMode, + cameraTargetShipId?: string, +) { + if (selectedItems.length === 1 && selectedItems[0].kind === "ship") { + return { + cameraMode, + cameraTargetShipId: selectedItems[0].id, + }; + } + + return { + cameraMode: cameraMode === "follow" ? "tactical" : cameraMode, + cameraTargetShipId: undefined, + }; +} + +export function toggleCameraMode(params: { + cameraMode: CameraMode; + cameraTargetShipId?: string; + selectedItems: Selectable[]; + desiredDistance: number; + followCameraPosition: THREE.Vector3; + followCameraFocus: THREE.Vector3; + forceMode?: CameraMode; +}) { + const { + cameraMode, + cameraTargetShipId, + selectedItems, + desiredDistance, + followCameraPosition, + followCameraFocus, + forceMode, + } = params; + + const nextMode = forceMode ?? (cameraMode === "follow" ? "tactical" : "follow"); + if (nextMode === "tactical") { + return { + cameraMode: "tactical" as const, + cameraTargetShipId, + desiredDistance, + }; + } + + const nextTargetShipId = cameraTargetShipId + ?? (selectedItems.length === 1 && selectedItems[0].kind === "ship" ? selectedItems[0].id : undefined); + if (!nextTargetShipId) { + return { + cameraMode, + cameraTargetShipId, + desiredDistance, + }; + } + + followCameraPosition.set(0, 0, 0); + followCameraFocus.set(0, 0, 0); + return { + cameraMode: "follow" as const, + cameraTargetShipId: nextTargetShipId, + desiredDistance: Math.min(desiredDistance, 1800), + }; +} + +export function updateFollowCamera(params: { + world: WorldState | undefined; + cameraMode: CameraMode; + cameraTargetShipId?: string; + shipVisuals: Map; + currentDistance: number; + camera: THREE.PerspectiveCamera; + followCameraPosition: THREE.Vector3; + followCameraFocus: THREE.Vector3; + followCameraDirection: THREE.Vector3; + followCameraDesiredDirection: THREE.Vector3; + followCameraOffset: THREE.Vector3; + systemFocusLocal: THREE.Vector3; + delta: number; + getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3; + toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; + resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3; +}) { + const { + world, + cameraTargetShipId, + shipVisuals, + currentDistance, + camera, + followCameraPosition, + followCameraFocus, + followCameraDirection, + followCameraDesiredDirection, + followCameraOffset, + systemFocusLocal, + delta, + getAnimatedShipLocalPosition, + toDisplayLocalPosition, + resolveShipHeading, + } = params; + + if (!cameraTargetShipId || !world) { + return { + handled: false, + cameraMode: "tactical" as const, + cameraTargetShipId, + }; + } + + const ship = world.ships.get(cameraTargetShipId); + const visual = shipVisuals.get(cameraTargetShipId); + if (!ship || !visual) { + return { + handled: false, + cameraMode: "tactical" as const, + cameraTargetShipId: undefined, + }; + } + + const shipLocalPosition = getAnimatedShipLocalPosition(visual); + const shipWorldPosition = toDisplayLocalPosition(shipLocalPosition, ship.systemId); + systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); + + followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize(); + followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5)); + followCameraDirection.normalize(); + + const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800); + const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100); + const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400); + followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance); + followCameraOffset.y += height; + + const desiredPosition = shipWorldPosition.clone().add(followCameraOffset); + const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead); + desiredFocus.y += height * 0.28; + + const positionLerp = 1 - Math.exp(-delta * 6); + const focusLerp = 1 - Math.exp(-delta * 8); + if (followCameraPosition.lengthSq() === 0) { + followCameraPosition.copy(desiredPosition); + followCameraFocus.copy(desiredFocus); + } else { + followCameraPosition.lerp(desiredPosition, positionLerp); + followCameraFocus.lerp(desiredFocus, focusLerp); + } + + camera.position.copy(followCameraPosition); + camera.lookAt(followCameraFocus); + return { + handled: true, + cameraMode: "follow" as const, + cameraTargetShipId, + }; +} + +export function updateSystemDetailVisibility(systemVisuals: Map, activeSystemId?: string) { + for (const [systemId, visual] of systemVisuals.entries()) { + visual.detailGroup.visible = systemId === activeSystemId; + } +} + +export function setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { + sprite.visible = opacity > 0.02; + sprite.material.opacity = opacity; + sprite.material.needsUpdate = true; +} + +export function zoomFromWheel(desiredDistance: number, deltaY: number) { + const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180); + const zoomFactor = Math.exp(clampedDelta * 0.00135); + return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); +} + +export function applyKeyboardControl(params: { + keyState: Set; + cameraMode: CameraMode; + desiredDistance: number; + key: string; +}) { + const { keyState, key } = params; + keyState.add(key); + + let cameraMode = params.cameraMode; + let desiredDistance = params.desiredDistance; + + if (["w", "a", "s", "d"].includes(key)) { + cameraMode = "tactical"; + } + if (key === "1") { + desiredDistance = ZOOM_DISTANCE.local; + } else if (key === "2") { + desiredDistance = ZOOM_DISTANCE.system; + } else if (key === "3") { + desiredDistance = ZOOM_DISTANCE.universe; + } + + return { cameraMode, desiredDistance }; +} + diff --git a/apps/viewer/src/viewerFactionStrip.ts b/apps/viewer/src/viewerFactionStrip.ts new file mode 100644 index 0000000..d08023e --- /dev/null +++ b/apps/viewer/src/viewerFactionStrip.ts @@ -0,0 +1,52 @@ +import { inventoryAmount } from "./viewerMath"; +import type { CameraMode, Selectable, WorldState } from "./viewerTypes"; + +export function renderFactionStrip( + world: WorldState | undefined, + selectedItems: Selectable[], + cameraMode: CameraMode, + cameraTargetShipId?: string, +) { + if (!world) { + return ""; + } + + const ships = [...world.ships.values()] + .sort((left, right) => left.label.localeCompare(right.label)); + + return ships + .map((ship) => { + const fuel = inventoryAmount(ship.inventory, "gas"); + const isSelected = selectedItems.length === 1 + && selectedItems[0].kind === "ship" + && selectedItems[0].id === ship.id; + const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id; + + return ` +
+
+

${ship.label}

+
+ ${ship.shipClass} + +
+
+

${ship.systemId}

+

Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}

+

State ${ship.state}

+
+

Order ${ship.orderKind ?? "none"}

+

Behavior ${ship.defaultBehaviorKind}

+

Task ${ship.controllerTaskKind}

+
+
+ `; + }) + .join(""); +} diff --git a/apps/viewer/src/viewerHistory.ts b/apps/viewer/src/viewerHistory.ts new file mode 100644 index 0000000..3d3557a --- /dev/null +++ b/apps/viewer/src/viewerHistory.ts @@ -0,0 +1,70 @@ +import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; + +export function createHistoryWindowState( + documentRef: Document, + target: Selectable, + historyWindowsCount: number, + historyWindowCounter: number, +): HistoryWindowState { + const id = `history-${historyWindowCounter}`; + const root = documentRef.createElement("aside"); + root.className = "history-window"; + root.dataset.historyWindowId = id; + root.innerHTML = ` +
+

History

+
+ + +
+
+
No history selected.
+ `; + + root.style.width = `${Math.min(520, window.innerWidth - 40)}px`; + root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`; + root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`; + root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`; + + return { + id, + target, + root, + titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement, + bodyEl: root.querySelector(".history-window-body") as HTMLDivElement, + copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement, + text: "", + }; +} + +export function refreshHistoryWindow( + world: WorldState, + windowState: HistoryWindowState, + renderRecentEvents: (entityKind: string, entityId: string) => string, +): boolean { + if (windowState.target.kind === "ship") { + const ship = world.ships.get(windowState.target.id); + if (!ship) { + return false; + } + + windowState.titleEl.textContent = `${ship.label} History`; + windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet."; + windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); + return true; + } + + if (windowState.target.kind === "station") { + const station = world.stations.get(windowState.target.id); + if (!station) { + return false; + } + + windowState.titleEl.textContent = `${station.label} History`; + windowState.text = renderRecentEvents("station", station.id).replaceAll("
", "\n") || "No history yet."; + windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); + return true; + } + + return false; +} diff --git a/apps/viewer/src/viewerHistoryManager.ts b/apps/viewer/src/viewerHistoryManager.ts new file mode 100644 index 0000000..c9895e5 --- /dev/null +++ b/apps/viewer/src/viewerHistoryManager.ts @@ -0,0 +1,180 @@ +import * as THREE from "three"; +import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory"; +import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; + +export function openHistoryWindow( + historyWindows: HistoryWindowState[], + historyLayerEl: HTMLDivElement, + target: Selectable, + nextCounter: number, + bringToFront: (windowState: HistoryWindowState) => void, + refreshWindows: () => void, +) { + const existing = historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target)); + if (existing) { + bringToFront(existing); + refreshWindows(); + return nextCounter; + } + + const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter); + historyWindows.push(windowState); + historyLayerEl.append(windowState.root); + bringToFront(windowState); + refreshWindows(); + return nextCounter; +} + +export function refreshHistoryWindows( + world: WorldState | undefined, + historyWindows: HistoryWindowState[], + renderRecentEvents: (entityKind: string, entityId: string) => string, + destroyWindow: (id: string) => void, +) { + if (!world) { + return; + } + + for (const windowState of [...historyWindows]) { + if (!refreshHistoryWindow(world, windowState, renderRecentEvents)) { + destroyWindow(windowState.id); + } + } +} + +export function destroyHistoryWindow( + historyWindows: HistoryWindowState[], + historyWindowDragId: string | undefined, + historyWindowDragPointerId: number | undefined, + id: string, +) { + const index = historyWindows.findIndex((windowState) => windowState.id === id); + if (index < 0) { + return { + historyWindowDragId, + historyWindowDragPointerId, + }; + } + + const [removed] = historyWindows.splice(index, 1); + removed.root.remove(); + if (historyWindowDragId === id) { + return { + historyWindowDragId: undefined, + historyWindowDragPointerId: undefined, + }; + } + + return { + historyWindowDragId, + historyWindowDragPointerId, + }; +} + +export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) { + windowState.root.style.zIndex = `${nextZIndex}`; +} + +export function beginHistoryWindowDrag( + historyWindows: HistoryWindowState[], + historyWindowDragOffset: THREE.Vector2, + pointerId: number, + windowId: string, + clientX: number, + clientY: number, +) { + const windowState = historyWindows.find((candidate) => candidate.id === windowId); + if (!windowState) { + return { + historyWindowDragId: undefined, + historyWindowDragPointerId: undefined, + }; + } + + const bounds = windowState.root.getBoundingClientRect(); + historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top); + windowState.root.setPointerCapture?.(pointerId); + return { + historyWindowDragId: windowId, + historyWindowDragPointerId: pointerId, + }; +} + +export function updateHistoryWindowDrag( + historyWindows: HistoryWindowState[], + historyWindowDragId: string | undefined, + historyWindowDragPointerId: number | undefined, + historyWindowDragOffset: THREE.Vector2, + pointerId: number, + clientX: number, + clientY: number, +) { + if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) { + return; + } + + const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId); + if (!windowState) { + return; + } + + const width = windowState.root.offsetWidth; + const height = windowState.root.offsetHeight; + const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20); + const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20); + windowState.root.style.left = `${left}px`; + windowState.root.style.top = `${top}px`; +} + +export function endHistoryWindowDrag( + historyWindows: HistoryWindowState[], + historyWindowDragId: string | undefined, + historyWindowDragPointerId: number | undefined, + pointerId: number, +) { + if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) { + return { + historyWindowDragId, + historyWindowDragPointerId, + }; + } + + const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId); + windowState?.root.releasePointerCapture?.(pointerId); + return { + historyWindowDragId: undefined, + historyWindowDragPointerId: undefined, + }; +} + +export async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + } + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.focus(); + textarea.select(); + + try { + const copied = document.execCommand("copy"); + if (!copied) { + throw new Error("execCommand copy failed"); + } + } finally { + textarea.remove(); + } +} diff --git a/apps/viewer/src/viewerHistoryWindowController.ts b/apps/viewer/src/viewerHistoryWindowController.ts new file mode 100644 index 0000000..42b47cd --- /dev/null +++ b/apps/viewer/src/viewerHistoryWindowController.ts @@ -0,0 +1,175 @@ +import * as THREE from "three"; +import { + beginHistoryWindowDrag, + bringHistoryWindowToFront, + copyTextToClipboard, + destroyHistoryWindow, + endHistoryWindowDrag, + openHistoryWindow, + refreshHistoryWindows, + updateHistoryWindowDrag, +} from "./viewerHistoryManager"; +import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes"; + +export interface ViewerHistoryWindowContext { + historyLayerEl: HTMLDivElement; + historyWindows: HistoryWindowState[]; + getWorld: () => WorldState | undefined; + getHistoryWindowCounter: () => number; + setHistoryWindowCounter: (value: number) => void; + getHistoryWindowZCounter: () => number; + setHistoryWindowZCounter: (value: number) => void; + getHistoryWindowDragId: () => string | undefined; + setHistoryWindowDragId: (value: string | undefined) => void; + getHistoryWindowDragPointerId: () => number | undefined; + setHistoryWindowDragPointerId: (value: number | undefined) => void; + historyWindowDragOffset: THREE.Vector2; + renderRecentEvents: (entityKind: string, entityId: string) => string; +} + +export class ViewerHistoryWindowController { + constructor(private readonly context: ViewerHistoryWindowContext) {} + + openHistoryWindow(target: Selectable) { + const nextCounter = openHistoryWindow( + this.context.historyWindows, + this.context.historyLayerEl, + target, + this.context.getHistoryWindowCounter() + 1, + (windowState) => this.bringHistoryWindowToFront(windowState), + () => this.refreshHistoryWindows(), + ); + this.context.setHistoryWindowCounter(nextCounter); + } + + refreshHistoryWindows() { + refreshHistoryWindows( + this.context.getWorld(), + this.context.historyWindows, + this.context.renderRecentEvents, + (id) => this.destroyHistoryWindow(id), + ); + } + + readonly onHistoryLayerClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const windowEl = target.closest("[data-history-window-id]"); + const windowId = windowEl?.dataset.historyWindowId; + if (!windowId) { + return; + } + + if (target.closest(".history-window-copy")) { + void this.copyHistoryWindowContent(windowId); + return; + } + + if (target.closest(".history-window-close")) { + this.destroyHistoryWindow(windowId); + return; + } + + const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId); + if (windowState) { + this.bringHistoryWindowToFront(windowState); + } + }; + + readonly onHistoryLayerPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const windowEl = target.closest("[data-history-window-id]"); + const windowId = windowEl?.dataset.historyWindowId; + if (!windowEl || !windowId) { + return; + } + + const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId); + if (!windowState) { + return; + } + + this.bringHistoryWindowToFront(windowState); + if (!target.closest(".history-window-header") || target.closest("button")) { + return; + } + + const nextState = beginHistoryWindowDrag( + this.context.historyWindows, + this.context.historyWindowDragOffset, + event.pointerId, + windowId, + event.clientX, + event.clientY, + ); + this.context.setHistoryWindowDragId(nextState.historyWindowDragId); + this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId); + }; + + readonly onHistoryWindowPointerMove = (event: PointerEvent) => { + updateHistoryWindowDrag( + this.context.historyWindows, + this.context.getHistoryWindowDragId(), + this.context.getHistoryWindowDragPointerId(), + this.context.historyWindowDragOffset, + event.pointerId, + event.clientX, + event.clientY, + ); + }; + + readonly onHistoryWindowPointerUp = (event: PointerEvent) => { + const nextState = endHistoryWindowDrag( + this.context.historyWindows, + this.context.getHistoryWindowDragId(), + this.context.getHistoryWindowDragPointerId(), + event.pointerId, + ); + this.context.setHistoryWindowDragId(nextState.historyWindowDragId); + this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId); + }; + + private destroyHistoryWindow(id: string) { + const nextState = destroyHistoryWindow( + this.context.historyWindows, + this.context.getHistoryWindowDragId(), + this.context.getHistoryWindowDragPointerId(), + id, + ); + this.context.setHistoryWindowDragId(nextState.historyWindowDragId); + this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId); + } + + private async copyHistoryWindowContent(windowId: string) { + const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId); + if (!windowState?.text) { + return; + } + + try { + await copyTextToClipboard(windowState.text); + windowState.copyButtonEl.textContent = "Copied"; + window.setTimeout(() => { + windowState.copyButtonEl.textContent = "Copy"; + }, 1200); + } catch { + windowState.copyButtonEl.textContent = "Failed"; + window.setTimeout(() => { + windowState.copyButtonEl.textContent = "Copy"; + }, 1200); + } + } + + private bringHistoryWindowToFront(windowState: HistoryWindowState) { + const nextZIndex = this.context.getHistoryWindowZCounter() + 1; + this.context.setHistoryWindowZCounter(nextZIndex); + bringHistoryWindowToFront(windowState, nextZIndex); + } +} diff --git a/apps/viewer/src/viewerHud.ts b/apps/viewer/src/viewerHud.ts new file mode 100644 index 0000000..3cd450b --- /dev/null +++ b/apps/viewer/src/viewerHud.ts @@ -0,0 +1,71 @@ +export interface ViewerHudElements { + root: HTMLDivElement; + statusEl: HTMLDivElement; + systemPanelEl: HTMLDivElement; + systemTitleEl: HTMLHeadingElement; + systemBodyEl: HTMLDivElement; + detailTitleEl: HTMLHeadingElement; + detailBodyEl: HTMLDivElement; + factionStripEl: HTMLDivElement; + networkPanelEl: HTMLDivElement; + performancePanelEl: HTMLDivElement; + errorEl: HTMLDivElement; + historyLayerEl: HTMLDivElement; + marqueeEl: HTMLDivElement; + hoverLabelEl: HTMLDivElement; +} + +export function createViewerHud(documentRef: Document): ViewerHudElements { + const root = documentRef.createElement("div"); + root.className = "viewer-shell"; + root.innerHTML = ` +
+
+

Game

+
Bootstrapping
+
+ + +
+
+ + + +
+
+
+
+ + `; + + return { + root, + statusEl: root.querySelector(".topbar-body") as HTMLDivElement, + systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement, + systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement, + systemBodyEl: root.querySelector(".system-body") as HTMLDivElement, + detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement, + detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement, + factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement, + networkPanelEl: root.querySelector(".network-body") as HTMLDivElement, + performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement, + errorEl: root.querySelector(".error-strip") as HTMLDivElement, + historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement, + marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement, + hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement, + }; +} diff --git a/apps/viewer/src/viewerInteraction.ts b/apps/viewer/src/viewerInteraction.ts new file mode 100644 index 0000000..0e0369a --- /dev/null +++ b/apps/viewer/src/viewerInteraction.ts @@ -0,0 +1,131 @@ +import * as THREE from "three"; +import { getSelectionGroup } from "./viewerSelection"; +import type { Selectable, SelectionGroup, WorldState } from "./viewerTypes"; + +export function pickSelectableAtClientPosition( + renderer: THREE.WebGLRenderer, + raycaster: THREE.Raycaster, + mouse: THREE.Vector2, + camera: THREE.Camera, + selectableTargets: Map, + clientX: number, + clientY: number, +) { + const bounds = renderer.domElement.getBoundingClientRect(); + mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; + mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1); + raycaster.setFromCamera(mouse, camera); + const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0]; + return hit ? selectableTargets.get(hit.object) : undefined; +} + +export function updateHoverLabel(params: { + dragMode?: string; + hoverLabelEl: HTMLDivElement; + selection: Selectable | undefined; + activeSystemId?: string; + world?: WorldState; + point: THREE.Vector2; +}) { + const { + dragMode, + hoverLabelEl, + selection, + activeSystemId, + world, + point, + } = params; + + if (dragMode) { + hoverLabelEl.hidden = true; + return; + } + + if (!selection || selection.kind !== "system" || selection.id === activeSystemId) { + hoverLabelEl.hidden = true; + return; + } + + const system = world?.systems.get(selection.id); + if (!system) { + hoverLabelEl.hidden = true; + return; + } + + hoverLabelEl.hidden = false; + hoverLabelEl.textContent = system.label; + hoverLabelEl.style.left = `${point.x + 14}px`; + hoverLabelEl.style.top = `${point.y + 14}px`; +} + +export function updateMarqueeBox( + marqueeEl: HTMLDivElement, + dragStart: THREE.Vector2, + dragLast: THREE.Vector2, +) { + const minX = Math.min(dragStart.x, dragLast.x); + const minY = Math.min(dragStart.y, dragLast.y); + const maxX = Math.max(dragStart.x, dragLast.x); + const maxY = Math.max(dragStart.y, dragLast.y); + marqueeEl.style.left = `${minX}px`; + marqueeEl.style.top = `${minY}px`; + marqueeEl.style.width = `${maxX - minX}px`; + marqueeEl.style.height = `${maxY - minY}px`; +} + +export function hideMarqueeBox(marqueeEl: HTMLDivElement) { + marqueeEl.style.display = "none"; + marqueeEl.style.width = "0"; + marqueeEl.style.height = "0"; +} + +export function completeMarqueeSelection(params: { + renderer: THREE.WebGLRenderer; + camera: THREE.Camera; + dragStart: THREE.Vector2; + dragLast: THREE.Vector2; + selectableTargets: Map; +}) { + const { + renderer, + camera, + dragStart, + dragLast, + selectableTargets, + } = params; + + const bounds = renderer.domElement.getBoundingClientRect(); + const minX = Math.min(dragStart.x, dragLast.x); + const minY = Math.min(dragStart.y, dragLast.y); + const maxX = Math.max(dragStart.x, dragLast.x); + const maxY = Math.max(dragStart.y, dragLast.y); + const grouped = new Map(); + + for (const [object, selectable] of selectableTargets.entries()) { + if (object instanceof THREE.Sprite && !object.visible) { + continue; + } + if (!object.visible) { + continue; + } + + const worldPosition = new THREE.Vector3(); + object.getWorldPosition(worldPosition); + worldPosition.project(camera); + const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width; + const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height; + if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) { + continue; + } + + const group = getSelectionGroup(selectable); + const list = grouped.get(group) ?? []; + if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) { + list.push(selectable); + } + grouped.set(group, list); + } + + return [...grouped.entries()] + .sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? []; +} diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts new file mode 100644 index 0000000..4d22f2f --- /dev/null +++ b/apps/viewer/src/viewerInteractionController.ts @@ -0,0 +1,297 @@ +import * as THREE from "three"; +import { + completeMarqueeSelection, + hideMarqueeBox, + pickSelectableAtClientPosition, + updateHoverLabel, + updateMarqueeBox, +} from "./viewerInteraction"; +import { + applyKeyboardControl, + toggleCameraMode, + zoomFromWheel, +} from "./viewerControls"; +import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; +import type { + CameraMode, + DragMode, + Selectable, + WorldState, +} from "./viewerTypes"; + +export interface ViewerInteractionContext { + renderer: THREE.WebGLRenderer; + raycaster: THREE.Raycaster; + mouse: THREE.Vector2; + camera: THREE.PerspectiveCamera; + selectableTargets: Map; + hoverLabelEl: HTMLDivElement; + marqueeEl: HTMLDivElement; + keyState: Set; + getWorld: () => WorldState | undefined; + getActiveSystemId: () => string | undefined; + getSelectedItems: () => Selectable[]; + setSelectedItems: (items: Selectable[]) => void; + getDragMode: () => DragMode | undefined; + setDragMode: (mode: DragMode | undefined) => void; + getDragPointerId: () => number | undefined; + setDragPointerId: (pointerId: number | undefined) => void; + dragStart: THREE.Vector2; + dragLast: THREE.Vector2; + getMarqueeActive: () => boolean; + setMarqueeActive: (value: boolean) => void; + getSuppressClickSelection: () => boolean; + setSuppressClickSelection: (value: boolean) => void; + getDesiredDistance: () => number; + setDesiredDistance: (value: number) => void; + getCameraMode: () => CameraMode; + setCameraMode: (value: CameraMode) => void; + getCameraTargetShipId: () => string | undefined; + setCameraTargetShipId: (value: string | undefined) => void; + getFollowCameraPosition: () => THREE.Vector3; + getFollowCameraFocus: () => THREE.Vector3; + screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2; + applyOrbitDelta: (delta: THREE.Vector2) => void; + syncFollowStateFromSelection: () => void; + updatePanels: () => void; + focusOnSelection: (selection: Selectable) => void; + updateGamePanel: (mode: string) => void; + historyController: ViewerHistoryWindowController; +} + +export class ViewerInteractionController { + constructor(private readonly context: ViewerInteractionContext) {} + + readonly onPointerDown = (event: PointerEvent) => { + if (event.button === 1) { + this.context.setDragMode("orbit"); + this.context.setDragPointerId(event.pointerId); + this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); + this.context.renderer.domElement.setPointerCapture(event.pointerId); + return; + } + + if (event.button !== 0) { + return; + } + + this.context.setDragMode("marquee"); + this.context.setDragPointerId(event.pointerId); + this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); + this.context.dragLast.copy(this.context.dragStart); + this.context.setMarqueeActive(false); + this.context.renderer.domElement.setPointerCapture(event.pointerId); + }; + + readonly onPointerMove = (event: PointerEvent) => { + this.updateHoverLabel(event); + + if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) { + return; + } + + const point = this.context.screenPointFromClient(event.clientX, event.clientY); + if (this.context.getDragMode() === "orbit") { + const delta = point.clone().sub(this.context.dragLast); + this.context.dragLast.copy(point); + this.context.applyOrbitDelta(delta); + return; + } + + const dragDistance = point.distanceTo(this.context.dragStart); + if (!this.context.getMarqueeActive() && dragDistance > 8) { + this.context.setMarqueeActive(true); + this.context.setSuppressClickSelection(true); + this.context.marqueeEl.style.display = "block"; + } + + if (!this.context.getMarqueeActive()) { + return; + } + + this.context.dragLast.copy(point); + updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast); + }; + + readonly onPointerUp = (event: PointerEvent) => { + if (this.context.getDragPointerId() !== event.pointerId) { + return; + } + + if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { + this.context.renderer.domElement.releasePointerCapture(event.pointerId); + } + + if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { + this.completeMarqueeSelection(); + hideMarqueeBox(this.context.marqueeEl); + } + + this.context.setDragMode(undefined); + this.context.setDragPointerId(undefined); + this.context.setMarqueeActive(false); + }; + + readonly onClick = (event: MouseEvent) => { + if (this.context.getSuppressClickSelection()) { + this.context.setSuppressClickSelection(false); + return; + } + + const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); + this.context.setSelectedItems(picked ? [picked] : []); + this.context.syncFollowStateFromSelection(); + this.context.updatePanels(); + }; + + readonly onShipStripClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const historyButton = target.closest("[data-history-ship-id]"); + const historyShipId = historyButton?.dataset.historyShipId; + if (historyShipId) { + this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId }); + return; + } + + const card = target.closest("[data-ship-id]"); + const shipId = card?.dataset.shipId; + if (!shipId) { + return; + } + + this.context.setSelectedItems([{ kind: "ship", id: shipId }]); + this.context.syncFollowStateFromSelection(); + this.context.updatePanels(); + }; + + readonly onShipStripDoubleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest("[data-history-ship-id]")) { + return; + } + + const card = target.closest("[data-ship-id]"); + const shipId = card?.dataset.shipId; + if (!shipId) { + return; + } + + this.context.setSelectedItems([{ kind: "ship", id: shipId }]); + this.context.syncFollowStateFromSelection(); + this.context.focusOnSelection({ kind: "ship", id: shipId }); + this.toggleCameraMode("follow"); + this.context.updatePanels(); + this.context.updateGamePanel("Live"); + }; + + readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event); + + readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event); + + readonly onHistoryWindowPointerMove = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerMove(event); + + readonly onHistoryWindowPointerUp = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerUp(event); + + readonly onDoubleClick = () => { + const selectedItems = this.context.getSelectedItems(); + if (selectedItems.length !== 1) { + return; + } + + this.context.focusOnSelection(selectedItems[0]); + this.context.syncFollowStateFromSelection(); + }; + + readonly onWheel = (event: WheelEvent) => { + event.preventDefault(); + this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY)); + this.context.updateGamePanel("Live"); + }; + + readonly onKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return; + } + + const key = event.key.toLowerCase(); + const controlState = applyKeyboardControl({ + keyState: this.context.keyState, + cameraMode: this.context.getCameraMode(), + desiredDistance: this.context.getDesiredDistance(), + key, + }); + this.context.setCameraMode(controlState.cameraMode); + this.context.setDesiredDistance(controlState.desiredDistance); + if (key === "c") { + this.toggleCameraMode(); + } + this.context.updateGamePanel("Live"); + }; + + readonly onKeyUp = (event: KeyboardEvent) => { + this.context.keyState.delete(event.key.toLowerCase()); + }; + + updateHoverLabel(event: PointerEvent) { + updateHoverLabel({ + dragMode: this.context.getDragMode(), + hoverLabelEl: this.context.hoverLabelEl, + selection: this.pickSelectableAtClientPosition(event.clientX, event.clientY), + activeSystemId: this.context.getActiveSystemId(), + world: this.context.getWorld(), + point: this.context.screenPointFromClient(event.clientX, event.clientY), + }); + } + + refreshHistoryWindows() { + this.context.historyController.refreshHistoryWindows(); + } + + toggleCameraMode(forceMode?: CameraMode) { + const nextState = toggleCameraMode({ + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + selectedItems: this.context.getSelectedItems(), + desiredDistance: this.context.getDesiredDistance(), + followCameraPosition: this.context.getFollowCameraPosition(), + followCameraFocus: this.context.getFollowCameraFocus(), + forceMode, + }); + this.context.setCameraMode(nextState.cameraMode); + this.context.setCameraTargetShipId(nextState.cameraTargetShipId); + this.context.setDesiredDistance(nextState.desiredDistance); + } + + private pickSelectableAtClientPosition(clientX: number, clientY: number) { + return pickSelectableAtClientPosition( + this.context.renderer, + this.context.raycaster, + this.context.mouse, + this.context.camera, + this.context.selectableTargets, + clientX, + clientY, + ); + } + + private completeMarqueeSelection() { + const selection = completeMarqueeSelection({ + renderer: this.context.renderer, + camera: this.context.camera, + dragStart: this.context.dragStart, + dragLast: this.context.dragLast, + selectableTargets: this.context.selectableTargets, + }); + this.context.setSelectedItems(selection); + this.context.syncFollowStateFromSelection(); + this.context.updatePanels(); + } +} diff --git a/apps/viewer/src/viewerMath.ts b/apps/viewer/src/viewerMath.ts new file mode 100644 index 0000000..f97cf7a --- /dev/null +++ b/apps/viewer/src/viewerMath.ts @@ -0,0 +1,193 @@ +import * as THREE from "three"; +import { MOON_RENDER_SCALE } from "./viewerConstants"; +import type { + PlanetSnapshot, + Vector3Dto, + WorldSnapshot, +} from "./contracts"; +import type { + OrbitalAnchor, + WorldState, + ZoomLevel, +} from "./viewerTypes"; +import type { ZoomBlend } from "./viewerConstants"; + +export function formatInventory(entries: { itemId: string; amount: number }[]): string { + if (entries.length === 0) { + return "empty"; + } + + return entries + .map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`) + .join("
"); +} + +export function inventoryAmount(entries: { itemId: string; amount: number }[], itemId: string): number { + return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0; +} + +export function formatVector(vector: Vector3Dto): string { + return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`; +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${Math.round(bytes)} B`; +} + +export function smoothBand(value: number, start: number, end: number): number { + const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1); + return t * t * (3 - (2 * t)); +} + +export function computeZoomBlend(distance: number): ZoomBlend { + const localToSystem = smoothBand(distance, 1200, 5200); + const systemToUniverse = smoothBand(distance, 9000, 22000); + + return { + localWeight: 1 - localToSystem, + systemWeight: Math.min(localToSystem, 1 - systemToUniverse), + universeWeight: systemToUniverse, + }; +} + +export function classifyZoomLevel(distance: number): ZoomLevel { + const blend = computeZoomBlend(distance); + if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) { + return "local"; + } + if (blend.systemWeight >= blend.universeWeight) { + return "system"; + } + return "universe"; +} + +export function toThreeVector(vector: Vector3Dto): THREE.Vector3 { + return new THREE.Vector3(vector.x, vector.y, vector.z); +} + +export function currentWorldTimeSeconds(world: WorldState | undefined, worldTimeSyncMs: number): number { + if (!world) { + return 0; + } + + const baseUtcMs = Date.parse(world.generatedAtUtc); + const elapsedMs = performance.now() - worldTimeSyncMs; + return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97); +} + +export function hashUnit(seed: number, value: string): number { + let hash = seed; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash) + value.charCodeAt(index); + hash |= 0; + } + + return (hash >>> 0) / 0xffffffff; +} + +export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number): THREE.Vector3 { + const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85); + const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed); + const eccentricAnomaly = meanAnomaly + + (eccentricity * Math.sin(meanAnomaly)) + + (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly)); + const semiMajorAxis = planet.orbitRadius; + const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05)); + const local = new THREE.Vector3( + semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity), + 0, + semiMinorAxis * Math.sin(eccentricAnomaly), + ); + + local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis)); + local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination)); + local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode)); + return local; +} + +export function computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number { + const spacing = planet.size * 1.4; + const variance = hashUnit(seed, `${planet.label}:${moonIndex}:radius`) * planet.size * 0.9; + return (planet.size * 1.8) + (moonIndex * spacing) + variance; +} + +export function computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number, seed: number): number { + const radius = computeMoonOrbitRadius(planet, moonIndex, seed); + return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003); +} + +export function computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number, seed: number): THREE.Vector3 { + const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed); + const speed = computeMoonOrbitSpeed(planet, moonIndex, seed); + const phase = hashUnit(seed, `${planet.label}:${moonIndex}:phase`) * Math.PI * 2; + const inclination = THREE.MathUtils.degToRad((hashUnit(seed, `${planet.label}:${moonIndex}:inclination`) - 0.5) * 28); + const node = THREE.MathUtils.degToRad(hashUnit(seed, `${planet.label}:${moonIndex}:node`) * 360); + const angle = phase + (timeSeconds * speed); + + const local = new THREE.Vector3( + Math.cos(angle) * orbitRadius, + 0, + Math.sin(angle) * orbitRadius, + ); + local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination); + local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node); + return local; +} + +export function computeMoonSize(planet: PlanetSnapshot, moonIndex: number, seed: number): number { + const base = Math.max(2.2, planet.size * 0.11); + const variance = hashUnit(seed, `${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5); + return Math.min(base + variance, planet.size * 0.42); +} + +export function celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1): number { + return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale); +} + +export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number { + return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), MOON_RENDER_SCALE, 2.5, 1.04); +} + +export function starHaloOpacity(starKind: string): number { + if (starKind.includes("neutron")) { + return 0.22; + } + if (starKind.includes("white-dwarf")) { + return 0.18; + } + if (starKind.includes("brown-dwarf")) { + return 0.1; + } + return 0.14; +} + +export function resolveOrbitalAnchorPosition( + world: WorldState | undefined, + systemId: string, + anchor: OrbitalAnchor, + timeSeconds: number, + seed: number, +): THREE.Vector3 { + if (!world || anchor.kind === "star") { + return new THREE.Vector3(); + } + + const system = world.systems.get(systemId); + const planet = system?.planets[anchor.planetIndex]; + if (!system || !planet) { + return new THREE.Vector3(); + } + + const planetPosition = computePlanetLocalPosition(planet, timeSeconds); + if (anchor.kind === "planet") { + return planetPosition; + } + + return planetPosition.add(computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds, seed)); +} diff --git a/apps/viewer/src/viewerNavigationController.ts b/apps/viewer/src/viewerNavigationController.ts new file mode 100644 index 0000000..94178c6 --- /dev/null +++ b/apps/viewer/src/viewerNavigationController.ts @@ -0,0 +1,193 @@ +import * as THREE from "three"; +import { + determineActiveSystemId, + focusOnSelection, + getCameraFocusWorldPosition, + resolveSelectionPosition, + seedSystemFocusLocal, + toDisplayLocalPosition, +} from "./viewerCamera"; +import { + syncFollowStateFromSelection, + updateFollowCamera, + updateSystemDetailVisibility, +} from "./viewerControls"; +import { computeNodeLocalPosition, resolveBubblePosition, resolvePointPosition } from "./viewerWorldPresentation"; +import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation"; +import type { + CameraMode, + NodeVisual, + PlanetVisual, + Selectable, + ShipVisual, + SystemVisual, + WorldState, +} from "./viewerTypes"; + +export interface ViewerNavigationContext { + getWorld: () => WorldState | undefined; + getWorldTimeSyncMs: () => number; + getActiveSystemId: () => string | undefined; + setActiveSystemId: (value: string | undefined) => void; + getCameraMode: () => CameraMode; + setCameraMode: (value: CameraMode) => void; + getCameraTargetShipId: () => string | undefined; + setCameraTargetShipId: (value: string | undefined) => void; + getCurrentDistance: () => number; + getSelectedItems: () => Selectable[]; + getOrbitYaw: () => number; + galaxyFocus: THREE.Vector3; + systemFocusLocal: THREE.Vector3; + camera: THREE.PerspectiveCamera; + shipVisuals: Map; + nodeVisuals: Map; + planetVisuals: PlanetVisual[]; + systemVisuals: Map; + followCameraPosition: THREE.Vector3; + followCameraFocus: THREE.Vector3; + followCameraDirection: THREE.Vector3; + followCameraDesiredDirection: THREE.Vector3; + followCameraOffset: THREE.Vector3; + createWorldPresentationContext: () => any; + updatePanels: () => void; + updateGamePanel: (mode: string) => void; +} + +export class ViewerNavigationController { + constructor(private readonly context: ViewerNavigationContext) {} + + focusOnSelection(selection: Selectable) { + focusOnSelection({ + world: this.context.getWorld(), + selection, + worldTimeSyncMs: this.context.getWorldTimeSyncMs(), + nodeVisuals: this.context.nodeVisuals, + planetVisuals: this.context.planetVisuals, + computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), + resolveBubblePosition: (bubbleId) => { + const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); + return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; + }, + resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId), + activeSystemId: this.context.getActiveSystemId(), + galaxyFocus: this.context.galaxyFocus, + systemFocusLocal: this.context.systemFocusLocal, + }); + } + + resolveSelectionPosition(selection: Selectable) { + return resolveSelectionPosition({ + world: this.context.getWorld(), + selection, + worldTimeSyncMs: this.context.getWorldTimeSyncMs(), + nodeVisuals: this.context.nodeVisuals, + planetVisuals: this.context.planetVisuals, + computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), + resolveBubblePosition: (bubbleId) => { + const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); + return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; + }, + resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId), + }); + } + + updateActiveSystem() { + const nextActiveSystemId = determineActiveSystemId({ + world: this.context.getWorld(), + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + currentDistance: this.context.getCurrentDistance(), + selectedItems: this.context.getSelectedItems(), + galaxyFocus: this.context.galaxyFocus, + }); + if (nextActiveSystemId === this.context.getActiveSystemId()) { + return; + } + + if (nextActiveSystemId) { + this.seedSystemFocusLocal(nextActiveSystemId); + } + + this.context.setActiveSystemId(nextActiveSystemId); + this.updateSystemDetailVisibility(); + this.context.updatePanels(); + this.context.updateGamePanel("Live"); + } + + updateFollowCamera(delta: number) { + const nextState = updateFollowCamera({ + world: this.context.getWorld(), + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + shipVisuals: this.context.shipVisuals, + currentDistance: this.context.getCurrentDistance(), + camera: this.context.camera, + followCameraPosition: this.context.followCameraPosition, + followCameraFocus: this.context.followCameraFocus, + followCameraDirection: this.context.followCameraDirection, + followCameraDesiredDirection: this.context.followCameraDesiredDirection, + followCameraOffset: this.context.followCameraOffset, + systemFocusLocal: this.context.systemFocusLocal, + delta, + getAnimatedShipLocalPosition, + toDisplayLocalPosition: (localPosition, systemId) => this.toDisplayLocalPosition(localPosition, systemId), + resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()), + }); + this.context.setCameraMode(nextState.cameraMode); + this.context.setCameraTargetShipId(nextState.cameraTargetShipId); + return nextState.handled; + } + + syncFollowStateFromSelection() { + const nextState = syncFollowStateFromSelection( + this.context.getSelectedItems(), + this.context.getCameraMode(), + this.context.getCameraTargetShipId(), + ); + this.context.setCameraMode(nextState.cameraMode); + this.context.setCameraTargetShipId(nextState.cameraTargetShipId); + } + + updateSystemDetailVisibility() { + updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId()); + } + + getCameraFocusWorldPosition() { + return getCameraFocusWorldPosition({ + world: this.context.getWorld(), + activeSystemId: this.context.getActiveSystemId(), + galaxyFocus: this.context.galaxyFocus, + systemFocusLocal: this.context.systemFocusLocal, + }); + } + + seedSystemFocusLocal(systemId: string) { + seedSystemFocusLocal({ + world: this.context.getWorld(), + systemId, + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + selectedItems: this.context.getSelectedItems(), + systemFocusLocal: this.context.systemFocusLocal, + worldTimeSyncMs: this.context.getWorldTimeSyncMs(), + nodeVisuals: this.context.nodeVisuals, + planetVisuals: this.context.planetVisuals, + computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds), + resolveBubblePosition: (bubbleId) => { + const bubble = this.context.getWorld()?.localBubbles.get(bubbleId); + return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined; + }, + resolvePointPosition: (systemIdValue, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, nodeId), + }); + } + + toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { + return toDisplayLocalPosition({ + world: this.context.getWorld(), + systemId, + activeSystemId: this.context.getActiveSystemId(), + localPosition, + systemFocusLocal: this.context.systemFocusLocal, + }); + } +} diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts new file mode 100644 index 0000000..13cf6ee --- /dev/null +++ b/apps/viewer/src/viewerPanels.ts @@ -0,0 +1,296 @@ +import { formatInventory, formatVector } from "./viewerMath"; +import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; +import type { + CameraMode, + HistoryWindowState, + NodeVisual, + OrbitalAnchor, + Selectable, + ShipVisual, + StructureVisual, + WorldState, +} from "./viewerTypes"; + +interface DetailPanelParams { + world: WorldState; + selectedItems: Selectable[]; + zoomLevel: string; + cameraMode: CameraMode; + cameraTargetShipId?: string; + worldLabel: string; + describeSelectionParent: (selection: Selectable) => string; +} + +interface SystemPanelParams { + world: WorldState; + activeSystemId?: string; + systemTitleEl: HTMLHeadingElement; + systemBodyEl: HTMLDivElement; + systemPanelEl: HTMLDivElement; + cameraMode: CameraMode; + cameraTargetShipId?: string; +} + +export function updateDetailPanel( + detailTitleEl: HTMLHeadingElement, + detailBodyEl: HTMLDivElement, + params: DetailPanelParams, +) { + const { + world, + selectedItems, + zoomLevel, + cameraMode, + cameraTargetShipId, + worldLabel, + describeSelectionParent, + } = params; + + if (selectedItems.length === 0) { + detailTitleEl.textContent = worldLabel; + detailBodyEl.innerHTML = ` + Zoom ${zoomLevel}
+ Systems ${world.systems.size}
+ Spatial nodes ${world.spatialNodes.size}
+ Bubbles ${world.localBubbles.size}
+ Stations ${world.stations.size}
+ Claims ${world.claims.size}
+ Construction ${world.constructionSites.size}
+ Ships ${world.ships.size}
+ Recent events ${world.recentEvents.length} + `; + return; + } + + if (selectedItems.length > 1) { + const group = getSelectionGroup(selectedItems[0]); + detailTitleEl.textContent = `${selectedItems.length} selected`; + detailBodyEl.innerHTML = ` + Type ${group}
+ ${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("
")} + `; + return; + } + + const selected = selectedItems[0]; + if (selected.kind === "ship") { + const ship = world.ships.get(selected.id); + if (!ship) { + return; + } + const parent = describeSelectionParent(selected); + const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); + detailTitleEl.textContent = ship.label; + detailBodyEl.innerHTML = ` +

Parent ${parent}

+

State ${ship.state}

+

Energy ${ship.energyStored.toFixed(0)}
Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

+

Inventory ${formatInventory(ship.inventory)}

+

Velocity ${formatVector(ship.localVelocity)}

+

Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}
Press C to toggle follow

+ `; + return; + } + + if (selected.kind === "station") { + const station = world.stations.get(selected.id); + if (!station) { + return; + } + const parent = describeSelectionParent(selected); + detailTitleEl.textContent = station.label; + detailBodyEl.innerHTML = ` +

${station.category} · ${station.systemId}

+

Parent ${parent}

+

Energy ${station.energyStored.toFixed(0)}
Docked ${station.dockedShips} / ${station.dockingPads}

+

Inventory ${formatInventory(station.inventory)}

+

History available in the separate history window.

+ `; + return; + } + + if (selected.kind === "node") { + const node = world.nodes.get(selected.id); + if (!node) { + return; + } + const parent = describeSelectionParent(selected); + detailTitleEl.textContent = `Node ${node.id}`; + detailBodyEl.innerHTML = ` +

${node.systemId}

+

Parent ${parent}

+

Source ${node.sourceKind}
Resource ${node.itemId}

+

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

+ `; + return; + } + + if (selected.kind === "spatial-node") { + const node = world.spatialNodes.get(selected.id); + if (!node) { + return; + } + const bubble = world.localBubbles.get(node.bubbleId); + detailTitleEl.textContent = `${node.kind} node`; + 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 = world.localBubbles.get(selected.id); + if (!bubble) { + return; + } + detailTitleEl.textContent = `Bubble ${bubble.id}`; + 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 = world.claims.get(selected.id); + if (!claim) { + return; + } + detailTitleEl.textContent = `Claim ${claim.id}`; + 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 = world.constructionSites.get(selected.id); + if (!site) { + return; + } + const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length; + detailTitleEl.textContent = `Construction ${site.id}`; + 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 = world.systems.get(selected.systemId); + const planet = system?.planets[selected.planetIndex]; + if (!system || !planet) { + return; + } + const parent = describeSelectionParent(selected); + detailTitleEl.textContent = planet.label; + detailBodyEl.innerHTML = ` +

${system.label}

+

Parent ${parent}

+

${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}

+

Orbit ${planet.orbitRadius.toFixed(0)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

+

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

+ `; + return; + } + + const system = world.systems.get(selected.id); + if (!system) { + return; + } + + detailTitleEl.textContent = system.label; + detailBodyEl.innerHTML = ` +

Parent galaxy

+ ${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)} + `; +} + +export function updateSystemPanel(params: SystemPanelParams) { + const { + world, + activeSystemId, + systemTitleEl, + systemBodyEl, + systemPanelEl, + cameraMode, + cameraTargetShipId, + } = params; + + const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined; + systemPanelEl.hidden = !activeSystem; + + if (!activeSystem) { + systemTitleEl.textContent = "Deep Space"; + systemBodyEl.innerHTML = ""; + return; + } + + systemTitleEl.textContent = activeSystem.label; + systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId); +} + +export function describeSelectionParent( + world: WorldState | undefined, + selection: Selectable, + stationVisuals: Map, + nodeVisuals: Map, +) { + if (!world) { + return "unknown"; + } + + if (selection.kind === "system") { + return "galaxy"; + } + if (selection.kind === "planet") { + const system = world.systems.get(selection.systemId); + return system ? `${system.label} star` : selection.systemId; + } + if (selection.kind === "ship") { + const ship = world.ships.get(selection.id); + if (!ship) { + return "unknown"; + } + const system = world.systems.get(ship.systemId); + return system ? `${system.label} system` : ship.systemId; + } + if (selection.kind === "station") { + const station = world.stations.get(selection.id); + const visual = station ? stationVisuals.get(selection.id) : undefined; + return describeOrbitalParent(world, station?.systemId, visual?.anchor); + } + if (selection.kind === "node") { + const node = world.nodes.get(selection.id); + const visual = node ? nodeVisuals.get(selection.id) : undefined; + return describeOrbitalParent(world, node?.systemId, visual?.anchor); + } + if (selection.kind === "spatial-node") { + const node = world.spatialNodes.get(selection.id); + return node?.parentNodeId ?? `${node?.systemId ?? "unknown"} network`; + } + if (selection.kind === "bubble") { + return `${world.localBubbles.get(selection.id)?.nodeId ?? "unknown"} node`; + } + if (selection.kind === "claim") { + return world.claims.get(selection.id)?.nodeId ?? "unknown"; + } + if (selection.kind === "construction-site") { + return world.constructionSites.get(selection.id)?.nodeId ?? "unknown"; + } + + return "unknown"; +} diff --git a/apps/viewer/src/viewerPresentation.ts b/apps/viewer/src/viewerPresentation.ts new file mode 100644 index 0000000..ea0072e --- /dev/null +++ b/apps/viewer/src/viewerPresentation.ts @@ -0,0 +1,126 @@ +import * as THREE from "three"; +import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants"; +import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath"; +import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes"; + +export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { + const elapsedMs = now - visual.receivedAtMs; + const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); + return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); +} + +export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3, orbitYaw: number) { + const desiredHeading = visual.targetPosition.clone().sub(worldPosition); + if (desiredHeading.lengthSq() > 0.01) { + return desiredHeading; + } + + if (visual.velocity.lengthSq() > 0.01) { + return visual.velocity.clone(); + } + + return new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw)); +} + +export function updatePlanetPresentation( + world: WorldState | undefined, + worldTimeSyncMs: number, + activeSystemId: string | undefined, + systemFocusLocal: THREE.Vector3, + planetVisuals: PlanetVisual[], +) { + const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs); + for (const visual of planetVisuals) { + const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; + const localPosition = computePlanetLocalPosition(visual.planet, nowSeconds); + const orbitOffset = visual.systemId === activeSystemId + ? systemFocusLocal.clone().multiplyScalar(-scale) + : new THREE.Vector3(); + const position = visual.systemId === activeSystemId + ? localPosition.clone().sub(systemFocusLocal).multiplyScalar(scale) + : localPosition.multiplyScalar(scale); + + visual.orbit.scale.setScalar(scale); + visual.orbit.position.copy(orbitOffset); + visual.mesh.position.copy(position); + visual.icon.position.copy(position); + if (visual.ring) { + visual.ring.position.copy(position); + } + + for (const [moonIndex, moon] of visual.moons.entries()) { + moon.orbit.position.copy(position); + moon.orbit.scale.setScalar(scale); + moon.mesh.position.copy(position).add( + computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale), + ); + } + } +} + +export function updateSystemSummaryPresentation( + systemSummaryVisuals: Map, + camera: THREE.PerspectiveCamera, + activeSystemId?: string, +) { + const distanceScale = activeSystemId ? 0.05 : 0.085; + for (const [systemId, visual] of systemSummaryVisuals.entries()) { + const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3()); + const distance = camera.position.distanceTo(worldPosition); + const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400; + const scale = Math.max(minimumScale, distance * distanceScale); + visual.sprite.scale.set(scale, scale * 0.3125, 1); + } +} + +export function updateSystemStarPresentation( + systemVisuals: Map, + activeSystemId: string | undefined, + systemFocusLocal: THREE.Vector3, + camera: THREE.PerspectiveCamera, + setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void, +) { + const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined; + + for (const [systemId, visual] of systemVisuals.entries()) { + visual.root.position.copy(visual.galaxyPosition); + visual.shellReticle.scale.setScalar(visual.shellReticleBaseScale); + + if (!activeSystem) { + visual.starCluster.position.set(0, 0, 0); + visual.icon.position.set(0, 0, 0); + visual.icon.visible = true; + visual.shellReticle.position.set(0, 0, 0); + visual.shellReticle.visible = false; + setShellReticleOpacity(visual.shellReticle, 0); + continue; + } + + if (systemId !== activeSystemId) { + visual.starCluster.position.set(0, 0, 0); + visual.icon.position.set(0, 0, 0); + visual.icon.visible = false; + visual.shellReticle.position.set(0, 0, 0); + visual.shellReticle.visible = true; + setShellReticleOpacity(visual.shellReticle, 1); + const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); + if (direction.lengthSq() > 0.0001) { + visual.root.position.copy( + activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), + ); + } + const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); + const reticleDistance = camera.position.distanceTo(reticleWorldPosition); + const reticleScale = Math.max(900, reticleDistance * 0.032); + visual.shellReticle.scale.setScalar(reticleScale); + continue; + } + + const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); + visual.starCluster.position.copy(offset); + visual.icon.position.copy(offset); + visual.icon.visible = true; + visual.shellReticle.visible = false; + setShellReticleOpacity(visual.shellReticle, 0); + } +} diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts new file mode 100644 index 0000000..234c1e6 --- /dev/null +++ b/apps/viewer/src/viewerPresentationController.ts @@ -0,0 +1,182 @@ +import * as THREE from "three"; +import { computeZoomBlend } from "./viewerMath"; +import { + updateNetworkPanel as renderNetworkPanel, + recordPerformanceStats, + updatePerformancePanel as renderPerformancePanel, +} from "./viewerTelemetry"; +import { updatePlanetPresentation } from "./viewerPresentation"; +import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation"; +import { updateSystemPanel } from "./viewerPanels"; +import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory"; + +export interface ViewerPresentationContext { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + ambienceGroup: THREE.Group; + statusEl: HTMLDivElement; + networkPanelEl: HTMLDivElement; + performancePanelEl: HTMLDivElement; + systemPanelEl: HTMLDivElement; + systemTitleEl: HTMLHeadingElement; + systemBodyEl: HTMLDivElement; + networkStats: any; + performanceStats: any; + getWorld: () => any; + getActiveSystemId: () => string | undefined; + getCameraMode: () => any; + getCameraTargetShipId: () => string | undefined; + getZoomLevel: () => any; + getWorldTimeSyncMs: () => number; + getCurrentDistance: () => number; + systemFocusLocal: THREE.Vector3; + planetVisuals: any[]; + systemSummaryVisuals: Map; + presentationEntries: any[]; + orbitLines: THREE.Object3D[]; + systemVisuals: Map; + createWorldPresentationContext: () => any; +} + +export class ViewerPresentationController { + constructor(private readonly context: ViewerPresentationContext) {} + + initializeAmbience() { + this.context.ambienceGroup.renderOrder = -10; + this.context.ambienceGroup.add(createBackdropStars()); + this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document))); + } + + updateAmbience(delta: number) { + this.context.ambienceGroup.position.copy(this.context.camera.position); + this.context.ambienceGroup.rotation.y += delta * 0.005; + this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; + } + + applyZoomPresentation() { + const activeSystemId = this.context.getActiveSystemId(); + const blend = computeZoomBlend(this.context.getCurrentDistance()); + + for (const entry of this.context.presentationEntries) { + const systemId = entry.systemId; + const isActiveDetail = !systemId || systemId === activeSystemId; + const isProjectedSystemIcon = !!activeSystemId + && !!systemId + && systemId !== activeSystemId + && this.context.systemVisuals.get(systemId)?.icon === entry.icon; + const detailAlpha = entry.hideDetailInUniverse + ? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0) + : 1; + const iconAlpha = isProjectedSystemIcon + ? 0 + : entry.hideIconInUniverse + ? blend.systemWeight * (isActiveDetail ? 1 : 0) + : Math.max(blend.systemWeight, blend.universeWeight); + + this.setObjectOpacity(entry.detail, detailAlpha); + this.setObjectOpacity(entry.icon, iconAlpha); + } + + for (const orbitLine of this.context.orbitLines) { + const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (activeSystemId ? 1 : 0); + this.setObjectOpacity(orbitLine, alpha); + } + + for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) { + const summaryOpacity = systemId === activeSystemId + ? 0 + : (activeSystemId ? 0.72 : 0.96); + this.setObjectOpacity(summaryVisual.sprite, summaryOpacity); + } + + this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035); + } + + updateNetworkPanel() { + renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats); + } + + recordPerformanceStats(frameMs: number) { + recordPerformanceStats(this.context.performanceStats, frameMs); + } + + updatePerformancePanel() { + renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer); + } + + updateShipPresentation() { + updateWorldPresentation(this.context.createWorldPresentationContext()); + } + + updatePlanetPresentation() { + const world = this.context.getWorld(); + updatePlanetPresentation( + world, + this.context.getWorldTimeSyncMs(), + this.context.getActiveSystemId(), + this.context.systemFocusLocal, + this.context.planetVisuals, + ); + } + + updateSystemSummaries() { + updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals); + } + + renderRecentEvents(entityKind: string, entityId: string) { + return renderRecentEvents(this.context.getWorld(), entityKind, entityId); + } + + updateGamePanel(mode: string) { + updateGameStatus({ + statusEl: this.context.statusEl, + world: this.context.getWorld(), + activeSystemId: this.context.getActiveSystemId(), + cameraMode: this.context.getCameraMode(), + zoomLevel: this.context.getZoomLevel(), + mode, + }); + } + + updateSystemPanel() { + const world = this.context.getWorld(); + if (!world) { + return; + } + + updateSystemPanel({ + world, + activeSystemId: this.context.getActiveSystemId(), + systemTitleEl: this.context.systemTitleEl, + systemBodyEl: this.context.systemBodyEl, + systemPanelEl: this.context.systemPanelEl, + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + }); + } + + screenPointFromClient(clientX: number, clientY: number) { + const bounds = this.context.renderer.domElement.getBoundingClientRect(); + return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); + } + + private setObjectOpacity(object: THREE.Object3D, opacity: number) { + const visible = opacity > 0.02; + object.visible = visible; + object.traverse((child) => { + if (!("material" in child)) { + return; + } + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + if (!("opacity" in material)) { + continue; + } + material.transparent = true; + material.opacity = opacity; + material.needsUpdate = true; + } + }); + } +} diff --git a/apps/viewer/src/viewerRenderLoop.ts b/apps/viewer/src/viewerRenderLoop.ts new file mode 100644 index 0000000..25c0dd9 --- /dev/null +++ b/apps/viewer/src/viewerRenderLoop.ts @@ -0,0 +1,59 @@ +import * as THREE from "three"; +import { classifyZoomLevel } from "./viewerMath"; +import type { PerformanceStats } from "./viewerTypes"; + +export interface RenderFrameParams { + clock: THREE.Clock; + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + updateCamera: (delta: number) => void; + updateAmbience: (delta: number) => void; + updatePlanetPresentation: () => void; + updateShipPresentation: () => void; + updateNetworkPanel: () => void; + applyZoomPresentation: () => void; + recordPerformanceStats: (frameMs: number) => void; + updatePerformancePanel: () => void; +} + +export interface ResizeParams { + renderer: THREE.WebGLRenderer; + camera: THREE.PerspectiveCamera; +} + +export interface CameraStepParams { + currentDistance: number; + desiredDistance: number; + orbitPitch: number; + delta: number; +} + +export function renderFrame(params: RenderFrameParams) { + const frameStartedAtMs = performance.now(); + const delta = Math.min(params.clock.getDelta(), 0.033); + params.updateCamera(delta); + params.updateAmbience(delta); + params.updatePlanetPresentation(); + params.updateShipPresentation(); + params.updateNetworkPanel(); + params.applyZoomPresentation(); + params.renderer.render(params.scene, params.camera); + params.recordPerformanceStats(performance.now() - frameStartedAtMs); + params.updatePerformancePanel(); +} + +export function resizeViewer(params: ResizeParams) { + const width = window.innerWidth; + const height = window.innerHeight; + params.camera.aspect = width / height; + params.camera.updateProjectionMatrix(); + params.renderer.setSize(width, height); +} + +export function stepCamera(params: CameraStepParams) { + const currentDistance = THREE.MathUtils.damp(params.currentDistance, params.desiredDistance, 7.5, params.delta); + const zoomLevel = classifyZoomLevel(currentDistance); + const orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3); + return { currentDistance, zoomLevel, orbitPitch }; +} diff --git a/apps/viewer/src/viewerSceneAppearance.ts b/apps/viewer/src/viewerSceneAppearance.ts new file mode 100644 index 0000000..2d170ca --- /dev/null +++ b/apps/viewer/src/viewerSceneAppearance.ts @@ -0,0 +1,67 @@ +import * as THREE from "three"; +import type { ShipSnapshot } from "./contracts"; + +export function shipSize(ship: ShipSnapshot) { + switch (ship.shipClass) { + case "capital": + return 18; + case "cruiser": + return 13; + case "destroyer": + return 10; + case "industrial": + return 11; + default: + return 8; + } +} + +export function shipLength(ship: ShipSnapshot) { + return shipSize(ship) * 2.6; +} + +export function shipColor(role: ShipSnapshot["role"]) { + if (role === "mining") { + return "#ffcf6e"; + } + if (role === "transport") { + return "#9ff0aa"; + } + return "#8bc0ff"; +} + +export function 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 shipColor(ship.role); +} + +export function 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"; +} + +export function 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); + }); +} diff --git a/apps/viewer/src/viewerSceneDataController.ts b/apps/viewer/src/viewerSceneDataController.ts new file mode 100644 index 0000000..43726aa --- /dev/null +++ b/apps/viewer/src/viewerSceneDataController.ts @@ -0,0 +1,222 @@ +import * as THREE from "three"; +import { + applyClaimDeltas as applyClaimDeltaUpdates, + applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates, + applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates, + applyNodeDeltas as applyNodeDeltaUpdates, + applyShipDeltas as applyShipDeltaUpdates, + applySpatialNodeDeltas as applySpatialNodeDeltaUpdates, + applyStationDeltas as applyStationDeltaUpdates, + rebuildSystems as rebuildSystemScene, + syncClaims as syncClaimScene, + syncConstructionSites as syncConstructionSiteScene, + syncLocalBubbles as syncBubbleScene, + syncNodes as syncNodeScene, + syncShips as syncShipScene, + syncSpatialNodes as syncSpatialNodeScene, + syncStations as syncStationScene, +} from "./viewerSceneSync"; +import { + deriveNodeOrbital, + deriveOrbitalFromLocalPosition, + resolveBubblePosition, + resolveOrbitalAnchor, + resolvePointPosition, + setBubbleVisualState, +} from "./viewerWorldPresentation"; +import { + createCirclePoints, + shipLength, + shipPresentationColor, + shipSize, + spatialNodeColor, +} from "./viewerSceneAppearance"; +import type { + ClaimDelta, + ClaimSnapshot, + ConstructionSiteDelta, + ConstructionSiteSnapshot, + LocalBubbleDelta, + LocalBubbleSnapshot, + ResourceNodeDelta, + ResourceNodeSnapshot, + ShipDelta, + ShipSnapshot, + SpatialNodeDelta, + SpatialNodeSnapshot, + StationDelta, + StationSnapshot, + SystemSnapshot, +} from "./contracts"; +import type { + OrbitalAnchor, +} from "./viewerTypes"; + +export interface ViewerSceneDataContext { + documentRef: Document; + getWorldGeneratedAtUtc: () => string | undefined; + getWorldSeed: () => number; + getWorldTimeSyncMs: () => number; + getWorldPresentationContext: () => any; + systemGroup: THREE.Group; + spatialNodeGroup: THREE.Group; + bubbleGroup: THREE.Group; + nodeGroup: THREE.Group; + stationGroup: THREE.Group; + claimGroup: THREE.Group; + constructionSiteGroup: THREE.Group; + shipGroup: THREE.Group; + selectableTargets: Map; + presentationEntries: any[]; + systemVisuals: Map; + systemSummaryVisuals: Map; + planetVisuals: any[]; + orbitLines: THREE.Object3D[]; + spatialNodeVisuals: Map; + bubbleVisuals: Map; + nodeVisuals: Map; + stationVisuals: Map; + claimVisuals: Map; + constructionSiteVisuals: Map; + shipVisuals: Map; + registerPresentation: (detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void; +} + +export class ViewerSceneDataController { + constructor(private readonly context: ViewerSceneDataContext) {} + + rebuildSystems(systems: SystemSnapshot[]) { + rebuildSystemScene(this.createSceneSyncContext(), systems); + } + + syncSpatialNodes(nodes: SpatialNodeSnapshot[]) { + syncSpatialNodeScene(this.createSceneSyncContext(), nodes); + } + + syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) { + syncBubbleScene(this.createSceneSyncContext(), bubbles); + } + + syncNodes(nodes: ResourceNodeSnapshot[]) { + syncNodeScene(this.createSceneSyncContext(), nodes); + } + + syncStations(stations: StationSnapshot[]) { + syncStationScene(this.createSceneSyncContext(), stations); + } + + syncClaims(claims: ClaimSnapshot[]) { + syncClaimScene(this.createSceneSyncContext(), claims); + } + + syncConstructionSites(sites: ConstructionSiteSnapshot[]) { + syncConstructionSiteScene(this.createSceneSyncContext(), sites); + } + + syncShips(ships: ShipSnapshot[], tickIntervalMs: number) { + syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs); + } + + applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) { + applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes); + } + + applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) { + applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles); + } + + applyNodeDeltas(nodes: ResourceNodeDelta[]) { + applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes); + } + + applyStationDeltas(stations: StationDelta[]) { + applyStationDeltaUpdates(this.createSceneSyncContext(), stations); + } + + applyClaimDeltas(claims: ClaimDelta[]) { + applyClaimDeltaUpdates(this.createSceneSyncContext(), claims); + } + + applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) { + applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites); + } + + applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) { + applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs); + } + + createWorldPresentationContext(overrides: { + world: any; + activeSystemId?: string; + orbitYaw: number; + camera: THREE.PerspectiveCamera; + systemFocusLocal: THREE.Vector3; + toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; + updateSystemDetailVisibility: () => void; + setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; + }) { + return { + world: overrides.world, + worldTimeSyncMs: this.context.getWorldTimeSyncMs(), + worldSeed: this.context.getWorldSeed(), + activeSystemId: overrides.activeSystemId, + orbitYaw: overrides.orbitYaw, + camera: overrides.camera, + systemFocusLocal: overrides.systemFocusLocal, + shipVisuals: this.context.shipVisuals, + nodeVisuals: this.context.nodeVisuals, + spatialNodeVisuals: this.context.spatialNodeVisuals, + bubbleVisuals: this.context.bubbleVisuals, + stationVisuals: this.context.stationVisuals, + claimVisuals: this.context.claimVisuals, + constructionSiteVisuals: this.context.constructionSiteVisuals, + systemVisuals: this.context.systemVisuals, + systemSummaryVisuals: this.context.systemSummaryVisuals, + toDisplayLocalPosition: overrides.toDisplayLocalPosition, + updateSystemDetailVisibility: overrides.updateSystemDetailVisibility, + setShellReticleOpacity: overrides.setShellReticleOpacity, + }; + } + + private createSceneSyncContext() { + return { + documentRef: this.context.documentRef, + worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(), + worldSeed: this.context.getWorldSeed(), + worldTimeSyncMs: this.context.getWorldTimeSyncMs(), + systemGroup: this.context.systemGroup, + spatialNodeGroup: this.context.spatialNodeGroup, + bubbleGroup: this.context.bubbleGroup, + nodeGroup: this.context.nodeGroup, + stationGroup: this.context.stationGroup, + claimGroup: this.context.claimGroup, + constructionSiteGroup: this.context.constructionSiteGroup, + shipGroup: this.context.shipGroup, + selectableTargets: this.context.selectableTargets, + presentationEntries: this.context.presentationEntries, + systemVisuals: this.context.systemVisuals, + systemSummaryVisuals: this.context.systemSummaryVisuals, + planetVisuals: this.context.planetVisuals, + orbitLines: this.context.orbitLines, + spatialNodeVisuals: this.context.spatialNodeVisuals, + bubbleVisuals: this.context.bubbleVisuals, + nodeVisuals: this.context.nodeVisuals, + stationVisuals: this.context.stationVisuals, + claimVisuals: this.context.claimVisuals, + constructionSiteVisuals: this.context.constructionSiteVisuals, + shipVisuals: this.context.shipVisuals, + registerPresentation: this.context.registerPresentation, + shipSize, + shipLength, + shipPresentationColor, + spatialNodeColor, + createCirclePoints, + resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble), + resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId), + resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition), + deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor), + deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor), + setBubbleVisualState, + }; + } +} diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts new file mode 100644 index 0000000..9a10f25 --- /dev/null +++ b/apps/viewer/src/viewerSceneFactory.ts @@ -0,0 +1,420 @@ +import * as THREE from "three"; +import { + MOON_RENDER_SCALE, + PLANET_RENDER_SCALE, + STAR_RENDER_SCALE, +} from "./viewerConstants"; +import type { + ClaimSnapshot, + ConstructionSiteSnapshot, + LocalBubbleSnapshot, + PlanetSnapshot, + ResourceNodeSnapshot, + ShipSnapshot, + SpatialNodeSnapshot, + StationSnapshot, + SystemSnapshot, +} from "./contracts"; +import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes"; +import { + celestialRenderRadius, + computeMoonOrbitRadius, + computeMoonRenderRadius, + computePlanetLocalPosition, + starHaloOpacity, + toThreeVector, +} from "./viewerMath"; + +export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh { + const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas"; + const mesh = new THREE.Mesh( + isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0), + new THREE.MeshStandardMaterial({ + color: isGas ? 0x7fd6ff : 0xd2b07a, + flatShading: !isGas, + transparent: isGas, + opacity: isGas ? 0.68 : 1, + emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05), + }), + ); + mesh.position.copy(toThreeVector(node.localPosition)); + mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + return mesh; +} + +export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh { + const color = spatialNodeColor(node.kind); + return new THREE.Mesh( + new THREE.OctahedronGeometry(10, 0), + new THREE.MeshStandardMaterial({ + color, + emissive: new THREE.Color(color).multiplyScalar(0.16), + roughness: 0.35, + metalness: 0.45, + }), + ); +} + +export function createBubbleRing( + bubble: LocalBubbleSnapshot, + localPosition: THREE.Vector3, + createCirclePoints: (radius: number, segments: number) => THREE.Vector3[], +): THREE.LineLoop { + const ring = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)), + new THREE.LineBasicMaterial({ + color: 0x6ed6ff, + transparent: true, + opacity: 0.32, + }), + ); + ring.position.copy(localPosition); + return ring; +} + +export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh { + 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, + }), + ); +} + +export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh { + 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, + }), + ); +} + +export function createStarCluster(system: SystemSnapshot): THREE.Group { + const root = new THREE.Group(); + const renderedStarSize = 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)]; + + for (const [index, offset] of offsets.entries()) { + const sizeScale = index === 0 ? 1 : 0.72; + const star = new THREE.Mesh( + new THREE.SphereGeometry(renderedStarSize * sizeScale, 24, 24), + new THREE.MeshBasicMaterial({ color: system.starColor }), + ); + const halo = new THREE.Mesh( + new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20), + new THREE.MeshBasicMaterial({ + color: system.starColor, + transparent: true, + opacity: starHaloOpacity(system.starKind), + side: THREE.BackSide, + }), + ); + star.position.copy(offset); + halo.position.copy(offset); + root.add(star, halo); + } + + return root; +} + +export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop { + const points = Array.from({ length: 120 }, (_, index) => { + const phaseDegrees = (index / 120) * 360; + return computePlanetLocalPosition(planet, 0, phaseDegrees); + }); + + return new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }), + ); +} + +export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh { + const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); + const ring = new THREE.Mesh( + new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48), + new THREE.MeshBasicMaterial({ + color: 0xdac89a, + transparent: true, + opacity: 0.42, + side: THREE.DoubleSide, + }), + ); + ring.rotation.x = Math.PI / 2; + ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25); + return ring; +} + +export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] { + const moonCount = Math.min(planet.moonCount, 12); + const moons: MoonVisual[] = []; + + for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) { + const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed); + const orbit = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints( + Array.from({ length: 48 }, (_, index) => { + const angle = (index / 48) * Math.PI * 2; + return new THREE.Vector3( + Math.cos(angle) * orbitRadius, + 0, + Math.sin(angle) * orbitRadius, + ); + }), + ), + new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }), + ); + orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35); + + const moonSize = computeMoonRenderRadius(planet, moonIndex, seed); + const mesh = new THREE.Mesh( + new THREE.SphereGeometry(moonSize, 12, 12), + new THREE.MeshStandardMaterial({ + color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55), + roughness: 0.96, + metalness: 0.02, + }), + ); + + moons.push({ mesh, orbit }); + } + + return moons; +} + +export function createStationMesh(station: StationSnapshot): THREE.Mesh { + const mesh = new THREE.Mesh( + new THREE.CylinderGeometry(24, 24, 18, 10), + new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), + ); + mesh.rotation.x = Math.PI / 2; + mesh.position.copy(toThreeVector(station.localPosition)); + return mesh; +} + +export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh { + const geometry = new THREE.ConeGeometry(size, length, 7); + geometry.rotateX(Math.PI / 2); + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + color, + emissive: new THREE.Color(color).multiplyScalar(0.18), + }), + ); + mesh.position.copy(toThreeVector(ship.localPosition)); + return mesh; +} + +export function createBackdropStars(): THREE.Points { + const starCount = 1800; + const radius = 36000; + const positions = new Float32Array(starCount * 3); + const colors = new Float32Array(starCount * 3); + const color = new THREE.Color(); + + for (let index = 0; index < starCount; index += 1) { + const direction = new THREE.Vector3( + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2), + ).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1)); + positions[index * 3] = direction.x; + positions[index * 3 + 1] = direction.y; + positions[index * 3 + 2] = direction.z; + + const tint = THREE.MathUtils.randFloat(0, 1); + color.setRGB( + THREE.MathUtils.lerp(0.68, 1, tint), + THREE.MathUtils.lerp(0.76, 0.94, tint), + THREE.MathUtils.lerp(0.9, 1, tint), + ); + if (Math.random() < 0.08) { + color.lerp(new THREE.Color(0xffd6a0), 0.45); + } + colors[index * 3] = color.r; + colors[index * 3 + 1] = color.g; + colors[index * 3 + 2] = color.b; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + + return new THREE.Points( + geometry, + new THREE.PointsMaterial({ + size: 2.2, + sizeAttenuation: false, + vertexColors: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + ); +} + +export function createNebulaTexture(documentRef: Document): THREE.CanvasTexture { + const canvas = documentRef.createElement("canvas"); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create nebula texture"); + } + + const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118); + gradient.addColorStop(0, "rgba(255,255,255,0.95)"); + gradient.addColorStop(0.2, "rgba(255,255,255,0.48)"); + gradient.addColorStop(0.55, "rgba(140,180,255,0.14)"); + gradient.addColorStop(1, "rgba(0,0,0,0)"); + context.fillStyle = gradient; + context.fillRect(0, 0, 256, 256); + + for (let index = 0; index < 10; index += 1) { + const x = THREE.MathUtils.randFloat(30, 226); + const y = THREE.MathUtils.randFloat(30, 226); + const radius = THREE.MathUtils.randFloat(24, 72); + const puff = context.createRadialGradient(x, y, 0, x, y, radius); + puff.addColorStop(0, "rgba(255,255,255,0.16)"); + puff.addColorStop(0.45, "rgba(255,255,255,0.08)"); + puff.addColorStop(1, "rgba(0,0,0,0)"); + context.fillStyle = puff; + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + } + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; +} + +export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] { + const directions = [ + new THREE.Vector3(0.74, 0.34, -0.58), + new THREE.Vector3(-0.62, 0.18, -0.77), + new THREE.Vector3(0.22, -0.44, -0.87), + new THREE.Vector3(-0.38, 0.56, 0.73), + ]; + + return directions.map((direction, index) => { + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0.14, + depthWrite: false, + color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff", + blending: THREE.AdditiveBlending, + })); + sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600)); + const scale = 15000 + index * 2400; + sprite.scale.set(scale, scale * 0.62, 1); + return sprite; + }); +} + +export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite { + const canvas = documentRef.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create tactical icon"); + } + + context.clearRect(0, 0, 64, 64); + context.strokeStyle = color; + context.lineWidth = 5; + context.beginPath(); + context.arc(32, 32, 18, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(32, 8); + context.lineTo(32, 56); + context.moveTo(8, 32); + context.lineTo(56, 32); + context.stroke(); + + const texture = new THREE.CanvasTexture(canvas); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + color: "#ffffff", + })); + sprite.scale.setScalar(size); + sprite.visible = false; + return sprite; +} + +export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual { + const canvas = documentRef.createElement("canvas"); + canvas.width = 512; + canvas.height = 160; + const texture = new THREE.CanvasTexture(canvas); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + })); + sprite.scale.set(520, 160, 1); + sprite.visible = false; + return { sprite, texture, anchor }; +} + +export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite { + const canvas = documentRef.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create shell reticle"); + } + + context.clearRect(0, 0, 128, 128); + context.strokeStyle = color; + context.lineWidth = 6; + context.globalAlpha = 0.58; + context.beginPath(); + context.arc(64, 64, 48, 0.12 * Math.PI, 0.34 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 0.62 * Math.PI, 0.84 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 1.12 * Math.PI, 1.34 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 1.62 * Math.PI, 1.84 * Math.PI); + context.stroke(); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + color, + opacity: 1, + blending: THREE.AdditiveBlending, + fog: false, + }); + const sprite = new THREE.Sprite(material); + sprite.scale.setScalar(size); + sprite.visible = false; + sprite.renderOrder = 1000; + return sprite; +} diff --git a/apps/viewer/src/viewerSceneSync.ts b/apps/viewer/src/viewerSceneSync.ts new file mode 100644 index 0000000..2aa98b9 --- /dev/null +++ b/apps/viewer/src/viewerSceneSync.ts @@ -0,0 +1,508 @@ +import * as THREE from "three"; +import { + PLANET_RENDER_SCALE, + STAR_RENDER_SCALE, +} from "./viewerConstants"; +import type { + BubbleVisual, + ClaimVisual, + ConstructionSiteVisual, + NodeVisual, + PlanetVisual, + PresentationEntry, + Selectable, + ShipVisual, + SpatialNodeVisual, + StructureVisual, + SystemSummaryVisual, + SystemVisual, +} from "./viewerTypes"; +import type { + ClaimDelta, + ClaimSnapshot, + ConstructionSiteDelta, + ConstructionSiteSnapshot, + LocalBubbleDelta, + LocalBubbleSnapshot, + ResourceNodeDelta, + ResourceNodeSnapshot, + ShipDelta, + ShipSnapshot, + SpatialNodeDelta, + SpatialNodeSnapshot, + StationDelta, + StationSnapshot, + SystemSnapshot, +} from "./contracts"; +import { + celestialRenderRadius, + computePlanetLocalPosition, + toThreeVector, +} from "./viewerMath"; +import { + createBubbleRing, + createClaimMesh, + createConstructionSiteMesh, + createMoonVisuals, + createNodeMesh, + createPlanetOrbit, + createPlanetRing, + createShellReticle, + createShipMesh, + createSpatialNodeMesh, + createStarCluster, + createStationMesh, + createSystemSummaryVisual, + createTacticalIcon, +} from "./viewerSceneFactory"; + +interface SceneSyncContext { + documentRef: Document; + worldGeneratedAtUtc?: string; + worldSeed: number; + worldTimeSyncMs: number; + systemGroup: THREE.Group; + spatialNodeGroup: THREE.Group; + bubbleGroup: THREE.Group; + nodeGroup: THREE.Group; + stationGroup: THREE.Group; + claimGroup: THREE.Group; + constructionSiteGroup: THREE.Group; + shipGroup: THREE.Group; + selectableTargets: Map; + presentationEntries: PresentationEntry[]; + systemVisuals: Map; + systemSummaryVisuals: Map; + planetVisuals: PlanetVisual[]; + orbitLines: THREE.Object3D[]; + spatialNodeVisuals: Map; + bubbleVisuals: Map; + nodeVisuals: Map; + stationVisuals: Map; + claimVisuals: Map; + constructionSiteVisuals: Map; + shipVisuals: Map; + registerPresentation: ( + detail: THREE.Object3D, + icon: THREE.Sprite, + hideDetailInUniverse: boolean, + hideIconInUniverse?: boolean, + systemId?: string, + ) => void; + shipSize: (ship: ShipSnapshot) => number; + shipLength: (ship: ShipSnapshot) => number; + shipPresentationColor: (ship: ShipSnapshot) => string; + spatialNodeColor: (kind: string) => string; + createCirclePoints: (radius: number, segments: number) => THREE.Vector3[]; + resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3; + resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; + resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"]; + deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => { + radius: number; + phase: number; + inclination: number; + }; + deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: StructureVisual["anchor"]) => { + radius: number; + phase: number; + inclination: number; + }; + setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void; +} + +export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) { + const worldTimeSeconds = context.worldGeneratedAtUtc + ? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97) + : 0; + + context.systemGroup.clear(); + context.selectableTargets.clear(); + context.presentationEntries.length = 0; + context.planetVisuals.length = 0; + context.orbitLines.length = 0; + context.systemVisuals.clear(); + context.systemSummaryVisuals.clear(); + + for (const system of systems) { + const root = new THREE.Group(); + root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z); + const detailGroup = new THREE.Group(); + const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); + + const starCluster = createStarCluster(system); + const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96); + const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400); + const summaryVisual = createSystemSummaryVisual( + context.documentRef, + new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z), + ); + summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0); + root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); + context.registerPresentation(starCluster, systemIcon, true); + context.systemVisuals.set(system.id, { + root, + starCluster, + icon: systemIcon, + shellReticle, + shellReticleBaseScale: 400, + detailGroup, + summary: summaryVisual, + galaxyPosition: toThreeVector(system.galaxyPosition), + }); + context.systemSummaryVisuals.set(system.id, summaryVisual); + starCluster.traverse((child) => { + if (child instanceof THREE.Mesh) { + context.selectableTargets.set(child, { kind: "system", id: system.id }); + } + }); + context.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); + context.selectableTargets.set(shellReticle, { kind: "system", id: system.id }); + + for (const [planetIndex, planet] of system.planets.entries()) { + const orbit = createPlanetOrbit(planet); + const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); + const planetMesh = new THREE.Mesh( + new THREE.SphereGeometry(renderedPlanetRadius, 18, 18), + new THREE.MeshStandardMaterial({ + color: planet.color, + roughness: 0.92, + metalness: 0.08, + emissive: new THREE.Color(planet.color).multiplyScalar(0.04), + }), + ); + planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds)); + const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2)); + planetIcon.position.copy(planetMesh.position); + const ring = planet.hasRing ? createPlanetRing(planet) : undefined; + if (ring) { + ring.position.copy(planetMesh.position); + } + const moons = createMoonVisuals(planet, context.worldSeed); + detailGroup.add(orbit, planetMesh, planetIcon); + if (ring) { + detailGroup.add(ring); + } + for (const moon of moons) { + moon.orbit.position.copy(planetMesh.position); + moon.mesh.position.copy(planetMesh.position); + detailGroup.add(moon.orbit, moon.mesh); + context.orbitLines.push(moon.orbit); + context.registerPresentation(moon.mesh, planetIcon, true, true, system.id); + } + context.orbitLines.push(orbit); + context.registerPresentation(planetMesh, planetIcon, true, true, system.id); + if (ring) { + context.registerPresentation(ring, planetIcon, true, true, system.id); + } + context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); + context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex }); + context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); + } + + context.systemGroup.add(root); + } +} + +export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSnapshot[]) { + context.spatialNodeGroup.clear(); + context.spatialNodeVisuals.clear(); + + for (const node of nodes) { + const mesh = createSpatialNodeMesh(node, context.spatialNodeColor); + const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18); + const localPosition = toThreeVector(node.localPosition); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + context.spatialNodeVisuals.set(node.id, { + id: node.id, + systemId: node.systemId, + mesh, + icon, + kind: node.kind, + localPosition, + }); + context.spatialNodeGroup.add(mesh, icon); + context.registerPresentation(mesh, icon, true, true, node.systemId); + context.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id }); + context.selectableTargets.set(icon, { kind: "spatial-node", id: node.id }); + } +} + +export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubbleSnapshot[]) { + context.bubbleGroup.clear(); + context.bubbleVisuals.clear(); + + for (const bubble of bubbles) { + const localPosition = context.resolveBubblePosition(bubble); + const mesh = createBubbleRing(bubble, localPosition, context.createCirclePoints); + const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius }; + context.setBubbleVisualState(visual, bubble); + context.bubbleVisuals.set(bubble.id, visual); + context.bubbleGroup.add(mesh); + context.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id }); + } +} + +export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[]) { + context.nodeGroup.clear(); + context.nodeVisuals.clear(); + + for (const node of nodes) { + const mesh = createNodeMesh(node); + const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); + icon.position.copy(mesh.position); + const localPosition = toThreeVector(node.localPosition); + const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition); + const orbital = context.deriveNodeOrbital(node, anchor); + context.nodeVisuals.set(node.id, { + systemId: node.systemId, + mesh, + icon, + sourceKind: node.sourceKind, + anchor, + localPosition, + orbitRadius: orbital.radius, + orbitPhase: orbital.phase, + orbitInclination: orbital.inclination, + }); + context.nodeGroup.add(mesh, icon); + context.registerPresentation(mesh, icon, true, true, node.systemId); + context.selectableTargets.set(mesh, { kind: "node", id: node.id }); + context.selectableTargets.set(icon, { kind: "node", id: node.id }); + } +} + +export function syncStations(context: SceneSyncContext, stations: StationSnapshot[]) { + context.stationGroup.clear(); + context.stationVisuals.clear(); + + for (const station of stations) { + const mesh = createStationMesh(station); + const icon = createTacticalIcon(context.documentRef, station.color, 26); + icon.position.copy(mesh.position); + const localPosition = toThreeVector(station.localPosition); + const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition); + const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); + context.stationVisuals.set(station.id, { + id: station.id, + systemId: station.systemId, + mesh, + icon, + anchor, + orbitRadius: orbital.radius, + orbitPhase: orbital.phase, + orbitInclination: orbital.inclination, + localPosition, + }); + context.stationGroup.add(mesh, icon); + context.registerPresentation(mesh, icon, true, true, station.systemId); + context.selectableTargets.set(mesh, { kind: "station", id: station.id }); + context.selectableTargets.set(icon, { kind: "station", id: station.id }); + } +} + +export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) { + context.claimGroup.clear(); + context.claimVisuals.clear(); + + for (const claim of claims) { + const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId); + const mesh = createClaimMesh(claim); + const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + context.claimVisuals.set(claim.id, { + id: claim.id, + nodeId: claim.nodeId, + systemId: claim.systemId, + mesh, + icon, + localPosition, + }); + context.claimGroup.add(mesh, icon); + context.registerPresentation(mesh, icon, true, true, claim.systemId); + context.selectableTargets.set(mesh, { kind: "claim", id: claim.id }); + context.selectableTargets.set(icon, { kind: "claim", id: claim.id }); + } +} + +export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[]) { + context.constructionSiteGroup.clear(); + context.constructionSiteVisuals.clear(); + + for (const site of sites) { + const localPosition = context.resolvePointPosition(site.systemId, site.nodeId); + const mesh = createConstructionSiteMesh(site); + const icon = createTacticalIcon(context.documentRef, "#9df29c", 18); + mesh.position.copy(localPosition); + icon.position.copy(localPosition); + context.constructionSiteVisuals.set(site.id, { + id: site.id, + nodeId: site.nodeId, + systemId: site.systemId, + mesh, + icon, + localPosition, + }); + context.constructionSiteGroup.add(mesh, icon); + context.registerPresentation(mesh, icon, true, true, site.systemId); + context.selectableTargets.set(mesh, { kind: "construction-site", id: site.id }); + context.selectableTargets.set(icon, { kind: "construction-site", id: site.id }); + } +} + +export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number) { + context.shipGroup.clear(); + context.shipVisuals.clear(); + + for (const ship of ships) { + const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship)); + const shipColor = context.shipPresentationColor(ship); + const icon = createTacticalIcon(context.documentRef, shipColor, 18); + const position = toThreeVector(ship.localPosition); + icon.position.copy(position); + icon.material.color.set(shipColor); + context.shipGroup.add(mesh, icon); + context.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); + context.selectableTargets.set(icon, { kind: "ship", id: ship.id }); + context.registerPresentation(mesh, icon, true, true, ship.systemId); + context.shipVisuals.set(ship.id, { + systemId: ship.systemId, + mesh, + icon, + startPosition: position.clone(), + authoritativePosition: position.clone(), + targetPosition: toThreeVector(ship.targetLocalPosition), + velocity: toThreeVector(ship.localVelocity), + receivedAtMs: performance.now(), + blendDurationMs: Math.max(tickIntervalMs, 80), + }); + } +} + +export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) { + for (const node of nodes) { + const visual = context.spatialNodeVisuals.get(node.id); + if (!visual) { + continue; + } + + visual.systemId = node.systemId; + visual.kind = node.kind; + visual.localPosition.copy(toThreeVector(node.localPosition)); + visual.mesh.position.copy(visual.localPosition); + visual.icon.position.copy(visual.localPosition); + (visual.mesh.material as THREE.MeshStandardMaterial).color.set(context.spatialNodeColor(node.kind)); + } +} + +export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: LocalBubbleDelta[]) { + for (const bubble of bubbles) { + const visual = context.bubbleVisuals.get(bubble.id); + if (!visual) { + continue; + } + + visual.systemId = bubble.systemId; + visual.radius = bubble.radius; + visual.localPosition.copy(context.resolveBubblePosition(bubble)); + visual.mesh.position.copy(visual.localPosition); + visual.mesh.scale.setScalar(Math.max(bubble.radius, 60)); + context.setBubbleVisualState(visual, bubble); + } +} + +export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[]) { + for (const node of nodes) { + const visual = context.nodeVisuals.get(node.id); + if (!visual) { + continue; + } + + visual.systemId = node.systemId; + visual.sourceKind = node.sourceKind; + visual.localPosition.copy(toThreeVector(node.localPosition)); + visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition); + const orbital = context.deriveNodeOrbital(node, visual.anchor); + visual.orbitRadius = orbital.radius; + visual.orbitPhase = orbital.phase; + visual.orbitInclination = orbital.inclination; + visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + } +} + +export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[]) { + for (const station of stations) { + const visual = context.stationVisuals.get(station.id); + if (!visual) { + continue; + } + + visual.systemId = station.systemId; + visual.localPosition.copy(toThreeVector(station.localPosition)); + visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition); + const orbital = context.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor); + visual.orbitRadius = orbital.radius; + visual.orbitPhase = orbital.phase; + visual.orbitInclination = orbital.inclination; + const material = visual.mesh.material as THREE.MeshStandardMaterial; + material.color.set(station.color); + material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); + } +} + +export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]) { + for (const claim of claims) { + const visual = context.claimVisuals.get(claim.id); + if (!visual) { + continue; + } + + visual.systemId = claim.systemId; + visual.localPosition.copy(context.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"); + } +} + +export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[]) { + for (const site of sites) { + const visual = context.constructionSiteVisuals.get(site.id); + if (!visual) { + continue; + } + + visual.systemId = site.systemId; + visual.localPosition.copy(context.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); + } +} + +export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number) { + for (const ship of ships) { + const visual = context.shipVisuals.get(ship.id); + if (!visual) { + continue; + } + + visual.systemId = ship.systemId; + visual.startPosition.copy(visual.authoritativePosition); + visual.authoritativePosition.copy(toThreeVector(ship.localPosition)); + visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition)); + visual.velocity.copy(toThreeVector(ship.localVelocity)); + visual.receivedAtMs = performance.now(); + visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); + const shipColor = context.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); + } +} diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts new file mode 100644 index 0000000..bfd536a --- /dev/null +++ b/apps/viewer/src/viewerSelection.ts @@ -0,0 +1,216 @@ +import type { SystemSnapshot } from "./contracts"; +import type { + CameraMode, + OrbitalAnchor, + Selectable, + SelectionGroup, + WorldState, +} from "./viewerTypes"; + +export function describeSelectable(world: WorldState | undefined, item: Selectable): string { + if (!world) { + return item.kind; + } + if (item.kind === "ship") { + return world.ships.get(item.id)?.label ?? item.id; + } + if (item.kind === "station") { + return world.stations.get(item.id)?.label ?? item.id; + } + if (item.kind === "node") { + return item.id; + } + if (item.kind === "spatial-node") { + return `${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 world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`; + } + return world.systems.get(item.id)?.label ?? item.id; +} + +export function getSelectionGroup(item: Selectable): SelectionGroup { + if (item.kind === "ship") { + return "ships"; + } + 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"; +} + +export function resolveSelectableSystemId(world: WorldState | undefined, selection: Selectable): string | undefined { + if (!world) { + return undefined; + } + + if (selection.kind === "ship") { + return world.ships.get(selection.id)?.systemId; + } + if (selection.kind === "station") { + return world.stations.get(selection.id)?.systemId; + } + if (selection.kind === "node") { + return world.nodes.get(selection.id)?.systemId; + } + if (selection.kind === "spatial-node") { + return world.spatialNodes.get(selection.id)?.systemId; + } + if (selection.kind === "bubble") { + return world.localBubbles.get(selection.id)?.systemId; + } + if (selection.kind === "claim") { + return world.claims.get(selection.id)?.systemId; + } + if (selection.kind === "construction-site") { + return world.constructionSites.get(selection.id)?.systemId; + } + if (selection.kind === "planet") { + return selection.systemId; + } + return selection.id; +} + +export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined { + if (!world || selectedItems.length !== 1) { + return undefined; + } + + const selected = selectedItems[0]; + if (selected.kind === "bubble") { + return selected.id; + } + if (selected.kind === "ship") { + return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined; + } + if (selected.kind === "station") { + return world.stations.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "spatial-node") { + return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "claim") { + return world.claims.get(selected.id)?.bubbleId ?? undefined; + } + if (selected.kind === "construction-site") { + return world.constructionSites.get(selected.id)?.bubbleId ?? undefined; + } + return undefined; +} + +export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string { + if (!world || !systemId) { + return "unknown"; + } + + const system = world.systems.get(systemId); + if (!system) { + return systemId; + } + + if (!anchor || anchor.kind === "star") { + return `${system.label} star`; + } + + const planet = system.planets[anchor.planetIndex]; + if (!planet) { + return `${system.label} star`; + } + + if (anchor.kind === "planet") { + return planet.label; + } + + return `${planet.label} moon ${anchor.moonIndex + 1}`; +} + +export function renderSystemDetails( + world: WorldState | undefined, + system: SystemSnapshot, + activeContext: boolean, + cameraMode: CameraMode, + cameraTargetShipId?: string, +): string { + if (!world) { + return ""; + } + + 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 world.ships.values()) { + if (ship.systemId === system.id) { + shipCount += 1; + } + } + for (const station of world.stations.values()) { + if (station.systemId === system.id) { + stationCount += 1; + } + } + for (const node of world.nodes.values()) { + if (node.systemId === system.id) { + nodeCount += 1; + } + } + for (const node of world.spatialNodes.values()) { + if (node.systemId === system.id) { + spatialNodeCount += 1; + } + } + for (const bubble of world.localBubbles.values()) { + if (bubble.systemId === system.id) { + bubbleCount += 1; + } + } + for (const claim of world.claims.values()) { + if (claim.systemId === system.id) { + claimCount += 1; + } + } + for (const site of world.constructionSites.values()) { + if (site.systemId === system.id) { + constructionCount += 1; + } + } + for (const planet of system.planets) { + moonCount += planet.moonCount; + } + + const followText = activeContext && cameraMode === "follow" && cameraTargetShipId + ? `

Camera locked to ${world.ships.get(cameraTargetShipId)?.label ?? cameraTargetShipId}

` + : ""; + + 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}

+

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} + `; +} diff --git a/apps/viewer/src/viewerState.ts b/apps/viewer/src/viewerState.ts new file mode 100644 index 0000000..abcde0e --- /dev/null +++ b/apps/viewer/src/viewerState.ts @@ -0,0 +1,119 @@ +import type { + FactionSnapshot, + WorldDelta, + WorldSnapshot, +} from "./contracts"; +import type { + NetworkStats, + PerformanceStats, + WorldState, +} from "./viewerTypes"; + +export function createInitialNetworkStats(): NetworkStats { + return { + snapshotBytes: 0, + deltasReceived: 0, + deltaBytes: 0, + lastDeltaBytes: 0, + lastEntityChanges: 0, + eventsReceived: 0, + streamConnected: false, + throughputSamples: [], + }; +} + +export function createInitialPerformanceStats(): PerformanceStats { + return { + frameSamples: [], + lastFrameMs: 0, + lastPanelUpdateAtMs: 0, + }; +} + +export function createWorldState(snapshot: WorldSnapshot): WorldState { + return { + label: snapshot.label, + seed: snapshot.seed, + sequence: snapshot.sequence, + 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: [], + }; +} + +export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean { + world.sequence = delta.sequence; + world.tickIntervalMs = delta.tickIntervalMs; + world.generatedAtUtc = delta.generatedAtUtc; + world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18); + + for (const node of delta.spatialNodes) { + world.spatialNodes.set(node.id, node); + } + for (const bubble of delta.localBubbles) { + world.localBubbles.set(bubble.id, bubble); + } + for (const node of delta.nodes) { + world.nodes.set(node.id, node); + } + for (const station of delta.stations) { + world.stations.set(station.id, station); + } + for (const claim of delta.claims) { + world.claims.set(claim.id, claim); + } + for (const site of delta.constructionSites) { + world.constructionSites.set(site.id, site); + } + for (const order of delta.marketOrders) { + world.marketOrders.set(order.id, order); + } + for (const policy of delta.policies) { + world.policies.set(policy.id, policy); + } + for (const ship of delta.ships) { + world.ships.set(ship.id, ship); + } + for (const faction of delta.factions) { + world.factions.set(faction.id, faction); + } + + return delta.factions.length > 0; +} + +export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta, rawBytes: number): void { + 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; + networkStats.deltasReceived += 1; + networkStats.deltaBytes += rawBytes; + networkStats.lastDeltaBytes = rawBytes; + networkStats.lastEntityChanges = changedEntities; + networkStats.eventsReceived += delta.events.length; + networkStats.lastDeltaAtMs = performance.now(); + networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes }); + const cutoff = performance.now() - 4000; + networkStats.throughputSamples = networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff); +} + +export function cloneFactions(world: WorldState): FactionSnapshot[] { + return [...world.factions.values()]; +} diff --git a/apps/viewer/src/viewerTelemetry.ts b/apps/viewer/src/viewerTelemetry.ts new file mode 100644 index 0000000..9df95ca --- /dev/null +++ b/apps/viewer/src/viewerTelemetry.ts @@ -0,0 +1,91 @@ +import * as THREE from "three"; +import { formatBytes } from "./viewerMath"; +import type { + NetworkStats, + PerformanceStats, +} from "./viewerTypes"; + +export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) { + const now = performance.now(); + const uptimeSeconds = networkStats.streamOpenedAtMs + ? (now - networkStats.streamOpenedAtMs) / 1000 + : 0; + const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0); + const recentWindowSeconds = networkStats.throughputSamples.length > 1 + ? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1) + : 1; + const kbPerSecond = recentBytes / 1024 / recentWindowSeconds; + const averageDeltaBytes = networkStats.deltasReceived > 0 + ? networkStats.deltaBytes / networkStats.deltasReceived + : 0; + const secondsSinceLastDelta = networkStats.lastDeltaAtMs + ? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1) + : "n/a"; + + networkPanelEl.textContent = [ + `snapshot: ${formatBytes(networkStats.snapshotBytes)}`, + `stream: ${networkStats.streamConnected ? "live" : "offline"}`, + `deltas: ${networkStats.deltasReceived}`, + `events: ${networkStats.eventsReceived}`, + `avg delta: ${formatBytes(averageDeltaBytes)}`, + `last delta: ${formatBytes(networkStats.lastDeltaBytes)}`, + `recent rate: ${kbPerSecond.toFixed(1)} KB/s`, + `changed: ${networkStats.lastEntityChanges}`, + `uptime: ${uptimeSeconds.toFixed(1)}s`, + `last packet: ${secondsSinceLastDelta}s`, + ].join("\n"); +} + +export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) { + const now = performance.now(); + performanceStats.lastFrameMs = frameMs; + performanceStats.frameSamples.push({ atMs: now, frameMs }); + const cutoff = now - 4000; + performanceStats.frameSamples = performanceStats.frameSamples.filter((sample) => sample.atMs >= cutoff); +} + +export function updatePerformancePanel( + performancePanelEl: HTMLDivElement, + performanceStats: PerformanceStats, + renderer: THREE.WebGLRenderer, +) { + const now = performance.now(); + if ( + performanceStats.lastPanelUpdateAtMs > 0 && + now - performanceStats.lastPanelUpdateAtMs < 250 + ) { + return; + } + + const samples = performanceStats.frameSamples; + const elapsedWindowSeconds = samples.length > 1 + ? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25) + : 1; + const averageFrameMs = samples.length > 0 + ? samples.reduce((sum, sample) => sum + sample.frameMs, 0) / samples.length + : 0; + const worstFrameMs = samples.length > 0 + ? samples.reduce((max, sample) => Math.max(max, sample.frameMs), 0) + : 0; + const fps = samples.length > 1 + ? (samples.length - 1) / elapsedWindowSeconds + : 0; + const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0; + const renderInfo = renderer.info; + + performancePanelEl.textContent = [ + `fps: ${fps.toFixed(1)}`, + `frame avg: ${averageFrameMs.toFixed(2)} ms`, + `frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`, + `frame worst: ${worstFrameMs.toFixed(2)} ms`, + `recent low: ${recentLowFps.toFixed(1)}`, + `draw calls: ${renderInfo.render.calls}`, + `triangles: ${renderInfo.render.triangles}`, + `points: ${renderInfo.render.points}`, + `lines: ${renderInfo.render.lines}`, + `geometries: ${renderInfo.memory.geometries}`, + `textures: ${renderInfo.memory.textures}`, + `pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`, + ].join("\n"); + performanceStats.lastPanelUpdateAtMs = now; +} diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts new file mode 100644 index 0000000..2aa4911 --- /dev/null +++ b/apps/viewer/src/viewerTypes.ts @@ -0,0 +1,207 @@ +import * as THREE from "three"; +import type { + ClaimSnapshot, + ConstructionSiteSnapshot, + FactionSnapshot, + LocalBubbleSnapshot, + MarketOrderSnapshot, + PlanetSnapshot, + PolicySetSnapshot, + ResourceNodeSnapshot, + ShipSnapshot, + SimulationEventRecord, + SpatialNodeSnapshot, + StationSnapshot, + SystemSnapshot, +} from "./contracts"; + +export type ZoomLevel = "local" | "system" | "universe"; +export type SelectionGroup = "ships" | "structures" | "celestials"; +export type DragMode = "orbit" | "marquee"; +export type CameraMode = "tactical" | "follow"; + +export 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 }; + +export interface ShipVisual { + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + startPosition: THREE.Vector3; + authoritativePosition: THREE.Vector3; + targetPosition: THREE.Vector3; + velocity: THREE.Vector3; + receivedAtMs: number; + blendDurationMs: number; +} + +export interface PlanetVisual { + systemId: string; + planet: PlanetSnapshot; + orbit: THREE.LineLoop; + mesh: THREE.Mesh; + icon: THREE.Sprite; + ring?: THREE.Mesh; + moons: MoonVisual[]; +} + +export interface MoonVisual { + mesh: THREE.Mesh; + orbit: THREE.LineLoop; +} + +export type OrbitalAnchor = + | { kind: "star" } + | { kind: "planet"; planetIndex: number } + | { kind: "moon"; planetIndex: number; moonIndex: number }; + +export interface NodeVisual { + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + sourceKind: string; + anchor: OrbitalAnchor; + localPosition: THREE.Vector3; + orbitRadius: number; + orbitPhase: number; + orbitInclination: number; +} + +export interface SpatialNodeVisual { + id: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + kind: string; + localPosition: THREE.Vector3; +} + +export interface BubbleVisual { + id: string; + systemId: string; + mesh: THREE.LineLoop; + localPosition: THREE.Vector3; + radius: number; +} + +export interface ClaimVisual { + id: string; + nodeId: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + localPosition: THREE.Vector3; +} + +export interface ConstructionSiteVisual { + id: string; + nodeId: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + localPosition: THREE.Vector3; +} + +export interface StructureVisual { + id: string; + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + anchor: OrbitalAnchor; + orbitRadius: number; + orbitPhase: number; + orbitInclination: number; + localPosition: THREE.Vector3; +} + +export interface SystemVisual { + root: THREE.Group; + starCluster: THREE.Group; + icon: THREE.Sprite; + shellReticle: THREE.Sprite; + shellReticleBaseScale: number; + detailGroup: THREE.Group; + summary: SystemSummaryVisual; + galaxyPosition: THREE.Vector3; +} + +export interface WorldState { + label: string; + seed: number; + sequence: number; + 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[]; +} + +export interface NetworkSample { + atMs: number; + bytes: number; +} + +export interface NetworkStats { + snapshotBytes: number; + deltasReceived: number; + deltaBytes: number; + lastDeltaBytes: number; + lastEntityChanges: number; + eventsReceived: number; + streamConnected: boolean; + streamOpenedAtMs?: number; + lastDeltaAtMs?: number; + throughputSamples: NetworkSample[]; +} + +export interface PerformanceSample { + atMs: number; + frameMs: number; +} + +export interface PerformanceStats { + frameSamples: PerformanceSample[]; + lastFrameMs: number; + lastPanelUpdateAtMs: number; +} + +export interface PresentationEntry { + detail: THREE.Object3D; + icon: THREE.Sprite; + systemId?: string; + hideDetailInUniverse?: boolean; + hideIconInUniverse?: boolean; +} + +export interface SystemSummaryVisual { + sprite: THREE.Sprite; + texture: THREE.CanvasTexture; + anchor: THREE.Vector3; +} + +export interface HistoryWindowState { + id: string; + target: Selectable; + root: HTMLElement; + titleEl: HTMLHeadingElement; + bodyEl: HTMLDivElement; + copyButtonEl: HTMLButtonElement; + text: string; +} diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts new file mode 100644 index 0000000..4fd2084 --- /dev/null +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -0,0 +1,247 @@ +import { fetchWorldSnapshot, openWorldStream } from "./api"; +import { renderFactionStrip } from "./viewerFactionStrip"; +import { updateDetailPanel } from "./viewerPanels"; +import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState"; +import type { + ClaimDelta, + ClaimSnapshot, + ConstructionSiteDelta, + ConstructionSiteSnapshot, + FactionSnapshot, + LocalBubbleDelta, + LocalBubbleSnapshot, + ResourceNodeDelta, + ResourceNodeSnapshot, + ShipDelta, + ShipSnapshot, + SpatialNodeDelta, + SpatialNodeSnapshot, + StationDelta, + StationSnapshot, + SystemSnapshot, + WorldDelta, + WorldSnapshot, +} from "./contracts"; +import type { + CameraMode, + NetworkStats, + Selectable, + WorldState, + ZoomLevel, +} from "./viewerTypes"; + +export interface ViewerWorldLifecycleContext { + getWorld: () => WorldState | undefined; + setWorld: (world: WorldState | undefined) => void; + getWorldTimeSyncMs: () => number; + setWorldTimeSyncMs: (value: number) => void; + getWorldSignature: () => string; + setWorldSignature: (value: string) => void; + getStream: () => EventSource | undefined; + setStream: (stream: EventSource | undefined) => void; + getCurrentStreamScopeKey: () => string; + setCurrentStreamScopeKey: (value: string) => void; + getZoomLevel: () => ZoomLevel; + getActiveSystemId: () => string | undefined; + getSelectedItems: () => Selectable[]; + getCameraMode: () => CameraMode; + getCameraTargetShipId: () => string | undefined; + getNetworkStats: () => NetworkStats; + getSystemSummaryVisuals: () => Map; + errorEl: HTMLDivElement; + factionStripEl: HTMLDivElement; + detailTitleEl: HTMLHeadingElement; + detailBodyEl: HTMLDivElement; + worldLabel: () => string; + rebuildSystems: (systems: SystemSnapshot[]) => void; + syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void; + syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => void; + syncNodes: (nodes: ResourceNodeSnapshot[]) => void; + syncStations: (stations: StationSnapshot[]) => void; + syncClaims: (claims: ClaimSnapshot[]) => void; + syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void; + syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void; + applySpatialNodeDeltas: (nodes: SpatialNodeDelta[]) => void; + applyLocalBubbleDeltas: (bubbles: LocalBubbleDelta[]) => void; + applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void; + applyStationDeltas: (stations: StationDelta[]) => void; + applyClaimDeltas: (claims: ClaimDelta[]) => void; + applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void; + applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void; + refreshHistoryWindows: () => void; + resolveFocusedBubbleId: () => string | undefined; + updateSystemSummaries: () => void; + applyZoomPresentation: () => void; + updateNetworkPanel: () => void; + updateSystemPanel: () => void; + updateGamePanel: (mode: string) => void; + describeSelectionParent: (selection: Selectable) => string; +} + +export class ViewerWorldLifecycle { + constructor(private readonly context: ViewerWorldLifecycleContext) {} + + async bootstrapWorld() { + try { + const snapshot = await fetchWorldSnapshot(); + this.context.setWorld(createWorldState(snapshot)); + this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size; + this.context.updateGamePanel("Bootstrapped"); + this.context.errorEl.hidden = true; + this.applySnapshot(snapshot); + this.openDeltaStream(snapshot.sequence); + this.updatePanels(); + } catch (error) { + this.context.updateGamePanel("Backend offline"); + this.context.errorEl.hidden = false; + this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot."; + } + } + + openDeltaStream(afterSequence: number) { + this.context.getStream()?.close(); + const scope = this.getPreferredStreamScope(); + this.context.setCurrentStreamScopeKey(JSON.stringify(scope)); + this.context.setStream(openWorldStream(afterSequence, { + onOpen: () => { + const networkStats = this.context.getNetworkStats(); + networkStats.streamConnected = true; + networkStats.streamOpenedAtMs = performance.now(); + this.context.updateGamePanel("Stream live"); + this.context.updateNetworkPanel(); + }, + onError: () => { + this.context.getNetworkStats().streamConnected = false; + this.context.updateGamePanel("Stream reconnecting"); + this.context.updateNetworkPanel(); + }, + onDelta: (delta, rawBytes) => { + void this.handleDelta(delta, rawBytes); + }, + }, scope)); + } + + async handleDelta(delta: WorldDelta, rawBytes: number) { + if (!this.context.getWorld()) { + return; + } + + if (delta.requiresSnapshotRefresh) { + await this.bootstrapWorld(); + return; + } + + this.applyDelta(delta); + recordDeltaStats(this.context.getNetworkStats(), delta, rawBytes); + this.context.updateGamePanel("Live"); + this.updatePanels(); + this.context.updateNetworkPanel(); + this.refreshStreamScopeIfNeeded(); + } + + refreshStreamScopeIfNeeded() { + const world = this.context.getWorld(); + if (!world) { + return; + } + + const nextScopeKey = JSON.stringify(this.getPreferredStreamScope()); + if (nextScopeKey === this.context.getCurrentStreamScopeKey()) { + return; + } + + this.openDeltaStream(world.sequence); + } + + applySnapshot(snapshot: WorldSnapshot) { + this.context.setWorldTimeSyncMs(performance.now()); + const signature = `${snapshot.seed}|${snapshot.systems.length}`; + if (signature !== this.context.getWorldSignature()) { + this.context.setWorldSignature(signature); + this.context.rebuildSystems(snapshot.systems); + } + + this.context.syncSpatialNodes(snapshot.spatialNodes); + this.context.syncLocalBubbles(snapshot.localBubbles); + this.context.syncNodes(snapshot.nodes); + this.context.syncStations(snapshot.stations); + this.context.syncClaims(snapshot.claims); + this.context.syncConstructionSites(snapshot.constructionSites); + this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs); + this.rebuildFactions(snapshot.factions); + this.context.updateSystemSummaries(); + this.context.applyZoomPresentation(); + this.context.updateNetworkPanel(); + } + + applyDelta(delta: WorldDelta) { + const world = this.context.getWorld(); + if (!world) { + return; + } + + this.context.setWorldTimeSyncMs(performance.now()); + const factionsChanged = applyDeltaToWorld(world, delta); + this.context.applySpatialNodeDeltas(delta.spatialNodes); + this.context.applyLocalBubbleDeltas(delta.localBubbles); + this.context.applyNodeDeltas(delta.nodes); + this.context.applyStationDeltas(delta.stations); + this.context.applyClaimDeltas(delta.claims); + this.context.applyConstructionSiteDeltas(delta.constructionSites); + this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs); + if (factionsChanged) { + this.rebuildFactions(cloneFactions(world)); + } + this.context.updateSystemSummaries(); + } + + rebuildFactions(_factions: FactionSnapshot[]) { + this.context.factionStripEl.innerHTML = renderFactionStrip( + this.context.getWorld(), + this.context.getSelectedItems(), + this.context.getCameraMode(), + this.context.getCameraTargetShipId(), + ); + } + + updatePanels() { + const world = this.context.getWorld(); + if (!world) { + return; + } + + this.context.refreshHistoryWindows(); + this.context.updateSystemPanel(); + this.refreshStreamScopeIfNeeded(); + updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, { + world, + selectedItems: this.context.getSelectedItems(), + zoomLevel: this.context.getZoomLevel(), + cameraMode: this.context.getCameraMode(), + cameraTargetShipId: this.context.getCameraTargetShipId(), + worldLabel: this.context.worldLabel(), + describeSelectionParent: this.context.describeSelectionParent, + }); + } + + private getPreferredStreamScope() { + const activeSystemId = this.context.getActiveSystemId(); + if (this.context.getZoomLevel() === "universe" || !activeSystemId) { + return { scopeKind: "universe" as const }; + } + + const bubbleId = this.context.resolveFocusedBubbleId(); + if (this.context.getZoomLevel() === "local" && bubbleId) { + return { + scopeKind: "local-bubble" as const, + systemId: activeSystemId, + bubbleId, + }; + } + + return { + scopeKind: "system" as const, + systemId: activeSystemId, + }; + } +} diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts new file mode 100644 index 0000000..74574f2 --- /dev/null +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -0,0 +1,465 @@ +import * as THREE from "three"; +import { + computeMoonLocalPosition, + computeMoonSize, + computePlanetLocalPosition, + currentWorldTimeSeconds, + resolveOrbitalAnchorPosition, + toThreeVector, +} from "./viewerMath"; +import { + resolveShipHeading, + updateSystemStarPresentation, + updateSystemSummaryPresentation, + getAnimatedShipLocalPosition, +} from "./viewerPresentation"; +import type { + LocalBubbleDelta, + LocalBubbleSnapshot, + ResourceNodeDelta, + ResourceNodeSnapshot, +} from "./contracts"; +import type { + BubbleVisual, + ClaimVisual, + ConstructionSiteVisual, + NodeVisual, + OrbitalAnchor, + ShipVisual, + SpatialNodeVisual, + StructureVisual, + SystemSummaryVisual, + SystemVisual, + WorldState, + ZoomLevel, + CameraMode, +} from "./viewerTypes"; + +type SummaryIconKind = "ship" | "station" | "structure"; + +export interface WorldOrbitalContext { + world?: WorldState; + worldTimeSyncMs: number; + worldSeed: number; + nodeVisuals: Map; + spatialNodeVisuals: Map; + bubbleVisuals: Map; + stationVisuals: Map; +} + +export interface WorldPresentationContext extends WorldOrbitalContext { + activeSystemId?: string; + orbitYaw: number; + camera: THREE.PerspectiveCamera; + systemFocusLocal: THREE.Vector3; + shipVisuals: Map; + claimVisuals: Map; + constructionSiteVisuals: Map; + systemVisuals: Map; + systemSummaryVisuals: Map; + toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; + updateSystemDetailVisibility: () => void; + setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; +} + +export interface GameStatusParams { + statusEl: HTMLDivElement; + world?: WorldState; + activeSystemId?: string; + cameraMode: CameraMode; + zoomLevel: ZoomLevel; + mode: string; +} + +export function updateWorldPresentation(context: WorldPresentationContext) { + const now = performance.now(); + const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs); + + for (const visual of context.shipVisuals.values()) { + const worldPosition = getAnimatedShipLocalPosition(visual, now); + visual.mesh.position.copy(context.toDisplayLocalPosition(worldPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + const shipVisible = visual.systemId === context.activeSystemId; + visual.mesh.visible = shipVisible; + visual.icon.visible = shipVisible && visual.icon.visible; + const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); + if (desiredHeading.lengthSq() > 0.01) { + visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); + } + } + + for (const visual of context.nodeVisuals.values()) { + const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === context.activeSystemId; + } + + for (const visual of context.spatialNodeVisuals.values()) { + const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === context.activeSystemId; + visual.icon.visible = visual.systemId === context.activeSystemId; + } + + for (const visual of context.bubbleVisuals.values()) { + const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.visible = visual.systemId === context.activeSystemId; + } + + for (const visual of context.stationVisuals.values()) { + const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === context.activeSystemId; + } + + for (const visual of context.claimVisuals.values()) { + const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === context.activeSystemId; + visual.icon.visible = visual.systemId === context.activeSystemId; + } + + for (const visual of context.constructionSiteVisuals.values()) { + const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); + visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.position.copy(visual.mesh.position); + visual.mesh.visible = visual.systemId === context.activeSystemId; + visual.icon.visible = visual.systemId === context.activeSystemId; + } + + updateSystemStarPresentation( + context.systemVisuals, + context.activeSystemId, + context.systemFocusLocal, + context.camera, + context.setShellReticleOpacity, + ); + context.updateSystemDetailVisibility(); + updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId); +} + +export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map) { + if (!world) { + return; + } + + const shipCounts = new Map(); + const stationCounts = new Map(); + const structureCounts = new Map(); + + for (const ship of world.ships.values()) { + shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1); + } + + for (const station of world.stations.values()) { + stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1); + structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1); + } + + for (const node of world.nodes.values()) { + structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1); + } + + for (const [systemId, system] of world.systems.entries()) { + const visual = systemSummaryVisuals.get(systemId); + if (!visual) { + continue; + } + + const canvas = visual.texture.image as HTMLCanvasElement; + const context = canvas.getContext("2d"); + if (!context) { + continue; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#eaf4ff"; + context.font = "600 34px Space Grotesk, sans-serif"; + context.textAlign = "center"; + context.fillText(system.label, canvas.width / 2, 40); + + const ships = shipCounts.get(systemId) ?? 0; + const stations = stationCounts.get(systemId) ?? 0; + const structures = structureCounts.get(systemId) ?? 0; + const gasClouds = [...world.nodes.values()] + .filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud") + .length; + const total = ships + stations + structures; + if (total > 0) { + context.fillStyle = "rgba(3, 8, 18, 0.72)"; + context.fillRect(56, 64, canvas.width - 112, 68); + context.strokeStyle = "rgba(132, 196, 255, 0.22)"; + context.strokeRect(56, 64, canvas.width - 112, 68); + + drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff"); + drawCountIcon(context, "station", 256, 98, stations, "#ffbf69"); + drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4"); + } + + visual.texture.needsUpdate = true; + } +} + +export function renderRecentEvents(world: WorldState | undefined, entityKind: string, entityId: string) { + if (!world) { + return ""; + } + + return world.recentEvents + .filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId)) + .slice(0, 8) + .map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`) + .join("
"); +} + +export function updateGameStatus(params: GameStatusParams) { + const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params; + const sequence = world?.sequence ?? 0; + const generatedAt = world?.generatedAtUtc + ? new Date(world.generatedAtUtc).toLocaleTimeString() + : "n/a"; + const activeSystem = activeSystemId ?? "deep-space"; + const cameraModeLabel = cameraMode === "follow" ? "camera-follow" : "tactical"; + + statusEl.textContent = [ + `mode: ${mode}`, + `camera: ${cameraModeLabel}`, + `zoom: ${zoomLevel}`, + `system: ${activeSystem}`, + `sequence: ${sequence}`, + `snapshot: ${generatedAt}`, + ].join("\n"); +} + +export function deriveNodeOrbital( + context: WorldOrbitalContext, + node: ResourceNodeSnapshot | ResourceNodeDelta, + anchor: OrbitalAnchor, +) { + return deriveOrbitalFromLocalPosition(context, toThreeVector(node.localPosition), node.systemId, anchor); +} + +export function deriveOrbitalFromLocalPosition( + context: WorldOrbitalContext, + localPosition: THREE.Vector3, + systemId: string, + anchor: OrbitalAnchor, +) { + const anchorPosition = getOrbitalAnchorPosition(context, systemId, anchor, currentWorldTimeSeconds(context.world, context.worldTimeSyncMs)); + const relativePosition = localPosition.clone().sub(anchorPosition); + const radius = Math.max(Math.sqrt((relativePosition.x * relativePosition.x) + (relativePosition.z * relativePosition.z)), 24); + const phase = Math.atan2(relativePosition.z, relativePosition.x); + const inclination = Math.atan2(relativePosition.y, radius); + return { radius, phase, inclination }; +} + +export function computeNodeLocalPosition(context: WorldOrbitalContext, node: NodeVisual, timeSeconds: number) { + const speed = computeNodeOrbitSpeed(node); + const angle = node.orbitPhase + (timeSeconds * speed); + const orbit = new THREE.Vector3( + Math.cos(angle) * node.orbitRadius, + 0, + Math.sin(angle) * node.orbitRadius, + ); + orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), node.orbitInclination); + return orbit.add(getOrbitalAnchorPosition(context, node.systemId, node.anchor, timeSeconds)); +} + +export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: string, localPosition: THREE.Vector3): OrbitalAnchor { + if (!context.world) { + return { kind: "star" }; + } + + const system = context.world.systems.get(systemId); + if (!system) { + return { kind: "star" }; + } + + const nowSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs); + let bestAnchor: OrbitalAnchor = { kind: "star" }; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const [planetIndex, planet] of system.planets.entries()) { + const planetPosition = computePlanetLocalPosition(planet, nowSeconds); + const planetDistance = localPosition.distanceTo(planetPosition); + const planetThreshold = Math.max(planet.size * 10, 180); + if (planetDistance < planetThreshold && planetDistance < bestDistance) { + bestDistance = planetDistance; + bestAnchor = { kind: "planet", planetIndex }; + } + + const moonCount = Math.min(planet.moonCount, 12); + for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) { + const moonPosition = planetPosition + .clone() + .add(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed)); + const moonDistance = localPosition.distanceTo(moonPosition); + const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80); + if (moonDistance < moonThreshold && moonDistance < bestDistance) { + bestDistance = moonDistance; + bestAnchor = { kind: "moon", planetIndex, moonIndex }; + } + } + } + + return bestAnchor; +} + +export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) { + if (nodeId) { + const spatialNode = context.world?.spatialNodes.get(nodeId); + if (spatialNode) { + return toThreeVector(spatialNode.localPosition); + } + } + + return new THREE.Vector3(0, 0, 0); +} + +export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { + return resolvePointPosition(context, bubble.systemId, bubble.nodeId); +} + +export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) { + return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone(); +} + +export function computeSpatialNodeLocalPositionById( + context: WorldOrbitalContext, + nodeId: string, + timeSeconds: number, + visiting = new Set(), +): THREE.Vector3 | undefined { + if (!context.world || visiting.has(nodeId)) { + return undefined; + } + + const node = context.world.spatialNodes.get(nodeId); + if (!node) { + return undefined; + } + + const basePosition = toThreeVector(node.localPosition); + if (!node.parentNodeId) { + return basePosition; + } + + const parentNode = context.world.spatialNodes.get(node.parentNodeId); + if (!parentNode) { + return basePosition; + } + + visiting.add(nodeId); + const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting); + visiting.delete(nodeId); + if (!parentCurrentPosition) { + return basePosition; + } + + const parentInitialPosition = 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); +} + +export function 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"); +} + +function drawCountIcon( + context: CanvasRenderingContext2D, + kind: SummaryIconKind, + x: number, + y: number, + value: number, + color: string, +) { + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 3; + + if (kind === "ship") { + context.beginPath(); + context.moveTo(x - 14, y + 10); + context.lineTo(x, y - 14); + context.lineTo(x + 14, y + 10); + context.closePath(); + context.stroke(); + } else if (kind === "station") { + context.strokeRect(x - 14, y - 14, 28, 28); + } else { + context.beginPath(); + context.arc(x, y, 14, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(x - 8, y); + context.lineTo(x + 8, y); + context.moveTo(x, y - 8); + context.lineTo(x, y + 8); + context.stroke(); + } + + context.fillStyle = "#eaf4ff"; + context.font = "600 26px IBM Plex Mono, monospace"; + context.textAlign = "left"; + context.fillText(String(value), x + 24, y + 9); + context.restore(); +} + +function computeNodeOrbitSpeed(node: NodeVisual) { + const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24; + return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4)); +} + +function computeStructureLocalPosition( + context: WorldOrbitalContext, + structure: StructureVisual, + timeSeconds: number, + baseSpeed: number, +) { + const angle = structure.orbitPhase + (timeSeconds * (baseSpeed / Math.sqrt(Math.max(structure.orbitRadius / 180, 0.45)))); + const orbit = new THREE.Vector3( + Math.cos(angle) * structure.orbitRadius, + 0, + Math.sin(angle) * structure.orbitRadius, + ); + orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), structure.orbitInclination); + return orbit.add(getOrbitalAnchorPosition(context, structure.systemId, structure.anchor, timeSeconds)); +} + +function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) { + return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed); +} + +function resolveBubbleAnimatedLocalPosition(context: WorldOrbitalContext, visual: BubbleVisual, timeSeconds: number) { + const bubble = context.world?.localBubbles.get(visual.id); + if (!bubble) { + return visual.localPosition.clone(); + } + + return computeSpatialNodeLocalPositionById(context, bubble.nodeId, timeSeconds) ?? visual.localPosition.clone(); +} + +function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) { + if (!context.world) { + return visual.localPosition.clone(); + } + + const station = context.world.stations.get(visual.id); + if (!station?.nodeId) { + return computeStructureLocalPosition(context, visual, timeSeconds, 0.14); + } + + return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone(); +}