From 5ba1287f85f4d228d90869ba5d5cf07f5d9999c5 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Sun, 15 Mar 2026 22:46:47 -0400 Subject: [PATCH] feat: production chain --- .../Contracts/WorldContracts.Celestial.cs | 2 + .../WorldContracts.Infrastructure.cs | 15 + .../backend/Contracts/WorldContracts.Ships.cs | 8 + .../backend/Contracts/WorldContracts.World.cs | 7 + apps/backend/Data/WorldDefinitions.cs | 10 +- apps/backend/Program.cs | 2 + .../Simulation/AI/ShipBehaviorStateMachine.cs | 1 + .../Simulation/AI/ShipBehaviorStates.cs | 49 +- .../Simulation/Model/ShipRuntimeModels.cs | 6 +- .../Simulation/Model/SimulationKinds.cs | 96 ++++ .../Simulation/Model/SimulationWorld.cs | 1 + .../Simulation/Model/SpatialRuntimeModels.cs | 6 +- .../Simulation/Model/StationRuntimeModels.cs | 5 +- .../Simulation/OrbitalSimulationOptions.cs | 6 + .../Simulation/ScenarioLoader.Generation.cs | 206 +++++++-- .../Simulation/ScenarioLoader.Seeding.cs | 126 ++++-- .../Simulation/ScenarioLoader.Spatial.cs | 64 ++- apps/backend/Simulation/ScenarioLoader.cs | 97 +++- .../SimulationEngine.MovementSystem.cs | 88 ++-- .../SimulationEngine.OrbitalSystem.cs | 65 ++- ...mulationEngine.PowerAndInventorySystems.cs | 339 +++++++++++++- .../SimulationEngine.Replication.cs | 165 ++++++- ...Engine.ResourceAndInfrastructureSystems.cs | 65 ++- .../SimulationEngine.ShipActionSystem.cs | 279 ++++++++---- .../SimulationEngine.ShipControl.cs | 307 ++++++++++--- .../SimulationEngine.StationSystems.cs | 363 +++++++++++++-- apps/backend/Simulation/SimulationEngine.cs | 13 +- .../Simulation/WorldGenerationOptions.cs | 8 + apps/backend/Simulation/WorldService.cs | 15 +- apps/backend/appsettings.Development.json | 7 + apps/backend/appsettings.json | 7 + apps/viewer/src/ViewerAppController.ts | 51 ++- apps/viewer/src/contracts.ts | 1 + apps/viewer/src/contractsCelestial.ts | 1 + apps/viewer/src/contractsInfrastructure.ts | 11 + apps/viewer/src/contractsShips.ts | 7 + apps/viewer/src/contractsWorld.ts | 8 + apps/viewer/src/style.css | 111 ++++- apps/viewer/src/viewerCamera.ts | 22 +- apps/viewer/src/viewerControllerFactory.ts | 7 +- apps/viewer/src/viewerControls.ts | 23 +- apps/viewer/src/viewerFactionStrip.ts | 37 +- apps/viewer/src/viewerHud.ts | 42 +- .../viewer/src/viewerInteractionController.ts | 11 + apps/viewer/src/viewerMath.ts | 3 +- apps/viewer/src/viewerPanels.ts | 111 ++++- apps/viewer/src/viewerPresentation.ts | 61 +-- .../src/viewerPresentationController.ts | 56 ++- apps/viewer/src/viewerSceneDataController.ts | 17 +- apps/viewer/src/viewerSceneFactory.ts | 74 ++-- apps/viewer/src/viewerScenePrimitives.ts | 159 +++++++ apps/viewer/src/viewerSceneSync.ts | 180 ++++---- apps/viewer/src/viewerSelection.ts | 204 ++++++++- apps/viewer/src/viewerState.ts | 4 + apps/viewer/src/viewerTelemetry.ts | 22 + apps/viewer/src/viewerTypes.ts | 67 +-- apps/viewer/src/viewerWorldLifecycle.ts | 8 +- apps/viewer/src/viewerWorldPresentation.ts | 73 +-- shared/data/balance.json | 3 - shared/data/constructibles.json | 12 +- shared/data/items.json | 96 ++++ shared/data/module-recipes.json | 38 ++ shared/data/modules.json | 24 +- shared/data/recipes.json | 419 ++++++++++++++++++ shared/data/ships.json | 14 +- 65 files changed, 3718 insertions(+), 687 deletions(-) create mode 100644 apps/backend/Simulation/OrbitalSimulationOptions.cs create mode 100644 apps/backend/Simulation/WorldGenerationOptions.cs create mode 100644 apps/viewer/src/viewerScenePrimitives.ts diff --git a/apps/backend/Contracts/WorldContracts.Celestial.cs b/apps/backend/Contracts/WorldContracts.Celestial.cs index c1bf907..1eebf78 100644 --- a/apps/backend/Contracts/WorldContracts.Celestial.cs +++ b/apps/backend/Contracts/WorldContracts.Celestial.cs @@ -30,6 +30,7 @@ public sealed record ResourceNodeSnapshot( string Id, string SystemId, Vector3Dto LocalPosition, + string? AnchorNodeId, string SourceKind, float OreRemaining, float MaxOre, @@ -39,6 +40,7 @@ public sealed record ResourceNodeDelta( string Id, string SystemId, Vector3Dto LocalPosition, + string? AnchorNodeId, string SourceKind, float OreRemaining, float MaxOre, diff --git a/apps/backend/Contracts/WorldContracts.Infrastructure.cs b/apps/backend/Contracts/WorldContracts.Infrastructure.cs index aef5569..18d0597 100644 --- a/apps/backend/Contracts/WorldContracts.Infrastructure.cs +++ b/apps/backend/Contracts/WorldContracts.Infrastructure.cs @@ -15,8 +15,13 @@ public sealed record StationSnapshot( string? AnchorNodeId, string Color, int DockedShips, + IReadOnlyList DockedShipIds, int DockingPads, + float FuelStored, + float FuelCapacity, float EnergyStored, + float EnergyCapacity, + IReadOnlyList CurrentProcesses, IReadOnlyList Inventory, string FactionId, string? CommanderId, @@ -39,8 +44,13 @@ public sealed record StationDelta( string? AnchorNodeId, string Color, int DockedShips, + IReadOnlyList DockedShipIds, int DockingPads, + float FuelStored, + float FuelCapacity, float EnergyStored, + float EnergyCapacity, + IReadOnlyList CurrentProcesses, IReadOnlyList Inventory, string FactionId, string? CommanderId, @@ -52,6 +62,11 @@ public sealed record StationDelta( IReadOnlyList InstalledModules, IReadOnlyList MarketOrderIds); +public sealed record StationActionProgressSnapshot( + string Lane, + string Label, + float Progress); + public sealed record ClaimSnapshot( string Id, string FactionId, diff --git a/apps/backend/Contracts/WorldContracts.Ships.cs b/apps/backend/Contracts/WorldContracts.Ships.cs index 6acc0e6..c435cd4 100644 --- a/apps/backend/Contracts/WorldContracts.Ships.cs +++ b/apps/backend/Contracts/WorldContracts.Ships.cs @@ -19,12 +19,14 @@ public sealed record ShipSnapshot( string? CommanderId, string? PolicySetId, float CargoCapacity, + string? CargoItemId, float WorkerPopulation, float EnergyStored, IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History, + ShipActionProgressSnapshot? CurrentAction, ShipSpatialStateSnapshot SpatialState); public sealed record ShipDelta( @@ -46,14 +48,20 @@ public sealed record ShipDelta( string? CommanderId, string? PolicySetId, float CargoCapacity, + string? CargoItemId, float WorkerPopulation, float EnergyStored, IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History, + ShipActionProgressSnapshot? CurrentAction, ShipSpatialStateSnapshot SpatialState); +public sealed record ShipActionProgressSnapshot( + string Label, + float Progress); + public sealed record ShipSpatialStateSnapshot( string SpaceLayer, string CurrentSystemId, diff --git a/apps/backend/Contracts/WorldContracts.World.cs b/apps/backend/Contracts/WorldContracts.World.cs index 280cebf..7150d3f 100644 --- a/apps/backend/Contracts/WorldContracts.World.cs +++ b/apps/backend/Contracts/WorldContracts.World.cs @@ -5,6 +5,8 @@ public sealed record WorldSnapshot( int Seed, long Sequence, int TickIntervalMs, + double OrbitalTimeSeconds, + OrbitalSimulationSnapshot OrbitalSimulation, DateTimeOffset GeneratedAtUtc, IReadOnlyList Systems, IReadOnlyList SpatialNodes, @@ -21,6 +23,8 @@ public sealed record WorldSnapshot( public sealed record WorldDelta( long Sequence, int TickIntervalMs, + double OrbitalTimeSeconds, + OrbitalSimulationSnapshot OrbitalSimulation, DateTimeOffset GeneratedAtUtc, bool RequiresSnapshotRefresh, IReadOnlyList Events, @@ -51,3 +55,6 @@ public sealed record ObserverScope( string ScopeKind, string? SystemId = null, string? BubbleId = null); + +public sealed record OrbitalSimulationSnapshot( + double SimulatedSecondsPerRealSecond); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 1953ec9..5f37ee6 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -11,7 +11,6 @@ public sealed class BalanceDefinition public float UndockingDuration { get; set; } public float UndockDistance { get; set; } public EnergyBalanceDefinition Energy { get; set; } = new(); - public FuelBalanceDefinition Fuel { get; set; } = new(); } public sealed class EnergyBalanceDefinition @@ -23,11 +22,6 @@ public sealed class EnergyBalanceDefinition public float StationSolarCharge { get; set; } } -public sealed class FuelBalanceDefinition -{ - public float WarpDrain { get; set; } -} - public sealed class SolarSystemDefinition { public required string Id { get; set; } @@ -57,6 +51,9 @@ public sealed class ResourceNodeDefinition public string SourceKind { get; set; } = "asteroid-belt"; public float Angle { get; set; } public float RadiusOffset { get; set; } + public float InclinationDegrees { get; set; } + public int? AnchorPlanetIndex { get; set; } + public int? AnchorMoonIndex { get; set; } public float OreAmount { get; set; } public required string ItemId { get; set; } public int ShardCount { get; set; } @@ -99,6 +96,7 @@ public sealed class RecipeDefinition public List RequiredModules { get; set; } = []; public List Inputs { get; set; } = []; public List Outputs { get; set; } = []; + public string? ShipOutputId { get; set; } } public sealed class PlanetDefinition diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index abeacbc..199e2f9 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -16,6 +16,8 @@ builder.Services.AddCors((options) => .AllowAnyOrigin(); }); }); +builder.Services.Configure(builder.Configuration.GetSection("WorldGeneration")); +builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs b/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs index 55c4375..761a1d3 100644 --- a/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs +++ b/apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs @@ -20,6 +20,7 @@ internal sealed class ShipBehaviorStateMachine new PatrolShipBehaviorState(), new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"), new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"), + new EnergySupplyShipBehaviorState(), new ConstructStationShipBehaviorState(), }; diff --git a/apps/backend/Simulation/AI/ShipBehaviorStates.cs b/apps/backend/Simulation/AI/ShipBehaviorStates.cs index 42e7840..3b38d1e 100644 --- a/apps/backend/Simulation/AI/ShipBehaviorStates.cs +++ b/apps/backend/Simulation/AI/ShipBehaviorStates.cs @@ -8,7 +8,7 @@ internal sealed class IdleShipBehaviorState : IShipBehaviorState { ship.ControllerTask = new ControllerTaskRuntime { - Kind = "idle", + Kind = ControllerTaskKind.Idle, Threshold = world.Balance.ArrivalThreshold, Status = WorkStatus.Pending, }; @@ -30,7 +30,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState ship.DefaultBehavior.Kind = "idle"; ship.ControllerTask = new ControllerTaskRuntime { - Kind = "idle", + Kind = ControllerTaskKind.Idle, Threshold = world.Balance.ArrivalThreshold, Status = WorkStatus.Pending, }; @@ -39,7 +39,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState ship.ControllerTask = new ControllerTaskRuntime { - Kind = "travel", + Kind = ControllerTaskKind.Travel, TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], TargetSystemId = ship.SystemId, Threshold = 18f, @@ -82,6 +82,10 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState case ("extract", "cargo-full"): ship.DefaultBehavior.Phase = "travel-to-station"; break; + case ("extract", "node-depleted"): + ship.DefaultBehavior.Phase = "travel-to-node"; + ship.DefaultBehavior.NodeId = null; + break; case ("travel-to-station", "arrived"): ship.DefaultBehavior.Phase = "dock"; break; @@ -114,10 +118,7 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState 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"; + ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "deliver-to-site"; break; case ("refuel", "refueled"): ship.DefaultBehavior.Phase = "deliver-to-site"; @@ -133,3 +134,37 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState } } } + +internal sealed class EnergySupplyShipBehaviorState : IShipBehaviorState +{ + public string Kind => "auto-supply-energy"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + engine.PlanEnergySupply(ship, world); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-source", "arrived"): + case ("travel-to-destination", "arrived"): + ship.DefaultBehavior.Phase = "dock"; + break; + case ("dock", "docked"): + ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "load"; + break; + case ("load", "loaded"): + ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock"; + break; + case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock"; + break; + case ("refuel", "refueled"): + ship.DefaultBehavior.Phase = "undock"; + break; + case ("undock", "undocked"): + ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "travel-to-destination" : "travel-to-source"; + break; + } + } +} diff --git a/apps/backend/Simulation/Model/ShipRuntimeModels.cs b/apps/backend/Simulation/Model/ShipRuntimeModels.cs index f726ea2..91adb86 100644 --- a/apps/backend/Simulation/Model/ShipRuntimeModels.cs +++ b/apps/backend/Simulation/Model/ShipRuntimeModels.cs @@ -12,7 +12,7 @@ public sealed class ShipRuntime 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 ShipState State { get; set; } = ShipState.Idle; public ShipOrderRuntime? Order { get; set; } public required DefaultBehaviorRuntime DefaultBehavior { get; set; } public required ControllerTaskRuntime ControllerTask { get; set; } @@ -25,6 +25,8 @@ public sealed class ShipRuntime public string? CommanderId { get; set; } public string? PolicySetId { get; set; } public float Health { get; set; } + public string? TrackedActionKey { get; set; } + public float TrackedActionTotal { get; set; } public List History { get; } = []; public string LastSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty; @@ -53,7 +55,7 @@ public sealed class DefaultBehaviorRuntime public sealed class ControllerTaskRuntime { - public required string Kind { get; set; } + public required ControllerTaskKind Kind { get; set; } public WorkStatus Status { get; set; } = WorkStatus.Pending; public string? CommanderId { get; set; } public string? TargetEntityId { get; set; } diff --git a/apps/backend/Simulation/Model/SimulationKinds.cs b/apps/backend/Simulation/Model/SimulationKinds.cs index 6353f55..bac4e3c 100644 --- a/apps/backend/Simulation/Model/SimulationKinds.cs +++ b/apps/backend/Simulation/Model/SimulationKinds.cs @@ -24,6 +24,53 @@ public enum OrderStatus Completed, } +public enum ShipState +{ + Idle, + Arriving, + CapacitorStarved, + LocalFlight, + SpoolingWarp, + Warping, + SpoolingFtl, + Ftl, + CargoFull, + MiningApproach, + Mining, + NodeDepleted, + AwaitingDock, + DockingApproach, + Docking, + Docked, + Transferring, + Loading, + Unloading, + Refueling, + WaitingMaterials, + ConstructionBlocked, + Constructing, + DeliveringConstruction, + Blocked, + Undocking, +} + +public enum ControllerTaskKind +{ + Idle, + Travel, + Extract, + Dock, + Load, + Unload, + Refuel, + DeliverConstruction, + BuildConstructionSite, + LoadWorkers, + UnloadWorkers, + ConstructModule, + Undock, +} + public static class SpaceLayerKinds { public const string UniverseSpace = "universe-space"; @@ -145,4 +192,53 @@ public static class SimulationEnumMappings OrderStatus.Completed => "completed", _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), }; + + public static string ToContractValue(this ShipState state) => state switch + { + ShipState.Idle => "idle", + ShipState.Arriving => "arriving", + ShipState.CapacitorStarved => "capacitor-starved", + ShipState.LocalFlight => "local-flight", + ShipState.SpoolingWarp => "spooling-warp", + ShipState.Warping => "warping", + ShipState.SpoolingFtl => "spooling-ftl", + ShipState.Ftl => "ftl", + ShipState.CargoFull => "cargo-full", + ShipState.MiningApproach => "mining-approach", + ShipState.Mining => "mining", + ShipState.NodeDepleted => "node-depleted", + ShipState.AwaitingDock => "awaiting-dock", + ShipState.DockingApproach => "docking-approach", + ShipState.Docking => "docking", + ShipState.Docked => "docked", + ShipState.Transferring => "transferring", + ShipState.Loading => "loading", + ShipState.Unloading => "unloading", + ShipState.Refueling => "refueling", + ShipState.WaitingMaterials => "waiting-materials", + ShipState.ConstructionBlocked => "construction-blocked", + ShipState.Constructing => "constructing", + ShipState.DeliveringConstruction => "delivering-construction", + ShipState.Blocked => "blocked", + ShipState.Undocking => "undocking", + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), + }; + + public static string ToContractValue(this ControllerTaskKind kind) => kind switch + { + ControllerTaskKind.Idle => "idle", + ControllerTaskKind.Travel => "travel", + ControllerTaskKind.Extract => "extract", + ControllerTaskKind.Dock => "dock", + ControllerTaskKind.Load => "load", + ControllerTaskKind.Unload => "unload", + ControllerTaskKind.Refuel => "refuel", + ControllerTaskKind.DeliverConstruction => "deliver-construction", + ControllerTaskKind.BuildConstructionSite => "build-construction-site", + ControllerTaskKind.LoadWorkers => "load-workers", + ControllerTaskKind.UnloadWorkers => "unload-workers", + ControllerTaskKind.ConstructModule => "construct-module", + ControllerTaskKind.Undock => "undock", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; } diff --git a/apps/backend/Simulation/Model/SimulationWorld.cs b/apps/backend/Simulation/Model/SimulationWorld.cs index 6f5ae1a..b2c1159 100644 --- a/apps/backend/Simulation/Model/SimulationWorld.cs +++ b/apps/backend/Simulation/Model/SimulationWorld.cs @@ -24,5 +24,6 @@ public sealed class SimulationWorld public required Dictionary ModuleRecipes { get; init; } public required Dictionary Recipes { get; init; } public int TickIntervalMs { get; init; } = 200; + public double OrbitalTimeSeconds { get; set; } public DateTimeOffset GeneratedAtUtc { get; set; } } diff --git a/apps/backend/Simulation/Model/SpatialRuntimeModels.cs b/apps/backend/Simulation/Model/SpatialRuntimeModels.cs index daf4a50..04fcabb 100644 --- a/apps/backend/Simulation/Model/SpatialRuntimeModels.cs +++ b/apps/backend/Simulation/Model/SpatialRuntimeModels.cs @@ -12,9 +12,13 @@ public sealed class ResourceNodeRuntime { public required string Id { get; init; } public required string SystemId { get; init; } - public required Vector3 Position { get; init; } + public required Vector3 Position { get; set; } public required string SourceKind { get; init; } public required string ItemId { get; init; } + public string? AnchorNodeId { get; set; } + public float OrbitRadius { get; init; } + public float OrbitPhase { get; init; } + public float OrbitInclination { get; init; } public float OreRemaining { get; set; } public float MaxOre { get; init; } public string LastDeltaSignature { get; set; } = string.Empty; diff --git a/apps/backend/Simulation/Model/StationRuntimeModels.cs b/apps/backend/Simulation/Model/StationRuntimeModels.cs index e7d0037..12a836b 100644 --- a/apps/backend/Simulation/Model/StationRuntimeModels.cs +++ b/apps/backend/Simulation/Model/StationRuntimeModels.cs @@ -14,17 +14,18 @@ public sealed class StationRuntime public string? AnchorNodeId { get; set; } public string? CommanderId { get; set; } public string? PolicySetId { get; set; } - public HashSet InstalledModules { get; } = new(StringComparer.Ordinal); + public List InstalledModules { get; } = []; public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public Dictionary ProductionLaneTimers { get; } = new(StringComparer.Ordinal); public Dictionary DockingPadAssignments { get; } = new(); public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); public float 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 float ShipProductionProgressSeconds { get; set; } public HashSet DockedShipIds { get; } = []; public ModuleConstructionRuntime? ActiveConstruction { get; set; } public string LastDeltaSignature { get; set; } = string.Empty; diff --git a/apps/backend/Simulation/OrbitalSimulationOptions.cs b/apps/backend/Simulation/OrbitalSimulationOptions.cs new file mode 100644 index 0000000..9ed76a8 --- /dev/null +++ b/apps/backend/Simulation/OrbitalSimulationOptions.cs @@ -0,0 +1,6 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class OrbitalSimulationOptions +{ + public double SimulatedSecondsPerRealSecond { get; init; } = 0d; +} diff --git a/apps/backend/Simulation/ScenarioLoader.Generation.cs b/apps/backend/Simulation/ScenarioLoader.Generation.cs index 8f2a947..9e185a5 100644 --- a/apps/backend/Simulation/ScenarioLoader.Generation.cs +++ b/apps/backend/Simulation/ScenarioLoader.Generation.cs @@ -4,13 +4,18 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class ScenarioLoader { - private static List InjectSpecialSystems(IReadOnlyList authoredSystems) + private const string SolSystemId = "sol"; + private const string DevelopmentCompanionSystemId = "helios"; + + private static List InjectSpecialSystems( + IReadOnlyList authoredSystems, + bool includeSolSystem) { var systems = authoredSystems .Select(CloneSystemDefinition) .ToList(); - if (systems.All((system) => system.Id != "sol")) + if (includeSolSystem && systems.All((system) => system.Id != "sol")) { systems.Add(CreateSolSystem()); } @@ -18,13 +23,25 @@ public sealed partial class ScenarioLoader return systems; } - private static List ExpandSystems(IReadOnlyList authoredSystems) + private static List ExpandSystems( + IReadOnlyList authoredSystems, + int targetSystemCount) { var systems = authoredSystems .Select(CloneSystemDefinition) .ToList(); - if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0) + if (targetSystemCount <= 0) + { + return []; + } + + if (systems.Count > targetSystemCount) + { + return TrimSystemsToTarget(systems, targetSystemCount); + } + + if (systems.Count >= targetSystemCount || authoredSystems.Count == 0) { return systems; } @@ -32,9 +49,11 @@ public sealed partial class ScenarioLoader var existingIds = systems .Select((system) => system.Id) .ToHashSet(StringComparer.Ordinal); - var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count); + var generatedPositions = BuildGalaxyPositions( + authoredSystems.Select((system) => ToVector(system.Position)).ToList(), + targetSystemCount - systems.Count); - for (var index = systems.Count; index < TargetSystemCount; index += 1) + for (var index = systems.Count; index < targetSystemCount; index += 1) { var template = authoredSystems[index % authoredSystems.Count]; var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; @@ -50,6 +69,63 @@ public sealed partial class ScenarioLoader return systems; } + private static List TrimSystemsToTarget( + IReadOnlyList systems, + int targetSystemCount) + { + var selected = new List(targetSystemCount); + + void AddById(string systemId) + { + var system = systems.FirstOrDefault((candidate) => string.Equals(candidate.Id, systemId, StringComparison.Ordinal)); + if (system is not null && selected.All((candidate) => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal))) + { + selected.Add(system); + } + } + + AddById(SolSystemId); + AddById(DevelopmentCompanionSystemId); + + foreach (var system in systems) + { + if (selected.Count >= targetSystemCount) + { + break; + } + + if (selected.Any((candidate) => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal))) + { + continue; + } + + selected.Add(system); + } + + if (selected.Count > 0 && selected.Count <= 4) + { + ApplyCompactGalaxyLayout(selected); + } + + return selected; + } + + private static void ApplyCompactGalaxyLayout(IReadOnlyList systems) + { + var compactPositions = new[] + { + new[] { 0f, 0f, 0f }, + new[] { 2600f, 24f, -420f }, + new[] { -2400f, -36f, 560f }, + new[] { 520f, 42f, 2480f }, + }; + + for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1) + { + systems[index].Position = compactPositions[index]; + } + } + private static SolarSystemDefinition CreateGeneratedSystem( SolarSystemDefinition template, string label, @@ -66,6 +142,9 @@ public sealed partial class ScenarioLoader SourceKind = node.SourceKind, Angle = node.Angle, RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, OreAmount = node.OreAmount, ItemId = node.ItemId, ShardCount = node.ShardCount, @@ -118,8 +197,12 @@ public sealed partial class ScenarioLoader ResourceNodes = definition.ResourceNodes .Select((node) => new ResourceNodeDefinition { + SourceKind = node.SourceKind, Angle = node.Angle, RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, OreAmount = node.OreAmount, ItemId = node.ItemId, ShardCount = node.ShardCount, @@ -161,6 +244,9 @@ public sealed partial class ScenarioLoader SourceKind = node.SourceKind, Angle = node.Angle, RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, OreAmount = node.OreAmount, ItemId = node.ItemId, ShardCount = node.ShardCount, @@ -239,9 +325,8 @@ public sealed partial class ScenarioLoader private static IEnumerable BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList planets) { - var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex); var nodeCount = 4 + (generatedIndex % 4); - var oreAmount = 2800f + ((generatedIndex % 5) * 320f); + var oreAmount = 1000f; for (var index = 0; index < nodeCount; index += 1) { @@ -249,7 +334,9 @@ public sealed partial class ScenarioLoader { SourceKind = "asteroid-belt", Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), - RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f), + RadiusOffset = 120f + Jitter(generatedIndex, 200 + index, 36f), + InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f), + AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets), OreAmount = oreAmount, ItemId = "ore", ShardCount = 6 + (index % 4), @@ -269,15 +356,27 @@ public sealed partial class ScenarioLoader yield break; } + var gasAnchorIndex = 0; + for (var index = 0; index < planets.Count; index += 1) + { + if (ReferenceEquals(planets[index], gasAnchor)) + { + gasAnchorIndex = index; + break; + } + } + var nodeCount = 2 + (generatedIndex % 3); - var gasAmount = 2200f + ((generatedIndex % 4) * 260f); + var gasAmount = 1000f; 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), + RadiusOffset = 170f + Jitter(generatedIndex, 260 + index, 44f), + InclinationDegrees = Jitter(generatedIndex, 320 + index, 10f), + AnchorPlanetIndex = gasAnchorIndex, OreAmount = gasAmount, ItemId = "gas", ShardCount = 10 + index, @@ -285,19 +384,29 @@ public sealed partial class ScenarioLoader } } - private static float ResolveAsteroidBeltRadius(IReadOnlyList planets, int generatedIndex) + private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList planets) { - 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) + if (planets.Count == 0) { - return gap.LeftOrbitRadius + (gap.Gap * 0.52f); + return 0; } - return 420f + ((generatedIndex % 5) * 60f); + var gasGiantIndex = -1; + for (var index = 0; index < planets.Count; index += 1) + { + if (planets[index].PlanetType is "gas-giant" or "ice-giant") + { + gasGiantIndex = index; + break; + } + } + + if (gasGiantIndex > 0) + { + return gasGiantIndex - 1; + } + + return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1); } private static List BuildGeneratedPlanets( @@ -424,6 +533,15 @@ public sealed partial class ScenarioLoader private static SolarSystemDefinition CreateSolSystem() { + var mercuryOrbitAu = 0.3871f; + var venusOrbitAu = 0.7233f; + var earthOrbitAu = 1.000f; + var marsOrbitAu = 1.5237f; + var jupiterOrbitAu = 5.203f; + var saturnOrbitAu = 9.582f; + var uranusOrbitAu = 19.201f; + var neptuneOrbitAu = 30.047f; + return new SolarSystemDefinition { Id = "sol", @@ -438,30 +556,30 @@ public sealed partial class ScenarioLoader AsteroidField = new AsteroidFieldDefinition { DecorationCount = 240, - RadiusOffset = 780f, + RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f), 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 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 }, + new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, 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) + CreateSolPlanet("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false), + CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false), + CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false), + CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false), + CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true), + CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, saturnOrbitAu, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true), + CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true), + CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, neptuneOrbitAu, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true) ], }; } @@ -471,8 +589,7 @@ public sealed partial class ScenarioLoader string planetType, string shape, int moonCount, - float orbitRadius, - float orbitSpeed, + float orbitRadiusAu, float orbitEccentricity, float orbitInclination, float ascendingNode, @@ -488,8 +605,8 @@ public sealed partial class ScenarioLoader PlanetType = planetType, Shape = shape, MoonCount = moonCount, - OrbitRadius = orbitRadius, - OrbitSpeed = orbitSpeed, + OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu), + OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu), OrbitEccentricity = orbitEccentricity, OrbitInclination = orbitInclination, OrbitLongitudeOfAscendingNode = ascendingNode, @@ -506,4 +623,13 @@ public sealed partial class ScenarioLoader HasRing = hasRing, }; } + + private static float ScaleSolOrbitRadiusFromAu(float orbitRadiusAu) => + MathF.Round(500f * MathF.Pow(orbitRadiusAu, 0.70f)); + + private static float ComputeSolOrbitSpeed(float orbitRadiusAu) + { + const float earthAngularSpeed = 0.11f; + return earthAngularSpeed / MathF.Sqrt(orbitRadiusAu * orbitRadiusAu * orbitRadiusAu); + } } diff --git a/apps/backend/Simulation/ScenarioLoader.Seeding.cs b/apps/backend/Simulation/ScenarioLoader.Seeding.cs index b0c65a4..de98a6d 100644 --- a/apps/backend/Simulation/ScenarioLoader.Seeding.cs +++ b/apps/backend/Simulation/ScenarioLoader.Seeding.cs @@ -21,7 +21,12 @@ public sealed partial class ScenarioLoader factionIds.Add(DefaultFactionId); } - return factionIds.Select(CreateFaction).ToList(); + factionIds.Add(UnclaimedFactionId); + + return factionIds + .Distinct(StringComparer.Ordinal) + .Select(CreateFaction) + .ToList(); } private static FactionRuntime CreateFaction(string factionId) @@ -35,6 +40,13 @@ public sealed partial class ScenarioLoader Color = "#7ed4ff", Credits = MinimumFactionCredits, }, + UnclaimedFactionId => new FactionRuntime + { + Id = factionId, + Label = "Unclaimed", + Color = "#7f8794", + Credits = 0f, + }, _ => new FactionRuntime { Id = factionId, @@ -89,30 +101,32 @@ public sealed partial class ScenarioLoader IReadOnlyCollection nodes, DateTimeOffset nowUtc) { - var claims = new List(stations.Count); - foreach (var station in stations) + var stationsByAnchorNodeId = stations + .Where((station) => station.AnchorNodeId is not null) + .ToDictionary((station) => station.AnchorNodeId!, StringComparer.Ordinal); + var claims = new List(); + foreach (var node in nodes.Where((candidate) => candidate.Kind == SpatialNodeKind.LagrangePoint)) { - if (station.AnchorNodeId is null) - { - continue; - } - - var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId); - if (anchorNode is null) - { - continue; - } + var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station) + ? station.FactionId + : UnclaimedFactionId; + var activatesAtUtc = owningFactionId == UnclaimedFactionId + ? nowUtc + : nowUtc.AddSeconds(8); + var state = owningFactionId == UnclaimedFactionId + ? ClaimStateKinds.Active + : ClaimStateKinds.Activating; claims.Add(new ClaimRuntime { - Id = $"claim-{station.Id}", - FactionId = station.FactionId, - SystemId = station.SystemId, - NodeId = anchorNode.Id, - BubbleId = anchorNode.BubbleId, + Id = $"claim-{node.Id}", + FactionId = owningFactionId, + SystemId = node.SystemId, + NodeId = node.Id, + BubbleId = node.BubbleId, PlacedAtUtc = nowUtc, - ActivatesAtUtc = nowUtc.AddSeconds(8), - State = ClaimStateKinds.Activating, + ActivatesAtUtc = activatesAtUtc, + State = state, Health = 100f, }); } @@ -138,8 +152,13 @@ public sealed partial class ScenarioLoader } 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)) + if (anchorNode is null) + { + continue; + } + + var claim = claims.FirstOrDefault((candidate) => candidate.NodeId == anchorNode.Id); + if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) { continue; } @@ -192,9 +211,20 @@ public sealed partial class ScenarioLoader StationRuntime station, IReadOnlyDictionary moduleRecipes) { - foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) + foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[] { - if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) + ("gas-tank", 1), + ("fuel-processor", 1), + ("refinery-stack", 1), + ("container-bay", 1), + ("fabricator-array", 2), + ("component-factory", 1), + ("ship-factory", 1), + ("solar-array", 2), + ("dock-bay-small", 2), + }) + { + if (CountModules(station.InstalledModules, moduleId) < targetCount && moduleRecipes.ContainsKey(moduleId)) { return moduleId; @@ -220,6 +250,11 @@ public sealed partial class ScenarioLoader var policies = new List(factions.Count); foreach (var faction in factions) { + if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal)) + { + continue; + } + var policyId = $"policy-{faction.Id}"; faction.DefaultPolicySetId = policyId; policies.Add(new PolicySetRuntime @@ -244,6 +279,11 @@ public sealed partial class ScenarioLoader foreach (var faction in factions) { + if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal)) + { + continue; + } + var commander = new CommanderRuntime { Id = $"commander-faction-{faction.Id}", @@ -251,9 +291,16 @@ public sealed partial class ScenarioLoader FactionId = faction.Id, ControlledEntityId = faction.Id, PolicySetId = faction.DefaultPolicySetId, - Doctrine = "strategic-default", + Doctrine = "strategic-expansionist", }; + commander.Goals.Add("control-all-systems"); + commander.Goals.Add("control-five-systems-fast"); + commander.Goals.Add("expand-industrial-base"); + commander.Goals.Add("grow-war-fleet"); + commander.Goals.Add("deter-pirate-harassment"); + commander.Goals.Add("contest-rival-expansion"); + commanders.Add(commander); factionCommanders[faction.Id] = commander; faction.CommanderIds.Add(commander.Id); @@ -334,7 +381,7 @@ public sealed partial class ScenarioLoader IReadOnlyDictionary> patrolRoutes, StationRuntime? refinery) { - if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null) + if (string.Equals(definition.Role, "construction", StringComparison.Ordinal) && refinery is not null) { return new DefaultBehaviorRuntime { @@ -345,24 +392,23 @@ public sealed partial class ScenarioLoader } if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null) + { + return CreateResourceHarvestBehavior("auto-harvest-gas", scenario.MiningDefaults.NodeSystemId, refinery.Id); + } + + if (string.Equals(definition.Role, "transport", StringComparison.Ordinal) && refinery is not null) { return new DefaultBehaviorRuntime { - Kind = "auto-harvest-gas", + Kind = "auto-supply-energy", StationId = refinery.Id, - Phase = "travel-to-node", + Phase = "travel-to-source", }; } 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", - }; + return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id); } if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route)) @@ -381,6 +427,14 @@ public sealed partial class ScenarioLoader }; } + private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new() + { + Kind = kind, + AreaSystemId = areaSystemId, + StationId = stationId, + Phase = "travel-to-node", + }; + private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new() { Kind = behavior.Kind, @@ -402,7 +456,7 @@ public sealed partial class ScenarioLoader private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new() { - Kind = task.Kind, + Kind = task.Kind.ToContractValue(), Status = task.Status, TargetEntityId = task.TargetEntityId, TargetNodeId = targetNodeId ?? task.TargetNodeId, diff --git a/apps/backend/Simulation/ScenarioLoader.Spatial.cs b/apps/backend/Simulation/ScenarioLoader.Spatial.cs index 65d0126..ca649d1 100644 --- a/apps/backend/Simulation/ScenarioLoader.Spatial.cs +++ b/apps/backend/Simulation/ScenarioLoader.Spatial.cs @@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader parentNodeId: starNode.Id); var lagrangeNodes = new Dictionary(StringComparer.Ordinal); - foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) + foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex)) { var lagrangeNode = AddSpatialNode( nodes, @@ -111,22 +111,41 @@ public sealed partial class ScenarioLoader return node; } - private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) + private static IEnumerable EnumeratePlanetLagrangePoints( + Vector3 planetPosition, + float orbitRadius, + float planetSize, + 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 offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex); 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("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f)))); yield return new LagrangePointPlacement( "L4", - Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); + Add( + planetPosition, + Add( + Scale(radial, offset * MathF.Cos(triangularAngle)), + Scale(tangential, offset * MathF.Sin(triangularAngle))))); yield return new LagrangePointPlacement( "L5", - Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); + Add( + planetPosition, + Add( + Scale(radial, offset * MathF.Cos(triangularAngle)), + Scale(tangential, -offset * MathF.Sin(triangularAngle))))); + } + + private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex) + { + var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f)); + var sizeScale = (planetSize * 1.9f) + 10f; + return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale)); } private static StationPlacement ResolveStationPlacement( @@ -172,6 +191,39 @@ public sealed partial class ScenarioLoader _ => "L1", }; + private static NodeRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) + { + if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0) + { + return null; + } + + if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) + { + var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; + return graph.Nodes.FirstOrDefault((node) => node.Id == moonNodeId); + } + + var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}"; + return graph.Nodes.FirstOrDefault((node) => node.Id == planetNodeId); + } + + private static Vector3 ComputeResourceNodePosition(NodeRuntime? anchorNode, ResourceNodeDefinition definition, float yPlane) + { + var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.18f, 28f); + var offset = new Vector3( + MathF.Cos(definition.Angle) * definition.RadiusOffset, + verticalOffset, + MathF.Sin(definition.Angle) * definition.RadiusOffset); + + if (anchorNode is null) + { + return new Vector3(offset.X, yPlane + offset.Y, offset.Z); + } + + return Add(anchorNode.Position, offset); + } + private static Vector3 ComputePlanetPosition(PlanetDefinition planet) { var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index 5b11e3c..2e38df5 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -6,7 +6,7 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class ScenarioLoader { private const string DefaultFactionId = "sol-dominion"; - private const int TargetSystemCount = 160; + private const string UnclaimedFactionId = "unclaimed"; private const int WorldSeed = 1; private const float MinimumFactionCredits = 0f; private const float MinimumRefineryOre = 0f; @@ -76,20 +76,27 @@ public sealed partial class ScenarioLoader ]; private readonly string _dataRoot; + private readonly WorldGenerationOptions _worldGeneration; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, }; - public ScenarioLoader(string contentRootPath) + public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null) { _dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data")); + _worldGeneration = worldGeneration ?? new WorldGenerationOptions(); } public SimulationWorld Load() { - var systems = ExpandSystems(InjectSpecialSystems(Read>("systems.json"))); - var scenario = Read("scenario.json"); + var authoredSystems = Read>("systems.json"); + var systems = ExpandSystems( + InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem), + _worldGeneration.TargetSystemCount); + var scenario = NormalizeScenarioToAvailableSystems( + Read("scenario.json"), + systems.Select((system) => system.Id).ToList()); var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); var items = Read>("items.json"); @@ -127,18 +134,21 @@ public sealed partial class ScenarioLoader foreach (var system in systemRuntimes) { + var systemGraph = systemGraphs[system.Definition.Id]; foreach (var node in system.Definition.ResourceNodes) { + var anchorNode = ResolveResourceNodeAnchor(systemGraph, node); var resourceNode = new ResourceNodeRuntime { Id = $"node-{++nodeIdCounter}", SystemId = system.Definition.Id, - Position = new Vector3( - MathF.Cos(node.Angle) * node.RadiusOffset, - balance.YPlane, - MathF.Sin(node.Angle) * node.RadiusOffset), + Position = ComputeResourceNodePosition(anchorNode, node, balance.YPlane), SourceKind = node.SourceKind, ItemId = node.ItemId, + AnchorNodeId = anchorNode?.Id, + OrbitRadius = node.RadiusOffset, + OrbitPhase = node.Angle, + OrbitInclination = DegreesToRadians(node.InclinationDegrees), OreRemaining = node.OreAmount, MaxOre = node.OreAmount, }; @@ -152,6 +162,7 @@ public sealed partial class ScenarioLoader Kind = SpatialNodeKind.ResourceSite, Position = resourceNode.Position, BubbleId = bubbleId, + ParentNodeId = anchorNode?.Id, }); localBubbles.Add(new LocalBubbleRuntime { @@ -230,10 +241,15 @@ public sealed partial class ScenarioLoader station.SystemId == scenario.MiningDefaults.RefinerySystemId) ?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank")); - var patrolRoutes = scenario.PatrolRoutes.ToDictionary( - (route) => route.SystemId, - (route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(), - StringComparer.Ordinal); + var patrolRoutes = scenario.PatrolRoutes + .GroupBy((route) => route.SystemId, StringComparer.Ordinal) + .ToDictionary( + (group) => group.Key, + (group) => group + .SelectMany((route) => route.Points) + .Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point)) + .ToList(), + StringComparer.Ordinal); var shipsRuntime = new List(); var shipIdCounter = 0; @@ -258,7 +274,7 @@ public sealed partial 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 = WorkStatus.Pending }, + ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, Health = definition.MaxHealth, }); @@ -306,6 +322,7 @@ public sealed partial class ScenarioLoader ItemDefinitions = itemDefinitions, ModuleRecipes = moduleRecipeDefinitions, Recipes = recipeDefinitions, + OrbitalTimeSeconds = WorldSeed * 97d, GeneratedAtUtc = DateTimeOffset.UtcNow, }; } @@ -318,6 +335,60 @@ public sealed partial class ScenarioLoader ?? throw new InvalidOperationException($"Unable to read {fileName}."); } + private static ScenarioDefinition NormalizeScenarioToAvailableSystems( + ScenarioDefinition scenario, + IReadOnlyList availableSystemIds) + { + if (availableSystemIds.Count == 0) + { + return scenario; + } + + var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal) + ? "sol" + : availableSystemIds[0]; + + string ResolveSystemId(string systemId) => + availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId; + + return new ScenarioDefinition + { + InitialStations = scenario.InitialStations + .Select((station) => new InitialStationDefinition + { + ConstructibleId = station.ConstructibleId, + SystemId = ResolveSystemId(station.SystemId), + FactionId = station.FactionId, + PlanetIndex = station.PlanetIndex, + LagrangeSide = station.LagrangeSide, + Position = station.Position?.ToArray(), + }) + .ToList(), + ShipFormations = scenario.ShipFormations + .Select((formation) => new ShipFormationDefinition + { + ShipId = formation.ShipId, + Count = formation.Count, + Center = formation.Center.ToArray(), + SystemId = ResolveSystemId(formation.SystemId), + FactionId = formation.FactionId, + }) + .ToList(), + PatrolRoutes = scenario.PatrolRoutes + .Select((route) => new PatrolRouteDefinition + { + SystemId = ResolveSystemId(route.SystemId), + Points = route.Points.Select((point) => point.ToArray()).ToList(), + }) + .ToList(), + MiningDefaults = new MiningDefaultsDefinition + { + NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId), + RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId), + }, + }; + } + private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) diff --git a/apps/backend/Simulation/SimulationEngine.MovementSystem.cs b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs index 92798f1..d3ee35b 100644 --- a/apps/backend/Simulation/SimulationEngine.MovementSystem.cs +++ b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs @@ -7,18 +7,19 @@ public sealed partial class SimulationEngine 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), + ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds), + ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds), + ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds), + ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds), + ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds), + ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds), + ControllerTaskKind.Refuel => UpdateRefuel(ship, world, deltaSeconds), + ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds), + ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds), + ControllerTaskKind.LoadWorkers => UpdateLoadWorkers(ship, world, deltaSeconds), + ControllerTaskKind.UnloadWorkers => UpdateUnloadWorkers(ship, world, deltaSeconds), + ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds), + ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds), _ => UpdateIdle(ship, world, deltaSeconds), }; } @@ -26,7 +27,7 @@ public sealed partial class SimulationEngine private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -36,7 +37,7 @@ public sealed partial class SimulationEngine var task = ship.ControllerTask; if (task.TargetPosition is null || task.TargetSystemId is null) { - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -47,7 +48,9 @@ public sealed partial class SimulationEngine if (ship.SystemId != task.TargetSystemId) { - return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode); + var destinationEntryNode = ResolveSystemEntryNode(world, task.TargetSystemId); + var destinationEntryPosition = destinationEntryNode?.Position ?? Vector3.Zero; + return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryNode); } var currentNode = ResolveCurrentNode(world, ship); @@ -95,6 +98,11 @@ public sealed partial class SimulationEngine .FirstOrDefault(); } + private static NodeRuntime? ResolveSystemEntryNode(SimulationWorld world, string systemId) => + world.SpatialNodes.FirstOrDefault(candidate => + candidate.SystemId == systemId && + candidate.Kind == SpatialNodeKind.Star); + private string UpdateLocalTravel( ShipRuntime ship, SimulationWorld world, @@ -119,19 +127,19 @@ public sealed partial class SimulationEngine ship.SystemId = targetSystemId; ship.SpatialState.CurrentNodeId = targetNode?.Id; ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; - ship.State = "arriving"; + ship.State = ShipState.Arriving; return "arrived"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } ship.ActionTimer = 0f; - ship.State = "local-flight"; + ship.State = ShipState.LocalFlight; ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds); return "none"; } @@ -158,32 +166,32 @@ public sealed partial class SimulationEngine ship.SpatialState.DestinationNodeId = targetNode.Id; var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != "warping") + if (ship.State != ShipState.Warping) { - if (ship.State != "spooling-warp") + if (ship.State != ShipState.SpoolingWarp) { ship.ActionTimer = 0f; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "spooling-warp"; + ship.State = ShipState.SpoolingWarp; if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) { return "none"; } - ship.State = "warping"; + ship.State = ShipState.Warping; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } @@ -220,32 +228,32 @@ public sealed partial class SimulationEngine ship.SpatialState.CurrentBubbleId = null; ship.SpatialState.DestinationNodeId = destinationNodeId; - if (ship.State != "ftl") + if (ship.State != ShipState.Ftl) { - if (ship.State != "spooling-ftl") + if (ship.State != ShipState.SpoolingFtl) { ship.ActionTimer = 0f; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "spooling-ftl"; + ship.State = ShipState.SpoolingFtl; if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime)) { return "none"; } - ship.State = "ftl"; + ship.State = ShipState.Ftl; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } @@ -254,7 +262,7 @@ public sealed partial class SimulationEngine 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) + ? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode) : "none"; } @@ -270,7 +278,23 @@ public sealed partial class SimulationEngine ship.SpatialState.CurrentNodeId = targetNode?.Id; ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; ship.SpatialState.DestinationNodeId = targetNode?.Id; - ship.State = "arriving"; + ship.State = ShipState.Arriving; return "arrived"; } + + private static string CompleteSystemEntryArrival(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 = ShipState.Arriving; + return "none"; + } } diff --git a/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs b/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs index 99592ee..390ee9f 100644 --- a/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs +++ b/apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs @@ -55,22 +55,57 @@ public sealed partial class SimulationEngine return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f); } - private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) + private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node) + { + var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f; + return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180f, 0.45f)); + } + + private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds) + { + var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node)); + var orbit = new Vector3( + MathF.Cos(angle) * node.OrbitRadius, + 0f, + MathF.Sin(angle) * node.OrbitRadius); + return RotateAroundX(orbit, node.OrbitInclination); + } + + private static IEnumerable EnumeratePlanetLagrangePoints( + Vector3 planetPosition, + float orbitRadius, + float planetSize, + 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 offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex); 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("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f)))); yield return new LagrangePointPlacement( "L4", - Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); + Add( + planetPosition, + Add( + Scale(radial, offset * MathF.Cos(triangularAngle)), + Scale(tangential, offset * MathF.Sin(triangularAngle))))); yield return new LagrangePointPlacement( "L5", - Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); + Add( + planetPosition, + Add( + Scale(radial, offset * MathF.Cos(triangularAngle)), + Scale(tangential, -offset * MathF.Sin(triangularAngle))))); + } + + private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex) + { + var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f)); + var sizeScale = (planetSize * 1.9f) + 10f; + return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale)); } private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback) @@ -125,9 +160,9 @@ public sealed partial class SimulationEngine } } - private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc) + private void UpdateOrbitalState(SimulationWorld world) { - var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f); + var worldTimeSeconds = (float)world.OrbitalTimeSeconds; var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); foreach (var system in world.Systems) @@ -150,7 +185,7 @@ public sealed partial class SimulationEngine var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds); planetNode.Position = planetPosition; - foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) + foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex)) { var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}"; if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode)) @@ -186,6 +221,20 @@ public sealed partial class SimulationEngine } } + foreach (var node in world.Nodes) + { + if (node.AnchorNodeId is null || !spatialNodesById.TryGetValue(node.AnchorNodeId, out var anchorNode)) + { + continue; + } + + node.Position = Add(anchorNode.Position, ComputeResourceNodeOffset(node, worldTimeSeconds)); + if (spatialNodesById.TryGetValue(node.Id, out var resourceNode)) + { + resourceNode.Position = node.Position; + } + } + foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null)) { var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); diff --git a/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs b/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs index 41f3a3d..25b2dc6 100644 --- a/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs +++ b/apps/backend/Simulation/SimulationEngine.PowerAndInventorySystems.cs @@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class SimulationEngine { + private const float StationEnergyCellToEnergyRatio = 1f; + private static bool HasShipModules(ShipDefinition definition, params string[] modules) => modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); @@ -19,7 +21,7 @@ public sealed partial class SimulationEngine foreach (var station in world.Stations) { var previousEnergy = station.EnergyStored; - GenerateStationEnergy(station, deltaSeconds); + GenerateStationEnergy(station, world, deltaSeconds); if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) { @@ -39,7 +41,7 @@ public sealed partial class SimulationEngine } } - private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) + private static void GenerateStationEnergy(StationRuntime station, SimulationWorld world, float deltaSeconds) { var powerCores = CountModules(station.InstalledModules, "power-core"); var tanks = CountModules(station.InstalledModules, "liquid-tank"); @@ -53,6 +55,32 @@ public sealed partial class SimulationEngine var energyCapacity = powerCores * StationEnergyPerPowerCore; var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); + if (desiredEnergy <= 0.01f) + { + station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); + station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); + return; + } + + var solarGenerated = MathF.Min(desiredEnergy, GetStationSolarGeneration(station, world) * deltaSeconds); + if (solarGenerated > 0.01f) + { + station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + solarGenerated); + desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); + } + + if (desiredEnergy > 0.01f && fuelStored <= 0.01f) + { + var energyCells = GetInventoryAmount(station.Inventory, "energy-cell"); + if (energyCells > 0.01f) + { + var consumedCells = MathF.Min(energyCells, desiredEnergy / StationEnergyCellToEnergyRatio); + RemoveInventory(station.Inventory, "energy-cell", consumedCells); + station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + (consumedCells * StationEnergyCellToEnergyRatio)); + desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); + } + } + if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); @@ -69,6 +97,37 @@ public sealed partial class SimulationEngine station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); } + private static float GetStationFuelCapacity(StationRuntime station) => + CountModules(station.InstalledModules, "liquid-tank") * StationFuelPerTank; + + private static float GetStationEnergyCapacity(StationRuntime station) => + CountModules(station.InstalledModules, "power-core") * StationEnergyPerPowerCore; + + private static float GetStationSolarGeneration(StationRuntime station, SimulationWorld world) => + world.Balance.Energy.StationSolarCharge * (1f + CountModules(station.InstalledModules, "solar-array")); + + private static float GetStationStorageCapacity(StationRuntime station, string storageClass) + { + var baseCapacity = station.Definition.Storage.TryGetValue(storageClass, out var capacity) + ? capacity + : 0f; + + var extraBulkBays = Math.Max(0, CountModules(station.InstalledModules, "bulk-bay") - CountModules(station.Definition.Modules, "bulk-bay")); + var extraLiquidTanks = Math.Max(0, CountModules(station.InstalledModules, "liquid-tank") - CountModules(station.Definition.Modules, "liquid-tank")); + var extraGasTanks = Math.Max(0, CountModules(station.InstalledModules, "gas-tank") - CountModules(station.Definition.Modules, "gas-tank")); + var extraContainerBays = Math.Max(0, CountModules(station.InstalledModules, "container-bay") - CountModules(station.Definition.Modules, "container-bay")); + var moduleBonus = storageClass switch + { + "bulk-solid" => extraBulkBays * 1000f, + "bulk-liquid" => extraLiquidTanks * 500f, + "bulk-gas" => extraGasTanks * 500f, + "container" => extraContainerBays * 800f, + _ => 0f, + }; + + return baseCapacity + moduleBonus; + } + private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var reactors = CountModules(ship.Definition.Modules, "reactor-core"); @@ -166,11 +225,265 @@ public sealed partial class SimulationEngine _ => false, }; + private static bool CanBuildClaimBeacon(ShipRuntime ship) => + string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal); + 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 GetShipAvailableEnergyBudget(ShipRuntime ship) => + ship.EnergyStored + (GetInventoryAmount(ship.Inventory, "fuel") * ShipFuelToEnergyRatio); + + private static float GetShipFuelReserve(ShipRuntime ship, float plannedFuel) + { + var capacity = GetShipFuelCapacity(ship); + var reserveRatio = ship.Definition.CargoItemId == "gas" ? 0.4f : 0.3f; + var reserve = MathF.Max(16f, MathF.Max(capacity * 0.18f, plannedFuel * reserveRatio)); + return MathF.Min(capacity, reserve); + } + + private static float EstimateFuelForEnergyDemand(ShipRuntime ship, float energyDemand) => + MathF.Max(0f, energyDemand - ship.EnergyStored) / ShipFuelToEnergyRatio; + + private static float EstimateTimedEnergyUse(SimulationWorld world, float durationSeconds, float drainPerSecond) => + MathF.Max(0f, durationSeconds) * drainPerSecond; + + private static float EstimateTravelEnergy( + ShipRuntime ship, + SimulationWorld world, + Vector3 fromPosition, + string fromSystemId, + Vector3 toPosition, + string toSystemId) + { + if (!string.Equals(fromSystemId, toSystemId, StringComparison.Ordinal)) + { + var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId); + var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition; + var ftlDistance = fromPosition.DistanceTo(destinationEntryPosition); + var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f); + return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain) + + EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain) + + EstimateInSystemTravelEnergy(ship, world, destinationEntryPosition, toPosition); + } + + return EstimateInSystemTravelEnergy(ship, world, fromPosition, toPosition); + } + + private static float EstimateInSystemTravelEnergy(ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, Vector3 toPosition) + { + var distance = fromPosition.DistanceTo(toPosition); + if (distance <= world.Balance.ArrivalThreshold) + { + return 0f; + } + + if (distance <= 120f) + { + var localDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f); + return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain); + } + + var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + var warpDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f); + return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain) + + EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain); + } + + private static float EstimateDockingEnergy(SimulationWorld world) => + EstimateTimedEnergyUse(world, world.Balance.DockingDuration, world.Balance.Energy.MoveDrain) + + EstimateTimedEnergyUse(world, 6f, world.Balance.Energy.IdleDrain); + + private static float EstimateUndockingEnergy(SimulationWorld world) => + EstimateTimedEnergyUse(world, world.Balance.UndockingDuration, world.Balance.Energy.MoveDrain) + + EstimateTimedEnergyUse(world, 4f, world.Balance.Energy.IdleDrain); + + private static float EstimateExtractionEnergy(ShipRuntime ship, SimulationWorld world) + { + var remainingCargo = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + if (remainingCargo <= 0.01f) + { + return 0f; + } + + var cycles = MathF.Ceiling(remainingCargo / MathF.Max(world.Balance.MiningRate, 0.01f)); + return EstimateTimedEnergyUse(world, cycles * world.Balance.MiningCycleSeconds, world.Balance.Energy.MoveDrain) + + EstimateTimedEnergyUse(world, cycles * 1.5f, world.Balance.Energy.IdleDrain); + } + + private static float EstimateConstructionEnergy(ShipRuntime ship, SimulationWorld world, StationRuntime station) + { + var holdPosition = GetConstructionHoldPosition(station, ship.Id); + var travelEnergy = EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, holdPosition, station.SystemId); + var site = GetConstructionSiteForStation(world, station.Id); + if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site)) + { + if (world.ModuleRecipes.TryGetValue(site.BlueprintId ?? string.Empty, out var siteRecipe)) + { + return travelEnergy + EstimateTimedEnergyUse(world, siteRecipe.Duration, world.Balance.Energy.IdleDrain); + } + + return travelEnergy; + } + + var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); + if (moduleId is not null + && world.ModuleRecipes.TryGetValue(moduleId, out var recipe) + && CanStartModuleConstruction(station, recipe)) + { + return travelEnergy + EstimateTimedEnergyUse(world, recipe.Duration, world.Balance.Energy.IdleDrain); + } + + return travelEnergy; + } + + private static float EstimateResourceHarvestEnergy(ShipRuntime ship, SimulationWorld world) + { + var cargoItemId = ship.Definition.CargoItemId; + if (cargoItemId is null) + { + return 0f; + } + + var requiredModule = cargoItemId == "gas" ? "gas-extractor" : "mining-turret"; + var behavior = ship.DefaultBehavior; + var refinery = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId); + var node = behavior.NodeId is null + ? world.Nodes + .Where(candidate => + (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && + candidate.ItemId == cargoItemId && + candidate.OreRemaining > 0.01f) + .OrderByDescending(candidate => candidate.OreRemaining) + .FirstOrDefault() + : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f); + if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) + { + return 0f; + } + + var currentPosition = ship.Position; + var currentSystemId = ship.SystemId; + var energy = 0f; + var cargoAmount = GetShipCargoAmount(ship); + if (ship.DockedStationId == refinery.Id) + { + currentPosition = GetUndockTargetPosition(refinery, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + currentSystemId = refinery.SystemId; + energy += EstimateUndockingEnergy(world); + } + + if (cargoAmount > 0.01f) + { + energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId); + return energy + EstimateDockingEnergy(world); + } + + var holdPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, holdPosition, node.SystemId); + energy += EstimateExtractionEnergy(ship, world); + energy += EstimateTravelEnergy(ship, world, holdPosition, node.SystemId, refinery.Position, refinery.SystemId); + energy += EstimateDockingEnergy(world); + return energy; + } + + private static float EstimateResourceReturnEnergy(ShipRuntime ship, SimulationWorld world) + { + var cargoItemId = ship.Definition.CargoItemId; + if (cargoItemId is null) + { + return 0f; + } + + var refinery = SelectBestBuyStation(world, ship, cargoItemId, ship.DefaultBehavior.StationId); + if (refinery is null) + { + return 0f; + } + + var currentPosition = ship.Position; + var currentSystemId = ship.SystemId; + return EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId) + + EstimateDockingEnergy(world); + } + + private static float EstimateTransportEnergy(ShipRuntime ship, SimulationWorld world) + { + var cargoItemId = ship.Definition.CargoItemId; + if (cargoItemId is null) + { + return 0f; + } + + var behavior = ship.DefaultBehavior; + var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId); + var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId); + if (source is null && destination is null) + { + return 0f; + } + + var cargoAmount = GetShipCargoAmount(ship); + var currentPosition = ship.Position; + var currentSystemId = ship.SystemId; + if (ship.DockedStationId is not null) + { + var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); + if (dockedStation is not null) + { + currentPosition = GetUndockTargetPosition(dockedStation, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + currentSystemId = dockedStation.SystemId; + } + } + + var targetStation = cargoAmount > 0.01f ? destination : source; + if (targetStation is null) + { + return ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f; + } + + var energy = ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f; + energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, targetStation.Position, targetStation.SystemId); + return energy + EstimateDockingEnergy(world); + } + + private static float EstimateShipMissionEnergyDemand(ShipRuntime ship, SimulationWorld world) => + ship.DefaultBehavior.Kind switch + { + "auto-mine" or "auto-harvest-gas" => EstimateResourceHarvestEnergy(ship, world), + "auto-supply-energy" => EstimateTransportEnergy(ship, world), + "construct-station" when ship.DefaultBehavior.StationId is not null + => world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) is { } station + ? EstimateConstructionEnergy(ship, world, station) + : 0f, + _ when ship.ControllerTask.TargetPosition is { } targetPosition && ship.ControllerTask.TargetSystemId is { } targetSystemId + => EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, targetPosition, targetSystemId), + _ => 0f, + }; + + private static float GetShipRefuelTarget(ShipRuntime ship, SimulationWorld world) + { + var capacity = GetShipFuelCapacity(ship); + var missionFuel = EstimateFuelForEnergyDemand(ship, EstimateShipMissionEnergyDemand(ship, world)); + var reserveFuel = GetShipFuelReserve(ship, missionFuel); + return MathF.Min(capacity, missionFuel + reserveFuel); + } + + internal static bool NeedsRefuel(ShipRuntime ship, SimulationWorld world) => + GetInventoryAmount(ship.Inventory, "fuel") + 0.01f < GetShipRefuelTarget(ship, world); + + internal static bool NeedsEmergencyReturn(ShipRuntime ship, SimulationWorld world) + { + if (ship.DefaultBehavior.Kind is not "auto-mine" and not "auto-harvest-gas") + { + return false; + } + + var returnEnergy = EstimateResourceReturnEnergy(ship, world); + var reserveFuel = GetShipFuelReserve(ship, EstimateFuelForEnergyDemand(ship, returnEnergy)); + var requiredBudget = returnEnergy + (reserveFuel * ShipFuelToEnergyRatio); + return GetShipAvailableEnergyBudget(ship) + 0.01f < requiredBudget; + } private static float ComputeWorkforceRatio(float population, float workforceRequired) { @@ -206,7 +519,8 @@ public sealed partial class SimulationEngine return 0f; } - if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity)) + var capacity = GetStationStorageCapacity(station, storageClass); + if (capacity <= 0.01f) { return 0f; } @@ -232,6 +546,17 @@ public sealed partial class SimulationEngine 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 float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId) + { + if (site.StationId is not null + && world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station) + { + return GetInventoryAmount(station.Inventory, itemId); + } + + return GetInventoryAmount(site.DeliveredItems, itemId); + } + + private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) => + site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value); } diff --git a/apps/backend/Simulation/SimulationEngine.Replication.cs b/apps/backend/Simulation/SimulationEngine.Replication.cs index 07466b7..561fe05 100644 --- a/apps/backend/Simulation/SimulationEngine.Replication.cs +++ b/apps/backend/Simulation/SimulationEngine.Replication.cs @@ -13,6 +13,8 @@ public sealed partial class SimulationEngine world.Seed, sequence, world.TickIntervalMs, + world.OrbitalTimeSeconds, + new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), world.GeneratedAtUtc, world.Systems.Select(system => new SystemSnapshot( system.Definition.Id, @@ -59,11 +61,12 @@ public sealed partial class SimulationEngine node.Id, node.SystemId, node.LocalPosition, + node.AnchorNodeId, node.SourceKind, node.OreRemaining, node.MaxOre, node.ItemId)).ToList(), - world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot( + world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot( station.Id, station.Label, station.Category, @@ -74,8 +77,13 @@ public sealed partial class SimulationEngine station.AnchorNodeId, station.Color, station.DockedShips, + station.DockedShipIds, station.DockingPads, + station.FuelStored, + station.FuelCapacity, station.EnergyStored, + station.EnergyCapacity, + station.CurrentProcesses, station.Inventory, station.FactionId, station.CommanderId, @@ -135,7 +143,7 @@ public sealed partial class SimulationEngine policy.DockingAccessPolicy, policy.ConstructionAccessPolicy, policy.OperationalRangePolicy)).ToList(), - world.Ships.Select(ToShipDelta).Select(ship => new ShipSnapshot( + world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot( ship.Id, ship.Label, ship.Role, @@ -154,12 +162,14 @@ public sealed partial class SimulationEngine ship.CommanderId, ship.PolicySetId, ship.CargoCapacity, + ship.CargoItemId, ship.WorkerPopulation, ship.EnergyStored, ship.Inventory, ship.FactionId, ship.Health, ship.History, + ship.CurrentAction, ship.SpatialState)).ToList(), world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot( faction.Id, @@ -193,7 +203,7 @@ public sealed partial class SimulationEngine foreach (var station in world.Stations) { - station.LastDeltaSignature = BuildStationSignature(station); + station.LastDeltaSignature = BuildStationSignature(world, station); } foreach (var claim in world.Claims) @@ -218,7 +228,7 @@ public sealed partial class SimulationEngine foreach (var ship in world.Ships) { - ship.LastDeltaSignature = BuildShipSignature(ship); + ship.LastDeltaSignature = BuildShipSignature(world, ship); } foreach (var faction in world.Factions) @@ -286,14 +296,14 @@ public sealed partial class SimulationEngine var deltas = new List(); foreach (var station in world.Stations) { - var signature = BuildStationSignature(station); + var signature = BuildStationSignature(world, station); if (signature == station.LastDeltaSignature) { continue; } station.LastDeltaSignature = signature; - deltas.Add(ToStationDelta(station)); + deltas.Add(ToStationDelta(world, station)); } return deltas; @@ -371,19 +381,19 @@ public sealed partial class SimulationEngine return deltas; } - private static IReadOnlyList BuildShipDeltas(SimulationWorld world) + private IReadOnlyList BuildShipDeltas(SimulationWorld world) { var deltas = new List(); foreach (var ship in world.Ships) { - var signature = BuildShipSignature(ship); + var signature = BuildShipSignature(world, ship); if (signature == ship.LastDeltaSignature) { continue; } ship.LastDeltaSignature = signature; - deltas.Add(ToShipDelta(ship)); + deltas.Add(ToShipDelta(world, ship)); } return deltas; @@ -407,7 +417,8 @@ public sealed partial class SimulationEngine return deltas; } - private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}"; + private static string BuildNodeSignature(ResourceNodeRuntime node) => + $"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.AnchorNodeId}|{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}"; @@ -415,8 +426,34 @@ public sealed partial class SimulationEngine 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 BuildStationSignature(SimulationWorld world, StationRuntime station) + { + var processes = ToStationActionProgressSnapshots(world, station); + return string.Join("|", + station.SystemId, + station.NodeId ?? "none", + station.BubbleId ?? "none", + station.AnchorNodeId ?? "none", + station.CommanderId ?? "none", + station.PolicySetId ?? "none", + BuildInventorySignature(station.Inventory), + GetInventoryAmount(station.Inventory, "fuel").ToString("0.###"), + GetStationFuelCapacity(station).ToString("0.###"), + station.EnergyStored.ToString("0.###"), + GetStationEnergyCapacity(station).ToString("0.###"), + string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")), + string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)), + station.DockingPadAssignments.Count.ToString(), + station.Population.ToString("0.###"), + station.PopulationCapacity.ToString("0.###"), + station.WorkforceRequired.ToString("0.###"), + station.WorkforceEffectiveRatio.ToString("0.###"), + string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)), + string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)), + station.ActiveConstruction?.ModuleId ?? "none", + station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0", + string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}"))); + } private static string BuildClaimSignature(ClaimRuntime claim) => $"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; @@ -430,7 +467,7 @@ public sealed partial class SimulationEngine private static string BuildPolicySignature(PolicySetRuntime policy) => $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}"; - private static string BuildShipSignature(ShipRuntime ship) => + private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) => string.Join("|", ship.SystemId, ship.Position.X.ToString("0.###"), @@ -442,10 +479,10 @@ public sealed partial class SimulationEngine ship.TargetPosition.X.ToString("0.###"), ship.TargetPosition.Y.ToString("0.###"), ship.TargetPosition.Z.ToString("0.###"), - ship.State, + ship.State.ToContractValue(), ship.Order?.Kind ?? "none", ship.DefaultBehavior.Kind, - ship.ControllerTask.Kind, + ship.ControllerTask.Kind.ToContractValue(), ship.SpatialState.CurrentNodeId ?? "none", ship.SpatialState.CurrentBubbleId ?? "none", ship.DockedStationId ?? "none", @@ -463,8 +500,14 @@ public sealed partial class SimulationEngine ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", GetShipCargoAmount(ship).ToString("0.###"), GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"), + ship.TrackedActionKey ?? "none", + ship.TrackedActionTotal.ToString("0.###"), + ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site + ? GetRemainingConstructionDelivery(world, site).ToString("0.###") + : "0", ship.EnergyStored.ToString("0.###"), - ship.Health.ToString("0.###")); + ship.Health.ToString("0.###"), + ship.ActionTimer.ToString("0.###")); private static string BuildInventorySignature(IReadOnlyDictionary inventory) => string.Join(",", @@ -480,6 +523,7 @@ public sealed partial class SimulationEngine node.Id, node.SystemId, ToDto(node.Position), + node.AnchorNodeId, node.SourceKind, node.OreRemaining, node.MaxOre, @@ -505,7 +549,7 @@ public sealed partial class SimulationEngine bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); - private static StationDelta ToStationDelta(StationRuntime station) => new( + private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new( station.Id, station.Definition.Label, station.Definition.Category, @@ -516,8 +560,13 @@ public sealed partial class SimulationEngine station.AnchorNodeId, station.Definition.Color, station.DockedShipIds.Count, + station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), GetDockingPadCount(station), + GetInventoryAmount(station.Inventory, "fuel"), + GetStationFuelCapacity(station), station.EnergyStored, + GetStationEnergyCapacity(station), + ToStationActionProgressSnapshots(world, station), ToInventoryEntries(station.Inventory), station.FactionId, station.CommanderId, @@ -529,6 +578,23 @@ public sealed partial class SimulationEngine station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(), station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList()); + private static IReadOnlyList ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) => + GetStationProductionLanes(station) + .Select(laneKey => + { + var recipe = SelectProductionRecipe(world, station, laneKey); + var timer = GetStationProductionTimer(station, laneKey); + return recipe is null || station.EnergyStored <= 0.01f || timer <= 0.01f + ? null + : new StationActionProgressSnapshot( + laneKey, + recipe.Label, + Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f)); + }) + .Where(snapshot => snapshot is not null) + .Cast() + .ToList(); + private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new( claim.Id, claim.FactionId, @@ -582,7 +648,7 @@ public sealed partial class SimulationEngine policy.ConstructionAccessPolicy, policy.OperationalRangePolicy); - private static ShipDelta ToShipDelta(ShipRuntime ship) => new( + private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new( ship.Id, ship.Definition.Label, ship.Definition.Role, @@ -591,24 +657,75 @@ public sealed partial class SimulationEngine ToDto(ship.Position), ToDto(ship.Velocity), ToDto(ship.TargetPosition), - ship.State, + ship.State.ToContractValue(), ship.Order?.Kind, ship.DefaultBehavior.Kind, - ship.ControllerTask.Kind, + ship.ControllerTask.Kind.ToContractValue(), ship.SpatialState.CurrentNodeId, ship.SpatialState.CurrentBubbleId, ship.DockedStationId, ship.CommanderId, ship.PolicySetId, ship.Definition.CargoCapacity, + ship.Definition.CargoItemId, ship.WorkerPopulation, ship.EnergyStored, ToInventoryEntries(ship.Inventory), ship.FactionId, ship.Health, ship.History.ToList(), + ToShipActionProgressSnapshot(world, ship), ToShipSpatialStateSnapshot(ship.SpatialState)); + private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship) + { + var progress = ship.State switch + { + ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)), + ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)), + ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)), + ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)), + ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)), + ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)), + ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)), + ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)), + ShipState.Refueling => CreateShipRemainingActionProgress( + "Refuel", + ship.TrackedActionTotal, + MathF.Max(0f, GetShipRefuelTarget(ship, world) - GetInventoryAmount(ship.Inventory, "fuel"))), + ShipState.Loading => CreateShipRemainingActionProgress( + "Load workers", + ship.TrackedActionTotal, + MathF.Max(0f, ship.TrackedActionTotal - ship.WorkerPopulation)), + ShipState.Unloading => CreateShipRemainingActionProgress( + "Unload workers", + ship.TrackedActionTotal, + ship.WorkerPopulation), + ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null + ? null + : world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site + ? null + : CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)), + _ => null, + }; + + return progress; + } + + private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) => + new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f)); + + private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount) + { + if (totalAmount <= 0.01f) + { + return null; + } + + var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f); + return new ShipActionProgressSnapshot(label, progress); + } + private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => inventory .Where(entry => entry.Value > 0.001f) @@ -647,9 +764,9 @@ public sealed partial class SimulationEngine private static void EmitShipStateEvents( ShipRuntime ship, - string previousState, + ShipState previousState, string previousBehavior, - string previousTask, + ControllerTaskKind previousTask, string controllerEvent, ICollection events) { @@ -657,7 +774,7 @@ public sealed partial class SimulationEngine if (previousState != ship.State) { - events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc)); + events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc)); } if (previousBehavior != ship.DefaultBehavior.Kind) @@ -667,7 +784,7 @@ public sealed partial class SimulationEngine if (previousTask != ship.ControllerTask.Kind) { - events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc)); + events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc)); } if (controllerEvent != "none") diff --git a/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs b/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs index 79eac18..f5d52ee 100644 --- a/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs +++ b/apps/backend/Simulation/SimulationEngine.ResourceAndInfrastructureSystems.cs @@ -65,7 +65,7 @@ public sealed partial class SimulationEngine continue; } - var remaining = MathF.Max(0f, required - GetInventoryAmount(site.DeliveredItems, order.ItemId)); + var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId)); order.RemainingAmount = remaining; order.State = remaining <= 0.01f ? MarketOrderStateKinds.Filled @@ -78,11 +78,6 @@ public sealed partial class SimulationEngine 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) @@ -111,9 +106,35 @@ public sealed partial class SimulationEngine private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) { - foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) + var priorities = GetFactionExpansionPressure(world, station.FactionId) > 0f + ? new (string ModuleId, int TargetCount)[] + { + ("gas-tank", 1), + ("fuel-processor", 1), + ("refinery-stack", 1), + ("container-bay", 1), + ("fabricator-array", 2), + ("component-factory", 1), + ("ship-factory", 1), + ("dock-bay-small", 2), + ("solar-array", 2), + } + : new (string ModuleId, int TargetCount)[] + { + ("gas-tank", 1), + ("fuel-processor", 1), + ("refinery-stack", 1), + ("container-bay", 1), + ("fabricator-array", 2), + ("component-factory", 1), + ("ship-factory", 1), + ("solar-array", 2), + ("dock-bay-small", 2), + }; + + foreach (var (moduleId, targetCount) in priorities) { - if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) + if (CountModules(station.InstalledModules, moduleId) < targetCount && world.ModuleRecipes.ContainsKey(moduleId)) { return moduleId; @@ -133,7 +154,10 @@ public sealed partial class SimulationEngine { order.State = MarketOrderStateKinds.Cancelled; order.RemainingAmount = 0f; + world.MarketOrders.Remove(order); } + + station.MarketOrderIds.Remove(orderId); } site.MarketOrderIds.Clear(); @@ -214,7 +238,7 @@ public sealed partial class SimulationEngine { var padCount = Math.Max(1, GetDockingPadCount(station)); var angle = ((MathF.PI * 2f) / padCount) * padIndex; - var radius = station.Definition.Radius + 14f; + var radius = station.Definition.Radius + 18f; return new Vector3( station.Position.X + (MathF.Cos(angle) * radius), station.Position.Y, @@ -225,7 +249,7 @@ public sealed partial class SimulationEngine { var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); var angle = (hash % 360) * (MathF.PI / 180f); - var radius = station.Definition.Radius + 34f; + var radius = station.Definition.Radius + 24f; return new Vector3( station.Position.X + (MathF.Cos(angle) * radius), station.Position.Y, @@ -259,4 +283,25 @@ public sealed partial class SimulationEngine ship.AssignedDockingPadIndex is int padIndex ? GetDockingPadPosition(station, padIndex) : station.Position; + + private static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + var radius = station.Definition.Radius + 78f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + private static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + return new Vector3( + nodePosition.X + (MathF.Cos(angle) * radius), + nodePosition.Y, + nodePosition.Z + (MathF.Sin(angle) * radius)); + } } diff --git a/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs b/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs index c9ca419..de63b0c 100644 --- a/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs +++ b/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs @@ -14,6 +14,17 @@ public sealed partial class SimulationEngine return true; } + private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total) + { + if (ship.TrackedActionKey == actionKey) + { + return; + } + + ship.TrackedActionKey = actionKey; + ship.TrackedActionTotal = MathF.Max(total, 0.01f); + } + internal static float GetShipCargoAmount(ShipRuntime ship) { var cargoItemId = ship.Definition.CargoItemId; @@ -26,11 +37,20 @@ public sealed partial class SimulationEngine 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.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } + var cargoAmount = GetShipCargoAmount(ship); + if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) + { + ship.ActionTimer = 0f; + ship.State = ShipState.CargoFull; + ship.TargetPosition = ship.Position; + return "cargo-full"; + } + ship.TargetPosition = task.TargetPosition.Value; var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance > task.Threshold) @@ -38,42 +58,47 @@ public sealed partial class SimulationEngine ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "mining-approach"; + ship.State = ShipState.MiningApproach; 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.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "mining"; + ship.State = ShipState.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); + var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); + var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity); mined = MathF.Min(mined, node.OreRemaining); + if (mined <= 0.01f) + { + ship.ActionTimer = 0f; + ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull; + ship.TargetPosition = ship.Position; + return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full"; + } + 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; - } + node.OreRemaining = MathF.Max(0f, node.OreRemaining); return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; } @@ -84,7 +109,7 @@ public sealed partial class SimulationEngine var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); if (station is null || task.TargetPosition is null) { - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -93,7 +118,7 @@ public sealed partial class SimulationEngine if (padIndex is null) { ship.ActionTimer = 0f; - ship.State = "awaiting-dock"; + ship.State = ShipState.AwaitingDock; ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) @@ -113,37 +138,37 @@ public sealed partial class SimulationEngine ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "docking-approach"; + ship.State = ShipState.DockingApproach; 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.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "docking"; + ship.State = ShipState.Docking; if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) { return "none"; } - ship.State = "docked"; + ship.State = ShipState.Docked; ship.DockedStationId = station.Id; station.DockedShipIds.Add(ship.Id); ship.Position = padPosition; @@ -155,7 +180,7 @@ public sealed partial class SimulationEngine { if (ship.DockedStationId is null) { - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -165,15 +190,14 @@ public sealed partial class SimulationEngine { ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) - || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } @@ -181,7 +205,8 @@ public sealed partial class SimulationEngine ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; - ship.State = "transferring"; + ship.State = ShipState.Transferring; + BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship)); 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) @@ -201,11 +226,11 @@ public sealed partial class SimulationEngine return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; } - private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null) { - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -215,15 +240,14 @@ public sealed partial class SimulationEngine { ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } - if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) - || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } @@ -231,8 +255,58 @@ public sealed partial class SimulationEngine 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")); + ship.State = ShipState.Loading; + BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship))); + var cargoItemId = ship.Definition.CargoItemId; + var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + var moved = cargoItemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, cargoItemId)); + if (cargoItemId is not null && moved > 0.01f) + { + RemoveInventory(station.Inventory, cargoItemId, moved); + AddInventory(ship.Inventory, cargoItemId, moved); + } + + return cargoItemId is null + || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f + || GetInventoryAmount(station.Inventory, cargoItemId) <= 0.01f + ? "loaded" + : "none"; + } + + private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var station = ResolveShipSupportStation(ship, world); + if (station is null) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return "none"; + } + + var supportPosition = ResolveShipSupportPosition(ship, station); + if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds); + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) + || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = ShipState.CapacitorStarved; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.TargetPosition = supportPosition; + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = ShipState.Refueling; + var refuelTarget = GetShipRefuelTarget(ship, world); + BeginTrackedAction(ship, "refueling", MathF.Max(0f, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel"))); + var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel")); var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); if (moved > 0.01f) { @@ -240,31 +314,40 @@ public sealed partial class SimulationEngine AddInventory(ship.Inventory, "fuel", moved); } - return !NeedsRefuel(ship) ? "refueled" : "none"; + return !NeedsRefuel(ship, world) ? "refueled" : "none"; } private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { - if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) + var station = ResolveShipSupportStation(ship, world); + if (station is null || ship.DefaultBehavior.ModuleId is null) { - ship.State = "idle"; + ship.State = ShipState.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)) + if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) { ship.AssignedDockingPadIndex = null; - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } + var supportPosition = ResolveShipSupportPosition(ship, station); + if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds); + return "none"; + } + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } @@ -272,22 +355,22 @@ public sealed partial class SimulationEngine if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) { ship.ActionTimer = 0f; - ship.State = "waiting-materials"; - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.State = ShipState.WaitingMaterials; + ship.TargetPosition = supportPosition; return "none"; } if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) { - ship.State = "construction-blocked"; - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.State = ShipState.ConstructionBlocked; + ship.TargetPosition = supportPosition; return "none"; } - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.TargetPosition = supportPosition; ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; - ship.State = "constructing"; + ship.State = ShipState.Constructing; station.ActiveConstruction.ProgressSeconds += deltaSeconds; if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) { @@ -301,34 +384,49 @@ public sealed partial class SimulationEngine private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { - if (ship.DockedStationId is null) + var station = ResolveShipSupportStation(ship, world); + if (station is null) { - ship.State = "idle"; + ship.State = ShipState.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.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } + var supportPosition = ResolveShipSupportPosition(ship, station); + if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds); + return "none"; + } + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.TargetPosition = supportPosition; ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; - ship.State = "delivering-construction"; + ship.State = ShipState.DeliveringConstruction; + BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site)); + + if (site.StationId is not null) + { + return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; + } foreach (var required in site.RequiredItems) { @@ -349,49 +447,58 @@ public sealed partial class SimulationEngine 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(world, site) ? "construction-delivered" : "none"; } - return IsConstructionSiteReady(site) ? "construction-delivered" : "none"; + return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; } private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { - if (ship.DockedStationId is null) + var station = ResolveShipSupportStation(ship, world); + if (station is null) { - ship.State = "idle"; + ship.State = ShipState.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.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } - if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + var supportPosition = ResolveShipSupportPosition(ship, station); + if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) { - ship.State = "waiting-materials"; - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds); + return "none"; + } + + if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + ship.State = ShipState.WaitingMaterials; + ship.TargetPosition = supportPosition; return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.TargetPosition = supportPosition; ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; - ship.State = "constructing"; + ship.State = ShipState.Constructing; site.AssignedConstructorShipIds.Add(ship.Id); site.Progress += deltaSeconds; if (site.Progress < recipe.Duration) @@ -404,22 +511,38 @@ public sealed partial class SimulationEngine return "site-constructed"; } + private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) => + ship.DockedStationId is not null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId) + : ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) + : null; + + private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) => + ship.DockedStationId is not null + ? GetShipDockedPosition(ship, station) + : GetConstructionHoldPosition(station, ship.Id); + + private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => + ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); + private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null || !CanTransportWorkers(ship)) { - ship.State = "blocked"; + ship.State = ShipState.Blocked; return "failed"; } var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); if (station is null || station.Population <= 0.01f) { - ship.State = "idle"; + ship.State = ShipState.Idle; return "none"; } var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation); + var totalTransfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation); transfer = MathF.Min(transfer, 4f * deltaSeconds); if (transfer <= 0.01f) { @@ -428,7 +551,8 @@ public sealed partial class SimulationEngine station.Population = MathF.Max(0f, station.Population - transfer); ship.WorkerPopulation += transfer; - ship.State = "loading"; + ship.State = ShipState.Loading; + BeginTrackedAction(ship, "loading", totalTransfer); return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none"; } @@ -436,18 +560,19 @@ public sealed partial class SimulationEngine { if (ship.DockedStationId is null || !CanTransportWorkers(ship)) { - ship.State = "blocked"; + ship.State = ShipState.Blocked; return "failed"; } var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); if (station is null || ship.WorkerPopulation <= 0.01f) { - ship.State = "idle"; + ship.State = ShipState.Idle; return "none"; } var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population)); + var totalTransfer = transfer; transfer = MathF.Min(transfer, 4f * deltaSeconds); if (transfer <= 0.01f) { @@ -456,7 +581,8 @@ public sealed partial class SimulationEngine ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer); station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer); - ship.State = "unloading"; + ship.State = ShipState.Unloading; + BeginTrackedAction(ship, "unloading", totalTransfer); return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none"; } @@ -465,7 +591,7 @@ public sealed partial class SimulationEngine var task = ship.ControllerTask; if (ship.DockedStationId is null || task.TargetPosition is null) { - ship.State = "idle"; + ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return "none"; } @@ -477,19 +603,19 @@ public sealed partial class SimulationEngine ship.TargetPosition = undockTarget; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { - ship.State = "power-starved"; + ship.State = ShipState.CapacitorStarved; ship.TargetPosition = ship.Position; return "none"; } - ship.State = "undocking"; + ship.State = ShipState.Undocking; if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) { if (station is not null) @@ -516,4 +642,7 @@ public sealed partial class SimulationEngine ship.AssignedDockingPadIndex = null; return "undocked"; } + + private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) => + site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key))); } diff --git a/apps/backend/Simulation/SimulationEngine.ShipControl.cs b/apps/backend/Simulation/SimulationEngine.ShipControl.cs index 53ff569..f84a826 100644 --- a/apps/backend/Simulation/SimulationEngine.ShipControl.cs +++ b/apps/backend/Simulation/SimulationEngine.ShipControl.cs @@ -41,7 +41,7 @@ public sealed partial class SimulationEngine { ship.ControllerTask = new ControllerTaskRuntime { - Kind = commander.ActiveTask.Kind, + Kind = ParseControllerTaskKind(commander.ActiveTask.Kind), Status = commander.ActiveTask.Status, CommanderId = commander.Id, TargetEntityId = commander.ActiveTask.TargetEntityId, @@ -81,8 +81,8 @@ public sealed partial class SimulationEngine 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 ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() }; + commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue(); commander.ActiveTask.Status = ship.ControllerTask.Status; commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId; commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId; @@ -121,7 +121,7 @@ public sealed partial class SimulationEngine { ship.ControllerTask = new ControllerTaskRuntime { - Kind = "travel", + Kind = ControllerTaskKind.Travel, Status = WorkStatus.Active, CommanderId = commander?.Id, TargetSystemId = ship.Order.DestinationSystemId, @@ -144,10 +144,13 @@ public sealed partial class SimulationEngine 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) + .Where(candidate => + (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && + candidate.ItemId == resourceItemId && + candidate.OreRemaining > 0.01f) .OrderByDescending(candidate => candidate.OreRemaining) .FirstOrDefault() - : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId); + : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f); if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) { @@ -157,13 +160,29 @@ public sealed partial class SimulationEngine } behavior.NodeId ??= node.Id; + if (NeedsEmergencyReturn(ship, world) && behavior.Phase is "travel-to-node" or "extract") + { + behavior.Phase = "travel-to-station"; + } + + if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f + && behavior.Phase is "travel-to-node" or "extract") + { + behavior.Phase = "travel-to-station"; + } + if (ship.DockedStationId == refinery.Id) { if (GetShipCargoAmount(ship) > 0.01f) { behavior.Phase = "unload"; } - else if (NeedsRefuel(ship)) + else if (behavior.Phase == "undock") + { + // Keep the post-refuel departure decision stable for the current dock cycle. + behavior.Phase = "undock"; + } + else if (NeedsRefuel(ship, world)) { behavior.Phase = "refuel"; } @@ -172,7 +191,7 @@ public sealed partial class SimulationEngine behavior.Phase = "undock"; } } - else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") + else if (NeedsRefuel(ship, world) && behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract") { behavior.Phase = "travel-to-station"; } @@ -180,19 +199,20 @@ public sealed partial class SimulationEngine switch (behavior.Phase) { case "extract": + var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); ship.ControllerTask = new ControllerTaskRuntime { - Kind = "extract", + Kind = ControllerTaskKind.Extract, TargetEntityId = node.Id, TargetSystemId = node.SystemId, - TargetPosition = node.Position, - Threshold = 14f, + TargetPosition = extractionPosition, + Threshold = 5f, }; break; case "travel-to-station": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "travel", + Kind = ControllerTaskKind.Travel, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, @@ -202,7 +222,7 @@ public sealed partial class SimulationEngine case "dock": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "dock", + Kind = ControllerTaskKind.Dock, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, @@ -212,7 +232,7 @@ public sealed partial class SimulationEngine case "unload": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "unload", + Kind = ControllerTaskKind.Unload, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, @@ -222,7 +242,7 @@ public sealed partial class SimulationEngine case "refuel": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "refuel", + Kind = ControllerTaskKind.Refuel, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, @@ -232,7 +252,7 @@ public sealed partial class SimulationEngine case "undock": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "undock", + Kind = ControllerTaskKind.Undock, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), @@ -242,7 +262,7 @@ public sealed partial class SimulationEngine default: ship.ControllerTask = new ControllerTaskRuntime { - Kind = "travel", + Kind = ControllerTaskKind.Travel, TargetEntityId = node.Id, TargetSystemId = node.SystemId, TargetPosition = node.Position, @@ -278,6 +298,153 @@ public sealed partial class SimulationEngine return bestOrder.Station ?? preferred; } + internal static StationRuntime? SelectBestSellStation(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.Sell && + 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 && GetInventoryAmount(entry.Station!.Inventory, itemId) > 0.01f) + .OrderByDescending(entry => + { + var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; + return entry.Order.Valuation - distancePenalty; + }) + .FirstOrDefault(); + + return bestOrder.Station ?? preferred; + } + + internal void PlanEnergySupply(ShipRuntime ship, SimulationWorld world) + { + var behavior = ship.DefaultBehavior; + var cargoItemId = ship.Definition.CargoItemId; + if (cargoItemId is null) + { + behavior.Kind = "idle"; + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + var cargoAmount = GetShipCargoAmount(ship); + if (cargoAmount > 0.01f) + { + var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId); + if (destination is null) + { + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + behavior.StationId = destination.Id; + switch (behavior.Phase) + { + case "dock": + case "unload": + case "refuel": + case "undock": + ship.ControllerTask = CreateStationSupportTask(world, ship, destination, behavior.Phase); + break; + default: + behavior.Phase = "travel-to-destination"; + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Travel, + TargetEntityId = destination.Id, + TargetSystemId = destination.SystemId, + TargetPosition = destination.Position, + Threshold = 18f, + }; + break; + } + + return; + } + + var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId); + if (source is null) + { + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + behavior.StationId = source.Id; + switch (behavior.Phase) + { + case "dock": + case "load": + case "refuel": + case "undock": + ship.ControllerTask = CreateStationSupportTask(world, ship, source, behavior.Phase); + break; + default: + behavior.Phase = "travel-to-source"; + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Travel, + TargetEntityId = source.Id, + TargetSystemId = source.SystemId, + TargetPosition = source.Position, + Threshold = 18f, + }; + break; + } + } + + private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) => + phase switch + { + "dock" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Dock, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 8f, + }, + "load" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Load, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 8f, + }, + "unload" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Unload, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 8f, + }, + "refuel" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Refuel, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 12f, + }, + "undock" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Undock, + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z), + Threshold = 8f, + }, + _ => CreateIdleTask(world.Balance.ArrivalThreshold), + }; + internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; @@ -298,17 +465,36 @@ public sealed partial class SimulationEngine return; } - if (ship.DockedStationId == station.Id) + if (ship.DockedStationId is not null) { - if (NeedsRefuel(ship)) + var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); + if (dockedStation is not null) + { + dockedStation.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(dockedStation, ship.Id); + } + + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + ship.Position = GetConstructionHoldPosition(station, ship.Id); + ship.TargetPosition = ship.Position; + } + + var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id); + var isAtConstructionHold = ship.SystemId == station.SystemId + && ship.Position.DistanceTo(constructionHoldPosition) <= 10f; + + if (isAtConstructionHold) + { + if (NeedsRefuel(ship, world)) { behavior.Phase = "refuel"; } - else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site)) + else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site)) { behavior.Phase = "deliver-to-site"; } - else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site)) + else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site)) { behavior.Phase = "build-site"; } @@ -325,81 +511,71 @@ public sealed partial class SimulationEngine behavior.Phase = "wait-for-materials"; } } - else if (behavior.Phase is not "travel-to-station" and not "dock") + else if (behavior.Phase != "travel-to-station") { 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", + Kind = ControllerTaskKind.Refuel, TargetEntityId = station.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, + TargetPosition = constructionHoldPosition, + Threshold = 10f, }; break; case "construct-module": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "construct-module", + Kind = ControllerTaskKind.ConstructModule, TargetEntityId = station.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, + TargetPosition = constructionHoldPosition, + Threshold = 10f, }; break; case "deliver-to-site": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "deliver-construction", + Kind = ControllerTaskKind.DeliverConstruction, TargetEntityId = site?.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, + TargetPosition = constructionHoldPosition, + Threshold = 10f, }; break; case "build-site": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "build-construction-site", + Kind = ControllerTaskKind.BuildConstructionSite, TargetEntityId = site?.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 0f, + TargetPosition = constructionHoldPosition, + Threshold = 10f, }; break; case "wait-for-materials": ship.ControllerTask = new ControllerTaskRuntime { - Kind = "idle", + Kind = ControllerTaskKind.Idle, TargetEntityId = station.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, + TargetPosition = constructionHoldPosition, Threshold = 0f, }; break; default: ship.ControllerTask = new ControllerTaskRuntime { - Kind = "travel", + Kind = ControllerTaskKind.Travel, TargetEntityId = station.Id, TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = station.Definition.Radius + 8f, + TargetPosition = constructionHoldPosition, + Threshold = 10f, }; behavior.Phase = "travel-to-station"; break; @@ -412,7 +588,7 @@ public sealed partial class SimulationEngine if (ship.Order is not null && controllerEvent == "arrived") { ship.Order = null; - ship.ControllerTask.Kind = "idle"; + ship.ControllerTask.Kind = ControllerTaskKind.Idle; if (commander is not null) { commander.ActiveOrder = null; @@ -439,16 +615,20 @@ public sealed partial class SimulationEngine } } - private static void TrackHistory(ShipRuntime ship) + private static void TrackHistory(ShipRuntime ship, string controllerEvent) { - var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}"; + var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}"; 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.#}"); + var target = ship.ControllerTask.TargetEntityId + ?? ship.ControllerTask.TargetSystemId + ?? "none"; + var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}"; + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}"); if (ship.History.Count > 18) { ship.History.RemoveAt(0); @@ -458,10 +638,27 @@ public sealed partial class SimulationEngine private static ControllerTaskRuntime CreateIdleTask(float threshold) => new() { - Kind = "idle", + Kind = ControllerTaskKind.Idle, Threshold = threshold, }; + private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch + { + "travel" => ControllerTaskKind.Travel, + "extract" => ControllerTaskKind.Extract, + "dock" => ControllerTaskKind.Dock, + "load" => ControllerTaskKind.Load, + "unload" => ControllerTaskKind.Unload, + "refuel" => ControllerTaskKind.Refuel, + "deliver-construction" => ControllerTaskKind.DeliverConstruction, + "build-construction-site" => ControllerTaskKind.BuildConstructionSite, + "load-workers" => ControllerTaskKind.LoadWorkers, + "unload-workers" => ControllerTaskKind.UnloadWorkers, + "construct-module" => ControllerTaskKind.ConstructModule, + "undock" => ControllerTaskKind.Undock, + _ => ControllerTaskKind.Idle, + }; + private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task) { if (commander is null) @@ -471,7 +668,7 @@ public sealed partial class SimulationEngine commander.ActiveTask = new CommanderTaskRuntime { - Kind = task.Kind, + Kind = task.Kind.ToContractValue(), Status = task.Status, TargetEntityId = task.TargetEntityId, TargetNodeId = task.TargetNodeId, diff --git a/apps/backend/Simulation/SimulationEngine.StationSystems.cs b/apps/backend/Simulation/SimulationEngine.StationSystems.cs index c97dba4..6a612dd 100644 --- a/apps/backend/Simulation/SimulationEngine.StationSystems.cs +++ b/apps/backend/Simulation/SimulationEngine.StationSystems.cs @@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class SimulationEngine { + private const int StrategicControlTargetSystems = 5; + private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) { var factionPopulation = new Dictionary(StringComparer.Ordinal); @@ -62,18 +64,27 @@ public sealed partial class SimulationEngine var desiredOrders = new List(); var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f); + var energyCellReserve = HasStationModules(station, "power-core", "liquid-tank") ? MathF.Max(20f, CountModules(station.InstalledModules, "power-core") * 40f) : 0f; 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; + var shipPartsReserve = HasStationModules(station, "fabricator-array") + && !HasStationModules(station, "component-factory", "ship-factory") + && FactionNeedsMoreWarships(world, station.FactionId) + ? 90f + : 0f; AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f); + AddDemandOrder(desiredOrders, station, "energy-cell", energyCellReserve, valuationBase: 1.1f); 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); + AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f); AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f); + AddSupplyOrder(desiredOrders, station, "energy-cell", energyCellReserve * 1.8f, reserveFloor: energyCellReserve, valuationBase: 0.82f); 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); @@ -84,56 +95,160 @@ public sealed partial class SimulationEngine 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) + foreach (var laneKey in GetStationProductionLanes(station)) { - faction.GoodsProduced += produced; + var recipe = SelectProductionRecipe(world, station, laneKey); + if (recipe is null || station.EnergyStored <= 0.01f) + { + station.ProductionLaneTimers[laneKey] = 0f; + continue; + } + + var throughput = GetStationProductionThroughput(station, recipe); + if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds * throughput)) + { + station.ProductionLaneTimers[laneKey] = 0f; + continue; + } + + var produced = 0f; + station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput); + while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe)) + { + station.ProductionLaneTimers[laneKey] -= recipe.Duration; + foreach (var input in recipe.Inputs) + { + RemoveInventory(station.Inventory, input.ItemId, input.Amount); + } + + if (recipe.ShipOutputId is not null) + { + produced += CompleteShipRecipe(world, station, recipe, events); + continue; + } + + foreach (var output in recipe.Outputs) + { + produced += TryAddStationInventory(world, station, output.ItemId, output.Amount); + } + } + + if (produced <= 0.01f) + { + continue; + } + + events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow)); + if (faction is not null) + { + faction.GoodsProduced += produced; + } } } - private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) => + private static IEnumerable GetStationProductionLanes(StationRuntime station) + { + if (CountModules(station.InstalledModules, "refinery-stack") > 0) + { + yield return "refinery"; + } + + if (CountModules(station.InstalledModules, "fuel-processor") > 0) + { + yield return "fuel"; + } + + if (CountModules(station.InstalledModules, "fabricator-array") > 0) + { + yield return "fabrication"; + } + + if (CountModules(station.InstalledModules, "component-factory") > 0) + { + yield return "components"; + } + + if (CountModules(station.InstalledModules, "ship-factory") > 0) + { + yield return "shipyard"; + } + } + + private static float GetStationProductionTimer(StationRuntime station, string laneKey) => + station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f; + + private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) => world.Recipes.Values - .Where(recipe => RecipeAppliesToStation(station, recipe)) - .OrderByDescending(recipe => recipe.Priority) + .Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal)) + .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); + private static string? GetStationProductionLaneKey(RecipeDefinition recipe) + { + if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal)) + { + return "fuel"; + } + + if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal)) + { + return "refinery"; + } + + if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal)) + { + return "fabrication"; + } + + if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal)) + { + return "components"; + } + + if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal)) + { + return "shipyard"; + } + + return null; + } + + private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + var priority = (float)recipe.Priority; + var producesFuel = recipe.Outputs.Any(output => string.Equals(output.ItemId, "fuel", StringComparison.Ordinal)); + if (producesFuel) + { + var fuelCapacity = MathF.Max(GetStationFuelCapacity(station), 1f); + var fuelRatio = GetInventoryAmount(station.Inventory, "fuel") / fuelCapacity; + priority += (1f - Math.Clamp(fuelRatio, 0f, 1f)) * 200f; + } + + var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); + var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f; + priority += recipe.Id switch + { + "ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory") + ? -140f * MathF.Max(expansionPressure, fleetPressure) + : 280f * MathF.Max(expansionPressure, fleetPressure), + "hull-fabrication" => 180f * expansionPressure, + "equipment-assembly" => 170f * expansionPressure, + "gun-assembly" => 160f * expansionPressure, + "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" + => 220f * MathF.Max(expansionPressure, fleetPressure), + "frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure), + "destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure), + "cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure), + "ammo-fabrication" => -80f * expansionPressure, + "trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly" + => -120f * expansionPressure, + _ => 0f, + }; + + return priority; + } + private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) { var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal) @@ -144,6 +259,21 @@ public sealed partial class SimulationEngine private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) { + if (recipe.ShipOutputId is not null) + { + if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition) + || !CanLaunchShipFromStation(station)) + { + return false; + } + + if (!string.Equals(shipDefinition.Role, "military", StringComparison.Ordinal) + || !FactionNeedsMoreWarships(world, station.FactionId)) + { + return false; + } + } + if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount)) { return false; @@ -165,7 +295,8 @@ public sealed partial class SimulationEngine return false; } - if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity)) + var capacity = GetStationStorageCapacity(station, itemDefinition.Storage); + if (capacity <= 0.01f) { return false; } @@ -259,5 +390,151 @@ public sealed partial class SimulationEngine private static bool CanProcessFuel(StationRuntime station) => HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); + private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection events) + { + if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition)) + { + return 0f; + } + + var spawnPosition = new Vector3(station.Position.X + station.Definition.Radius + 32f, station.Position.Y, station.Position.Z); + var ship = new ShipRuntime + { + Id = $"ship-{world.Ships.Count + 1}", + SystemId = station.SystemId, + Definition = definition, + FactionId = station.FactionId, + Position = spawnPosition, + TargetPosition = spawnPosition, + SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), + DefaultBehavior = CreateSpawnedShipBehavior(definition, station), + ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold), + Health = definition.MaxHealth, + }; + + ship.Inventory["fuel"] = 120f; + world.Ships.Add(ship); + if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) + { + faction.ShipsBuilt += 1; + } + + events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Definition.Label} launched {definition.Label}.", DateTimeOffset.UtcNow)); + return 1f; + } + + private static bool CanLaunchShipFromStation(StationRuntime station) => + HasStationModules(station, "power-core", "ship-factory", "container-bay", "dock-bay-small"); + + private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId) + { + var militaryShipCount = world.Ships.Count(ship => + ship.FactionId == factionId + && string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal)); + var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); + var controlledSystems = GetFactionControlledSystemsCount(world, factionId); + var expansionDeficit = Math.Max(0, targetSystems - controlledSystems); + var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3)); + return militaryShipCount < targetWarships; + } + + private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) + { + return world.Claims + .Where(claim => claim.State != ClaimStateKinds.Destroyed) + .Select(claim => claim.SystemId) + .Distinct(StringComparer.Ordinal) + .Count(systemId => FactionControlsSystem(world, factionId, systemId)); + } + + private static float GetFactionExpansionPressure(SimulationWorld world, string factionId) + { + var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); + var controlledSystems = GetFactionControlledSystemsCount(world, factionId); + var deficit = Math.Max(0, targetSystems - controlledSystems); + return Math.Clamp(deficit / (float)targetSystems, 0f, 1f); + } + + private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) + { + var buildableLocations = world.Claims + .Where(claim => + claim.SystemId == systemId && + claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active) + .ToList(); + if (buildableLocations.Count == 0) + { + return false; + } + + var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId); + return ownedLocations > (buildableLocations.Count / 2f); + } + + private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new() + { + CurrentSystemId = station.SystemId, + SpaceLayer = SpaceLayerKinds.LocalSpace, + CurrentNodeId = station.NodeId, + CurrentBubbleId = station.BubbleId, + LocalPosition = position, + SystemPosition = position, + MovementRegime = MovementRegimeKinds.LocalFlight, + }; + + private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station) + { + if (!string.Equals(definition.Role, "military", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "idle", + }; + } + + var patrolRadius = station.Definition.Radius + 90f; + return new DefaultBehaviorRuntime + { + Kind = "patrol", + PatrolPoints = + [ + new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), + new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius), + new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z), + new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius), + ], + }; + } + + private static float GetStationProductionThroughput(StationRuntime station, RecipeDefinition recipe) + { + if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal)) + { + return Math.Max(1, CountModules(station.InstalledModules, "refinery-stack")); + } + + if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal)) + { + return Math.Max(1, CountModules(station.InstalledModules, "fuel-processor")); + } + + if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal)) + { + return Math.Max(1, CountModules(station.InstalledModules, "fabricator-array")); + } + + if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal)) + { + return Math.Max(1, CountModules(station.InstalledModules, "component-factory")); + } + + if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal)) + { + return Math.Max(1, CountModules(station.InstalledModules, "ship-factory")); + } + + return 1f; + } + 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 a4c5a9e..35a74dc 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -4,6 +4,7 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class SimulationEngine { + private readonly OrbitalSimulationOptions _orbitalSimulation; private const float ShipFuelToEnergyRatio = 12f; private const float StationFuelToEnergyRatio = 18f; private const float CapacitorEnergyPerModule = 120f; @@ -16,7 +17,7 @@ public sealed partial class SimulationEngine 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) => engine.UpdateOrbitalState(world)), 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)), @@ -29,10 +30,16 @@ public sealed partial class SimulationEngine new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)), ]; + public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null) + { + _orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions(); + } + public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { var events = new List(); var nowUtc = DateTimeOffset.UtcNow; + world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; foreach (var step in _worldUpdatePipeline) { @@ -54,7 +61,7 @@ public sealed partial class SimulationEngine var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); AdvanceControlState(ship, world, controllerEvent); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); - TrackHistory(ship); + TrackHistory(ship, controllerEvent); EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } @@ -65,6 +72,8 @@ public sealed partial class SimulationEngine return new WorldDelta( sequence, world.TickIntervalMs, + world.OrbitalTimeSeconds, + new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond), world.GeneratedAtUtc, false, events, diff --git a/apps/backend/Simulation/WorldGenerationOptions.cs b/apps/backend/Simulation/WorldGenerationOptions.cs new file mode 100644 index 0000000..2d79c5b --- /dev/null +++ b/apps/backend/Simulation/WorldGenerationOptions.cs @@ -0,0 +1,8 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public sealed class WorldGenerationOptions +{ + public int TargetSystemCount { get; init; } = 160; + + public bool IncludeSolSystem { get; init; } = true; +} diff --git a/apps/backend/Simulation/WorldService.cs b/apps/backend/Simulation/WorldService.cs index 75880de..78e2cf4 100644 --- a/apps/backend/Simulation/WorldService.cs +++ b/apps/backend/Simulation/WorldService.cs @@ -1,18 +1,23 @@ using System.Threading.Channels; +using Microsoft.Extensions.Options; using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; -public sealed class WorldService(IWebHostEnvironment environment) +public sealed class WorldService( + IWebHostEnvironment environment, + IOptions worldGenerationOptions, + IOptions orbitalSimulationOptions) { private const int DeltaHistoryLimit = 256; private readonly object _sync = new(); - private readonly ScenarioLoader _loader = new(environment.ContentRootPath); - private readonly SimulationEngine _engine = new(); + private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); + private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value); + private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value); private readonly Dictionary _subscribers = []; private readonly Queue _history = []; - private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load(); + private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); private long _sequence; public WorldSnapshot GetSnapshot() @@ -98,6 +103,8 @@ public sealed class WorldService(IWebHostEnvironment environment) var resetDelta = new WorldDelta( _sequence, _world.TickIntervalMs, + _world.OrbitalTimeSeconds, + _orbitalSimulation, DateTimeOffset.UtcNow, true, [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")], diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index 0c208ae..ee8d4d7 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -4,5 +4,12 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "WorldGeneration": { + "TargetSystemCount": 1, + "IncludeSolSystem": true + }, + "OrbitalSimulation": { + "SimulatedSecondsPerRealSecond": 0 } } diff --git a/apps/backend/appsettings.json b/apps/backend/appsettings.json index 10f68b8..76dbf9c 100644 --- a/apps/backend/appsettings.json +++ b/apps/backend/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, + "WorldGeneration": { + "TargetSystemCount": 160, + "IncludeSolSystem": true + }, + "OrbitalSimulation": { + "SimulatedSecondsPerRealSecond": 0 + }, "AllowedHosts": "*" } diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index 192ad96..afad471 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -55,6 +55,7 @@ import { ViewerNavigationController } from "./viewerNavigationController"; import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerPresentationController } from "./viewerPresentationController"; import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory"; +import type { SceneNode } from "./viewerScenePrimitives"; import type { FactionSnapshot, ShipSnapshot } from "./contracts"; import type { BubbleVisual, @@ -66,6 +67,7 @@ import type { MoonVisual, NetworkStats, NodeVisual, + OrbitLineVisual, OrbitalAnchor, PerformanceStats, PlanetVisual, @@ -101,6 +103,7 @@ export class ViewerAppController { private readonly constructionSiteGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group(); private readonly ambienceGroup = new THREE.Group(); + private readonly gamePanelEl: HTMLDivElement; private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; private readonly nodeVisuals = new Map(); @@ -113,15 +116,20 @@ export class ViewerAppController { private readonly systemVisuals = new Map(); private readonly systemSummaryVisuals = new Map(); private readonly planetVisuals: PlanetVisual[] = []; - private readonly orbitLines: THREE.Object3D[] = []; + private readonly orbitLines: OrbitLineVisual[] = []; private readonly statusEl: HTMLDivElement; + private readonly gameSummaryEl: HTMLSpanElement; 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 networkSectionEl: HTMLDivElement; + private readonly networkSummaryEl: HTMLSpanElement; private readonly networkPanelEl: HTMLDivElement; + private readonly performanceSectionEl: HTMLDivElement; + private readonly performanceSummaryEl: HTMLSpanElement; private readonly performancePanelEl: HTMLDivElement; private readonly errorEl: HTMLDivElement; private readonly historyLayerEl: HTMLDivElement; @@ -179,15 +187,32 @@ export class ViewerAppController { const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); keyLight.position.set(1000, 1200, 800); this.scene.add(keyLight); + this.scene.add( + this.ambienceGroup, + this.systemGroup, + this.spatialNodeGroup, + this.bubbleGroup, + this.nodeGroup, + this.stationGroup, + this.claimGroup, + this.constructionSiteGroup, + this.shipGroup, + ); const hud = createViewerHud(document); + this.gamePanelEl = hud.gamePanelEl; this.statusEl = hud.statusEl; + this.gameSummaryEl = hud.gameSummaryEl; + this.networkSectionEl = hud.networkSectionEl; 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.networkSummaryEl = hud.networkSummaryEl; this.networkPanelEl = hud.networkPanelEl; + this.performanceSectionEl = hud.performanceSectionEl; + this.performanceSummaryEl = hud.performanceSummaryEl; this.performancePanelEl = hud.performancePanelEl; this.errorEl = hud.errorEl; this.historyLayerEl = hud.historyLayerEl; @@ -200,13 +225,31 @@ export class ViewerAppController { worldLifecycle: this.worldLifecycle, interactionController: this.interactionController, } = createViewerControllers(this)); + this.presentationController.initializeAmbience(); this.container.append(this.renderer.domElement, hud.root); + this.initializePanelToggles(); wireViewerEvents(this); this.onResize(); this.updateCamera(0); } + private initializePanelToggles() { + for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) { + const toggle = panel.querySelector(".panel-toggle"); + if (!(toggle instanceof HTMLButtonElement)) { + continue; + } + + toggle.addEventListener("click", () => { + const collapsed = panel.classList.toggle("is-collapsed"); + toggle.textContent = collapsed ? "+" : "-"; + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`); + }); + } + } + async start() { await this.worldLifecycle.bootstrapWorld(); this.renderer.setAnimationLoop(() => this.render()); @@ -308,8 +351,8 @@ export class ViewerAppController { } private registerPresentation( - detail: THREE.Object3D, - icon: THREE.Sprite, + detail: SceneNode, + icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse = false, systemId?: string, @@ -344,7 +387,7 @@ export class ViewerAppController { }); }; - private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { + private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { setShellReticleOpacity(sprite, opacity); } diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index ae70b51..a670ccf 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -4,6 +4,7 @@ export type { WorldDelta, SimulationEventRecord, ObserverScope, + OrbitalSimulationSnapshot, } from "./contractsWorld"; export type { SystemSnapshot, diff --git a/apps/viewer/src/contractsCelestial.ts b/apps/viewer/src/contractsCelestial.ts index b1b2771..c1e23b1 100644 --- a/apps/viewer/src/contractsCelestial.ts +++ b/apps/viewer/src/contractsCelestial.ts @@ -32,6 +32,7 @@ export interface ResourceNodeSnapshot { id: string; systemId: string; localPosition: Vector3Dto; + anchorNodeId?: string | null; sourceKind: string; oreRemaining: number; maxOre: number; diff --git a/apps/viewer/src/contractsInfrastructure.ts b/apps/viewer/src/contractsInfrastructure.ts index 3977cbe..7c05d4d 100644 --- a/apps/viewer/src/contractsInfrastructure.ts +++ b/apps/viewer/src/contractsInfrastructure.ts @@ -1,5 +1,11 @@ import type { InventoryEntry, Vector3Dto } from "./contractsCommon"; +export interface StationActionProgressSnapshot { + lane: string; + label: string; + progress: number; +} + export interface StationSnapshot { id: string; label: string; @@ -11,8 +17,13 @@ export interface StationSnapshot { anchorNodeId?: string | null; color: string; dockedShips: number; + dockedShipIds: string[]; dockingPads: number; + fuelStored: number; + fuelCapacity: number; energyStored: number; + energyCapacity: number; + currentProcesses: StationActionProgressSnapshot[]; inventory: InventoryEntry[]; factionId: string; commanderId?: string | null; diff --git a/apps/viewer/src/contractsShips.ts b/apps/viewer/src/contractsShips.ts index d7dfce4..054ae87 100644 --- a/apps/viewer/src/contractsShips.ts +++ b/apps/viewer/src/contractsShips.ts @@ -19,17 +19,24 @@ export interface ShipSnapshot { commanderId?: string | null; policySetId?: string | null; cargoCapacity: number; + cargoItemId?: string | null; workerPopulation: number; energyStored: number; inventory: InventoryEntry[]; factionId: string; health: number; history: string[]; + currentAction?: ShipActionProgressSnapshot | null; spatialState: ShipSpatialStateSnapshot; } export interface ShipDelta extends ShipSnapshot {} +export interface ShipActionProgressSnapshot { + label: string; + progress: number; +} + export interface ShipSpatialStateSnapshot { spaceLayer: string; currentSystemId: string; diff --git a/apps/viewer/src/contractsWorld.ts b/apps/viewer/src/contractsWorld.ts index 273f09f..f7cc678 100644 --- a/apps/viewer/src/contractsWorld.ts +++ b/apps/viewer/src/contractsWorld.ts @@ -33,6 +33,8 @@ export interface WorldSnapshot { seed: number; sequence: number; tickIntervalMs: number; + orbitalTimeSeconds: number; + orbitalSimulation: OrbitalSimulationSnapshot; generatedAtUtc: string; systems: SystemSnapshot[]; spatialNodes: SpatialNodeSnapshot[]; @@ -50,6 +52,8 @@ export interface WorldSnapshot { export interface WorldDelta { sequence: number; tickIntervalMs: number; + orbitalTimeSeconds: number; + orbitalSimulation: OrbitalSimulationSnapshot; generatedAtUtc: string; requiresSnapshotRefresh: boolean; events: SimulationEventRecord[]; @@ -83,3 +87,7 @@ export interface ObserverScope { systemId?: string | null; bubbleId?: string | null; } + +export interface OrbitalSimulationSnapshot { + simulatedSecondsPerRealSecond: number; +} diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index 0ab761d..884519c 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -96,7 +96,7 @@ canvas { .topbar { border-radius: 22px; - padding: 18px 20px; + padding: 14px 16px; pointer-events: auto; } @@ -124,8 +124,48 @@ canvas { .topbar h2 { color: var(--accent); letter-spacing: 0.16em; - font-size: 0.72rem; + font-size: 0.64rem; text-transform: uppercase; + line-height: 1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-heading-meta { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.panel-summary { + display: none; + color: var(--muted); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.72rem; + line-height: 1; + text-align: right; + white-space: nowrap; +} + +.panel-toggle { + border: 1px solid rgba(127, 214, 255, 0.2); + background: rgba(127, 214, 255, 0.08); + color: var(--text); + border-radius: 999px; + width: 28px; + height: 28px; + cursor: pointer; + font: inherit; +} + +.panel-toggle:hover { + background: rgba(127, 214, 255, 0.16); } .topbar-body { @@ -139,7 +179,7 @@ canvas { .info-panel { border-radius: 24px; - padding: 18px; + padding: 16px; color: var(--text); pointer-events: auto; overflow: auto; @@ -147,7 +187,7 @@ canvas { .network-panel { border-radius: 24px; - padding: 18px; + padding: 14px 16px; color: var(--text); pointer-events: auto; } @@ -155,7 +195,7 @@ canvas { .performance-panel { width: min(360px, calc(100vw - 40px)); border-radius: 24px; - padding: 18px; + padding: 14px 16px; color: var(--text); pointer-events: auto; } @@ -172,7 +212,8 @@ canvas { margin: 0; color: var(--accent); letter-spacing: 0.16em; - font-size: 0.72rem; + font-size: 0.64rem; + line-height: 1; text-transform: uppercase; } @@ -186,6 +227,20 @@ canvas { white-space: pre-wrap; } +.collapsible-panel.is-collapsed .topbar-body, +.collapsible-panel.is-collapsed .network-body, +.collapsible-panel.is-collapsed .performance-body { + display: none; +} + +.collapsible-panel.is-collapsed .panel-summary { + display: inline-block; +} + +.collapsible-panel.is-collapsed { + padding-bottom: 12px; +} + .detail-title { margin-top: 12px; font-size: 1.05rem; @@ -208,6 +263,40 @@ canvas { margin: 0 0 12px; } +.detail-progress, +.ship-action-progress { + margin: 0 0 12px; +} + +.detail-progress-label, +.ship-action-progress-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; + color: var(--muted); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.72rem; + line-height: 1; +} + +.detail-progress-track, +.ship-action-progress-track { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(127, 214, 255, 0.12); + border: 1px solid rgba(127, 214, 255, 0.14); +} + +.detail-progress-fill, +.ship-action-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9)); +} + .history { font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-size: 0.78rem; @@ -329,7 +418,7 @@ canvas { left: 0; right: 0; bottom: 0; - width: 100vw; + width: 50vw; min-height: 128px; border-radius: 0; padding: 0; @@ -412,12 +501,16 @@ canvas { font-size: 0.72rem; } -.ship-card-header + p { +.ship-card-header+p { font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; } +.ship-action-progress { + margin-top: 2px; +} + .ship-card-ai { margin-top: 2px; padding-top: 6px; @@ -495,7 +588,7 @@ canvas { left: 0; right: 0; bottom: 0; - width: 100vw; + width: 50vw; min-height: 120px; } diff --git a/apps/viewer/src/viewerCamera.ts b/apps/viewer/src/viewerCamera.ts index 3ebfbca..a19d74b 100644 --- a/apps/viewer/src/viewerCamera.ts +++ b/apps/viewer/src/viewerCamera.ts @@ -7,6 +7,7 @@ import type { ClaimVisual, ConstructionSiteVisual, NodeVisual, + PlanetVisual, Selectable, ShipVisual, SpatialNodeVisual, @@ -19,7 +20,7 @@ interface ResolveSelectionPositionParams { selection: Selectable; worldTimeSyncMs: number; nodeVisuals: Map; - planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; + planetVisuals: PlanetVisual[]; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; @@ -49,7 +50,7 @@ interface SeedSystemFocusParams { systemFocusLocal: THREE.Vector3; worldTimeSyncMs: number; nodeVisuals: Map; - planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; + planetVisuals: PlanetVisual[]; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; @@ -217,9 +218,7 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams) return undefined; } - const visual = planetVisuals.find((candidate) => - candidate.systemId === selection.systemId && candidate.planet === planet); - return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs)); + return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs)); } const system = world.systems.get(selection.id); @@ -240,8 +239,14 @@ export function focusOnSelection(params: FocusOnSelectionParams) { return; } + if (selection.kind === "system") { + galaxyFocus.copy(nextFocus); + systemFocusLocal.set(0, 0, 0); + return; + } + const selectionSystemId = resolveSelectableSystemId(world, selection); - if (selectionSystemId && selection.kind !== "system" && world) { + if (selectionSystemId && world) { const system = world.systems.get(selectionSystemId); if (system) { galaxyFocus.copy(toThreeVector(system.galaxyPosition)); @@ -282,6 +287,11 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) { const selected = selectedItems[0]; if (selected && resolveSelectableSystemId(world, selected) === systemId) { + if (selected.kind === "system") { + systemFocusLocal.set(0, 0, 0); + return; + } + const selectedPosition = resolveSelectionPosition({ world, selection: selected, diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index 6bef273..e59c9db 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -9,7 +9,8 @@ import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; export function createViewerControllers(host: any) { const sceneDataController = new ViewerSceneDataController({ documentRef: document, - getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc, + getWorldOrbitalTimeSeconds: () => host.world?.orbitalTimeSeconds, + getOrbitalSimulationSpeed: () => host.world?.orbitalSimulation.simulatedSecondsPerRealSecond ?? 0, getWorldSeed: () => host.world?.seed ?? 1, getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldPresentationContext: () => host.createWorldPresentationContext(), @@ -77,6 +78,9 @@ export function createViewerControllers(host: any) { scene: host.scene, camera: host.camera, ambienceGroup: host.ambienceGroup, + gameSummaryEl: host.gameSummaryEl, + networkSummaryEl: host.networkSummaryEl, + performanceSummaryEl: host.performanceSummaryEl, statusEl: host.statusEl, networkPanelEl: host.networkPanelEl, performancePanelEl: host.performancePanelEl, @@ -90,6 +94,7 @@ export function createViewerControllers(host: any) { getCameraMode: () => host.cameraMode, getCameraTargetShipId: () => host.cameraTargetShipId, getZoomLevel: () => host.zoomLevel, + getSelectedItems: () => host.selectedItems, getWorldTimeSyncMs: () => host.worldTimeSyncMs, getCurrentDistance: () => host.currentDistance, systemFocusLocal: host.systemFocusLocal, diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts index 8982a40..56f0b5d 100644 --- a/apps/viewer/src/viewerControls.ts +++ b/apps/viewer/src/viewerControls.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants"; +import { rawObject } from "./viewerScenePrimitives"; import type { CameraMode, Selectable, @@ -166,14 +167,15 @@ export function updateFollowCamera(params: { export function updateSystemDetailVisibility(systemVisuals: Map, activeSystemId?: string) { for (const [systemId, visual] of systemVisuals.entries()) { - visual.detailGroup.visible = systemId === activeSystemId; + visual.detailGroup.setVisible(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 setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) { + sprite.setVisible(opacity > 0.02); + const material = (rawObject(sprite) as THREE.Sprite).material; + material.opacity = opacity; + material.needsUpdate = true; } export function zoomFromWheel(desiredDistance: number, deltaY: number) { @@ -203,8 +205,17 @@ export function applyKeyboardControl(params: { desiredDistance = ZOOM_DISTANCE.system; } else if (key === "3") { desiredDistance = ZOOM_DISTANCE.universe; + } else if (key === "=") { + desiredDistance = desiredDistance <= ZOOM_DISTANCE.system + ? ZOOM_DISTANCE.local + : ZOOM_DISTANCE.system; + } else if (key === "-") { + desiredDistance = desiredDistance >= ZOOM_DISTANCE.system + ? ZOOM_DISTANCE.universe + : ZOOM_DISTANCE.system; + } else if (key === "/") { + desiredDistance = ZOOM_DISTANCE.system; } return { cameraMode, desiredDistance }; } - diff --git a/apps/viewer/src/viewerFactionStrip.ts b/apps/viewer/src/viewerFactionStrip.ts index d08023e..7bed5db 100644 --- a/apps/viewer/src/viewerFactionStrip.ts +++ b/apps/viewer/src/viewerFactionStrip.ts @@ -1,22 +1,38 @@ import { inventoryAmount } from "./viewerMath"; -import type { CameraMode, Selectable, WorldState } from "./viewerTypes"; +import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection"; +import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes"; export function renderFactionStrip( world: WorldState | undefined, selectedItems: Selectable[], cameraMode: CameraMode, cameraTargetShipId?: string, + zoomLevel?: ZoomLevel, + activeSystemId?: string, ) { if (!world) { return ""; } const ships = [...world.ships.values()] + .filter((ship) => { + if (zoomLevel === "universe" || !activeSystemId) { + return true; + } + + return ship.systemId === activeSystemId; + }) .sort((left, right) => left.label.localeCompare(right.label)); return ships .map((ship) => { - const fuel = inventoryAmount(ship.inventory, "gas"); + const fuel = inventoryAmount(ship.inventory, "fuel"); + const cargo = ship.cargoItemId + ? inventoryAmount(ship.inventory, ship.cargoItemId) + : 0; + const shipLocation = describeShipLocation(world, ship); + const shipState = describeShipState(world, ship); + const shipAction = describeShipCurrentAction(ship); const isSelected = selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id; @@ -37,9 +53,20 @@ export function renderFactionStrip( >🕔 -

${ship.systemId}

-

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

-

State ${ship.state}

+

${shipLocation.system}${shipLocation.local ? `
${shipLocation.local}` : ""}

+

Fuel ${fuel.toFixed(1)} · Cap ${ship.energyStored.toFixed(1)}${ship.cargoCapacity > 0 ? ` · Cargo ${cargo.toFixed(0)}` : ""}

+

State ${shipState}

+ ${shipAction ? ` +
+
+ ${shipAction.label} + ${Math.round(shipAction.progress * 100)}% +
+
+
+
+
+ ` : ""}

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

Behavior ${ship.defaultBehaviorKind}

diff --git a/apps/viewer/src/viewerHud.ts b/apps/viewer/src/viewerHud.ts index 3cd450b..ed1b5e4 100644 --- a/apps/viewer/src/viewerHud.ts +++ b/apps/viewer/src/viewerHud.ts @@ -1,13 +1,19 @@ export interface ViewerHudElements { root: HTMLDivElement; + gamePanelEl: HTMLDivElement; statusEl: HTMLDivElement; + gameSummaryEl: HTMLSpanElement; + networkSectionEl: HTMLDivElement; systemPanelEl: HTMLDivElement; systemTitleEl: HTMLHeadingElement; systemBodyEl: HTMLDivElement; detailTitleEl: HTMLHeadingElement; detailBodyEl: HTMLDivElement; factionStripEl: HTMLDivElement; + networkSummaryEl: HTMLSpanElement; networkPanelEl: HTMLDivElement; + performanceSectionEl: HTMLDivElement; + performanceSummaryEl: HTMLSpanElement; performancePanelEl: HTMLDivElement; errorEl: HTMLDivElement; historyLayerEl: HTMLDivElement; @@ -20,16 +26,34 @@ export function createViewerHud(documentRef: Document): ViewerHudElements { root.className = "viewer-shell"; root.innerHTML = `
-
-

Game

+ -
@@ -54,14 +78,20 @@ export function createViewerHud(documentRef: Document): ViewerHudElements { return { root, + gamePanelEl: root.querySelector(".topbar") as HTMLDivElement, statusEl: root.querySelector(".topbar-body") as HTMLDivElement, + gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement, + networkSectionEl: root.querySelector(".network-panel") 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, + networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement, networkPanelEl: root.querySelector(".network-body") as HTMLDivElement, + performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement, + performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement, performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement, errorEl: root.querySelector(".error-strip") as HTMLDivElement, historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement, diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index 4d22f2f..937527b 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -140,6 +140,9 @@ export class ViewerInteractionController { const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); this.context.setSelectedItems(picked ? [picked] : []); + if (picked && this.shouldFocusSelectionOnClick(picked)) { + this.context.focusOnSelection(picked); + } this.context.syncFollowStateFromSelection(); this.context.updatePanels(); }; @@ -294,4 +297,12 @@ export class ViewerInteractionController { this.context.syncFollowStateFromSelection(); this.context.updatePanels(); } + + private shouldFocusSelectionOnClick(selection: Selectable) { + if (selection.kind === "planet") { + return true; + } + + return selection.kind === "system" && selection.id !== this.context.getActiveSystemId(); + } } diff --git a/apps/viewer/src/viewerMath.ts b/apps/viewer/src/viewerMath.ts index f97cf7a..63cbb76 100644 --- a/apps/viewer/src/viewerMath.ts +++ b/apps/viewer/src/viewerMath.ts @@ -76,9 +76,8 @@ export function currentWorldTimeSeconds(world: WorldState | undefined, worldTime return 0; } - const baseUtcMs = Date.parse(world.generatedAtUtc); const elapsedMs = performance.now() - worldTimeSyncMs; - return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97); + return world.orbitalTimeSeconds + ((elapsedMs / 1000) * world.orbitalSimulation.simulatedSecondsPerRealSecond); } export function hashUnit(seed: number, value: string): number { diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 13cf6ee..aa3254b 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -1,5 +1,5 @@ -import { formatInventory, formatVector } from "./viewerMath"; -import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; +import { formatInventory, formatVector, inventoryAmount } from "./viewerMath"; +import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import type { CameraMode, HistoryWindowState, @@ -31,6 +31,29 @@ interface SystemPanelParams { cameraTargetShipId?: string; } +function renderSystemOwnership(world: WorldState, systemId: string): string { + const claims = [...world.claims.values()].filter((claim) => + claim.systemId === systemId && claim.state !== "destroyed"); + if (claims.length === 0) { + return "Ownership none"; + } + + const ownershipByFaction = new Map(); + for (const claim of claims) { + ownershipByFaction.set(claim.factionId, (ownershipByFaction.get(claim.factionId) ?? 0) + 1); + } + + return [...ownershipByFaction.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([factionId, count]) => { + const faction = world.factions.get(factionId); + const label = faction?.label ?? factionId; + const share = Math.round((count / claims.length) * 100); + return `${label} ${count}/${claims.length} (${share}%)`; + }) + .join("
"); +} + export function updateDetailPanel( detailTitleEl: HTMLHeadingElement, detailBodyEl: HTMLDivElement, @@ -79,12 +102,30 @@ export function updateDetailPanel( return; } const parent = describeSelectionParent(selected); - const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); + const fuelStored = inventoryAmount(ship.inventory, "fuel"); + const cargoUsed = ship.cargoItemId + ? inventoryAmount(ship.inventory, ship.cargoItemId) + : 0; + const cargoLabel = ship.cargoItemId ?? "none"; + const shipState = describeShipState(world, ship); + const shipAction = describeShipCurrentAction(ship); detailTitleEl.textContent = ship.label; detailBodyEl.innerHTML = `

Parent ${parent}

-

State ${ship.state}

-

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

+

State ${shipState}

+ ${shipAction ? ` +
+
+ ${shipAction.label} + ${Math.round(shipAction.progress * 100)}% +
+
+
+
+
+ ` : ""} +

Fuel ${fuelStored.toFixed(1)}
Capacitor ${ship.energyStored.toFixed(1)}

+

Cargo ${cargoLabel} ${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

@@ -98,12 +139,43 @@ export function updateDetailPanel( return; } const parent = describeSelectionParent(selected); + const installedModules = station.installedModules.length > 0 + ? station.installedModules.join("
") + : "none"; + const activeConstruction = [...world.constructionSites.values()] + .filter((site) => site.stationId === station.id && site.state !== "completed") + .map((site) => `${site.blueprintId ?? site.targetDefinitionId} (${site.state})`) + .join("
") || "none"; + const dockedShipLabels = station.dockedShipIds.length > 0 + ? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("
") + : "none"; + const stationInventory = station.inventory.filter((entry) => entry.itemId !== "fuel"); + const stationProcesses = station.currentProcesses; + const stationProcessingHtml = stationProcesses.length > 0 + ? stationProcesses.map((process) => ` +
+
+ ${process.label} + ${Math.round(process.progress * 100)}% +
+
+
+
+
+ `).join("") + : ""; 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)}

+ ${stationProcessingHtml} +

Fuel ${station.fuelStored.toFixed(1)} / ${station.fuelCapacity.toFixed(1)}
Capacitor ${station.energyStored.toFixed(1)} / ${station.energyCapacity.toFixed(1)}

+

Docked ${station.dockedShips} / ${station.dockingPads} +
+ ${dockedShipLabels}

+

Modules ${installedModules}

+

Constructing ${activeConstruction}

+

Inventory ${formatInventory(stationInventory)}

History available in the separate history window.

`; return; @@ -115,11 +187,23 @@ export function updateDetailPanel( return; } const parent = describeSelectionParent(selected); + const nodeLevel = node.maxOre > 0 + ? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1)) + : 0; detailTitleEl.textContent = `Node ${node.id}`; detailBodyEl.innerHTML = `

${node.systemId}

Parent ${parent}

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

+
+
+ Level + ${Math.round(nodeLevel * 100)}% +
+
+
+
+

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

`; return; @@ -240,7 +324,9 @@ export function updateSystemPanel(params: SystemPanelParams) { } systemTitleEl.textContent = activeSystem.label; - systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId); + systemBodyEl.innerHTML = ` +

${renderSystemOwnership(world, activeSystem.id)}

+ `; } export function describeSelectionParent( @@ -270,8 +356,13 @@ export function describeSelectionParent( } 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 (!station) { + return "unknown"; + } + + return station.anchorNodeId + ? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network` + : "unknown"; } if (selection.kind === "node") { const node = world.nodes.get(selection.id); diff --git a/apps/viewer/src/viewerPresentation.ts b/apps/viewer/src/viewerPresentation.ts index ea0072e..8e61b69 100644 --- a/apps/viewer/src/viewerPresentation.ts +++ b/apps/viewer/src/viewerPresentation.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants"; import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath"; +import { rawObject } from "./viewerScenePrimitives"; import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes"; export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { @@ -40,19 +41,21 @@ export function updatePlanetPresentation( ? 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); + visual.orbit.setScaleScalar(scale); + visual.orbit.setPosition(orbitOffset); + visual.mesh.setPosition(position); + visual.icon.setPosition(position); if (visual.ring) { - visual.ring.position.copy(position); + visual.ring.setPosition(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), + moon.orbit.setPosition(position); + moon.orbit.setScaleScalar(scale); + moon.mesh.setPosition( + position.clone().add( + computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale), + ), ); } } @@ -69,7 +72,7 @@ export function updateSystemSummaryPresentation( 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); + rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1); } } @@ -78,49 +81,49 @@ export function updateSystemStarPresentation( activeSystemId: string | undefined, systemFocusLocal: THREE.Vector3, camera: THREE.PerspectiveCamera, - setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void, + setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], 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); + visual.root.setPosition(visual.galaxyPosition); + visual.shellReticle.setScaleScalar(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; + visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); + visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); + visual.icon.setVisible(true); + visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); + visual.shellReticle.setVisible(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; + visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0)); + visual.icon.setPosition(new THREE.Vector3(0, 0, 0)); + visual.icon.setVisible(false); + visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0)); + visual.shellReticle.setVisible(true); setShellReticleOpacity(visual.shellReticle, 1); const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); if (direction.lengthSq() > 0.0001) { - visual.root.position.copy( + visual.root.setPosition( 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); + visual.shellReticle.setScaleScalar(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; + visual.starCluster.setPosition(offset); + visual.icon.setPosition(offset); + visual.icon.setVisible(true); + visual.shellReticle.setVisible(false); setShellReticleOpacity(visual.shellReticle, 0); } } diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts index 234c1e6..7eb548e 100644 --- a/apps/viewer/src/viewerPresentationController.ts +++ b/apps/viewer/src/viewerPresentationController.ts @@ -3,18 +3,24 @@ import { computeZoomBlend } from "./viewerMath"; import { updateNetworkPanel as renderNetworkPanel, recordPerformanceStats, + summarizeNetworkStats, + summarizePerformanceStats, 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"; +import type { OrbitLineVisual, Selectable } from "./viewerTypes"; export interface ViewerPresentationContext { renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.PerspectiveCamera; ambienceGroup: THREE.Group; + gameSummaryEl: HTMLSpanElement; + networkSummaryEl: HTMLSpanElement; + performanceSummaryEl: HTMLSpanElement; statusEl: HTMLDivElement; networkPanelEl: HTMLDivElement; performancePanelEl: HTMLDivElement; @@ -28,13 +34,14 @@ export interface ViewerPresentationContext { getCameraMode: () => any; getCameraTargetShipId: () => string | undefined; getZoomLevel: () => any; + getSelectedItems: () => Selectable[]; getWorldTimeSyncMs: () => number; getCurrentDistance: () => number; systemFocusLocal: THREE.Vector3; planetVisuals: any[]; systemSummaryVisuals: Map; presentationEntries: any[]; - orbitLines: THREE.Object3D[]; + orbitLines: OrbitLineVisual[]; systemVisuals: Map; createWorldPresentationContext: () => any; } @@ -74,20 +81,20 @@ export class ViewerPresentationController { ? blend.systemWeight * (isActiveDetail ? 1 : 0) : Math.max(blend.systemWeight, blend.universeWeight); - this.setObjectOpacity(entry.detail, detailAlpha); - this.setObjectOpacity(entry.icon, iconAlpha); + entry.detail.setOpacity(detailAlpha); + entry.icon.setOpacity(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); + const alpha = this.resolveOrbitLineOpacity(orbitLine, blend, activeSystemId); + orbitLine.line.setOpacity(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); + summaryVisual.sprite.setOpacity(summaryOpacity); } this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035); @@ -95,6 +102,7 @@ export class ViewerPresentationController { updateNetworkPanel() { renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats); + this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats); } recordPerformanceStats(frameMs: number) { @@ -103,6 +111,7 @@ export class ViewerPresentationController { updatePerformancePanel() { renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer); + this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats); } updateShipPresentation() { @@ -131,10 +140,12 @@ export class ViewerPresentationController { updateGamePanel(mode: string) { updateGameStatus({ statusEl: this.context.statusEl, + summaryEl: this.context.gameSummaryEl, world: this.context.getWorld(), activeSystemId: this.context.getActiveSystemId(), cameraMode: this.context.getCameraMode(), zoomLevel: this.context.getZoomLevel(), + selectedItems: this.context.getSelectedItems(), mode, }); } @@ -161,22 +172,21 @@ export class ViewerPresentationController { 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; - } - }); + private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, blend: ReturnType, activeSystemId?: string) { + if (!activeSystemId || orbitLine.systemId !== activeSystemId) { + return 0; + } + + const selected = this.context.getSelectedItems(); + const selectedItem = selected.length === 1 ? selected[0] : undefined; + const baseAlpha = Math.max(blend.localWeight * 0.55, blend.systemWeight); + + if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) { + return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex + ? baseAlpha + : 0; + } + + return orbitLine.kind === "planet" ? baseAlpha : 0; } } diff --git a/apps/viewer/src/viewerSceneDataController.ts b/apps/viewer/src/viewerSceneDataController.ts index 43726aa..e9c23c7 100644 --- a/apps/viewer/src/viewerSceneDataController.ts +++ b/apps/viewer/src/viewerSceneDataController.ts @@ -48,13 +48,13 @@ import type { StationSnapshot, SystemSnapshot, } from "./contracts"; -import type { - OrbitalAnchor, -} from "./viewerTypes"; +import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes"; +import type { SceneNode } from "./viewerScenePrimitives"; export interface ViewerSceneDataContext { documentRef: Document; - getWorldGeneratedAtUtc: () => string | undefined; + getWorldOrbitalTimeSeconds: () => number | undefined; + getOrbitalSimulationSpeed: () => number; getWorldSeed: () => number; getWorldTimeSyncMs: () => number; getWorldPresentationContext: () => any; @@ -71,7 +71,7 @@ export interface ViewerSceneDataContext { systemVisuals: Map; systemSummaryVisuals: Map; planetVisuals: any[]; - orbitLines: THREE.Object3D[]; + orbitLines: OrbitLineVisual[]; spatialNodeVisuals: Map; bubbleVisuals: Map; nodeVisuals: Map; @@ -79,7 +79,7 @@ export interface ViewerSceneDataContext { claimVisuals: Map; constructionSiteVisuals: Map; shipVisuals: Map; - registerPresentation: (detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void; + registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void; } export class ViewerSceneDataController { @@ -153,7 +153,7 @@ export class ViewerSceneDataController { systemFocusLocal: THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; updateSystemDetailVisibility: () => void; - setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; + setShellReticleOpacity: (sprite: any, opacity: number) => void; }) { return { world: overrides.world, @@ -181,7 +181,8 @@ export class ViewerSceneDataController { private createSceneSyncContext() { return { documentRef: this.context.documentRef, - worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(), + worldOrbitalTimeSeconds: this.context.getWorldOrbitalTimeSeconds(), + orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(), worldSeed: this.context.getWorldSeed(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(), systemGroup: this.context.systemGroup, diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts index 9a10f25..a8b9e92 100644 --- a/apps/viewer/src/viewerSceneFactory.ts +++ b/apps/viewer/src/viewerSceneFactory.ts @@ -24,8 +24,10 @@ import { starHaloOpacity, toThreeVector, } from "./viewerMath"; +import { createSceneNode } from "./viewerScenePrimitives"; +import type { SceneNode } from "./viewerScenePrimitives"; -export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh { +export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode { 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), @@ -39,12 +41,12 @@ export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh { ); mesh.position.copy(toThreeVector(node.localPosition)); mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); - return mesh; + return createSceneNode(mesh); } -export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh { +export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode { const color = spatialNodeColor(node.kind); - return new THREE.Mesh( + return createSceneNode(new THREE.Mesh( new THREE.OctahedronGeometry(10, 0), new THREE.MeshStandardMaterial({ color, @@ -52,14 +54,14 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo roughness: 0.35, metalness: 0.45, }), - ); + )); } export function createBubbleRing( bubble: LocalBubbleSnapshot, localPosition: THREE.Vector3, createCirclePoints: (radius: number, segments: number) => THREE.Vector3[], -): THREE.LineLoop { +): SceneNode { const ring = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)), new THREE.LineBasicMaterial({ @@ -69,11 +71,11 @@ export function createBubbleRing( }), ); ring.position.copy(localPosition); - return ring; + return createSceneNode(ring); } -export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh { - return new THREE.Mesh( +export function createClaimMesh(claim: ClaimSnapshot): SceneNode { + return createSceneNode(new THREE.Mesh( new THREE.ConeGeometry(9, 20, 4), new THREE.MeshStandardMaterial({ color: claim.state === "active" ? 0xff7f50 : 0xff5b5b, @@ -81,11 +83,11 @@ export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh { roughness: 0.4, metalness: 0.28, }), - ); + )); } -export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh { - return new THREE.Mesh( +export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): SceneNode { + return createSceneNode(new THREE.Mesh( new THREE.TorusKnotGeometry(7, 2.2, 54, 8), new THREE.MeshStandardMaterial({ color: site.state === "completed" ? 0x46d37f : 0x9df29c, @@ -93,10 +95,10 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THRE roughness: 0.34, metalness: 0.48, }), - ); + )); } -export function createStarCluster(system: SystemSnapshot): THREE.Group { +export function createStarCluster(system: SystemSnapshot): SceneNode { const root = new THREE.Group(); const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const offsets = system.starCount > 1 @@ -123,22 +125,22 @@ export function createStarCluster(system: SystemSnapshot): THREE.Group { root.add(star, halo); } - return root; + return createSceneNode(root); } -export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop { +export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode { const points = Array.from({ length: 120 }, (_, index) => { const phaseDegrees = (index / 120) * 360; return computePlanetLocalPosition(planet, 0, phaseDegrees); }); - return new THREE.LineLoop( + return createSceneNode(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 { +export function createPlanetRing(planet: PlanetSnapshot): SceneNode { 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), @@ -151,7 +153,7 @@ export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh { ); ring.rotation.x = Math.PI / 2; ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25); - return ring; + return createSceneNode(ring); } export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] { @@ -185,23 +187,23 @@ export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVis }), ); - moons.push({ mesh, orbit }); + moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) }); } return moons; } -export function createStationMesh(station: StationSnapshot): THREE.Mesh { +export function createStationMesh(station: StationSnapshot): SceneNode { 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; + return createSceneNode(mesh); } -export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh { +export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode { const geometry = new THREE.ConeGeometry(size, length, 7); geometry.rotateX(Math.PI / 2); const mesh = new THREE.Mesh( @@ -212,7 +214,7 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number, }), ); mesh.position.copy(toThreeVector(ship.localPosition)); - return mesh; + return createSceneNode(mesh); } export function createBackdropStars(): THREE.Points { @@ -324,7 +326,7 @@ export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] { }); } -export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite { +export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode { const canvas = documentRef.createElement("canvas"); canvas.width = 64; canvas.height = 64; @@ -356,7 +358,7 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n })); sprite.scale.setScalar(size); sprite.visible = false; - return sprite; + return createSceneNode(sprite); } export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual { @@ -364,18 +366,18 @@ export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.V canvas.width = 512; canvas.height = 160; const texture = new THREE.CanvasTexture(canvas); - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, depthTest: false, - })); - sprite.scale.set(520, 160, 1); - sprite.visible = false; + }))); + sprite.object.scale.set(520, 160, 1); + sprite.setVisible(false); return { sprite, texture, anchor }; } -export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite { +export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode { const canvas = documentRef.createElement("canvas"); canvas.width = 128; canvas.height = 128; @@ -412,9 +414,9 @@ export function createShellReticle(documentRef: Document, color: string, size: n blending: THREE.AdditiveBlending, fog: false, }); - const sprite = new THREE.Sprite(material); - sprite.scale.setScalar(size); - sprite.visible = false; - sprite.renderOrder = 1000; + const sprite = createSceneNode(new THREE.Sprite(material)); + sprite.setScaleScalar(size); + sprite.setVisible(false); + sprite.setRenderOrder(1000); return sprite; } diff --git a/apps/viewer/src/viewerScenePrimitives.ts b/apps/viewer/src/viewerScenePrimitives.ts new file mode 100644 index 0000000..0644348 --- /dev/null +++ b/apps/viewer/src/viewerScenePrimitives.ts @@ -0,0 +1,159 @@ +import * as THREE from "three"; + +export interface SceneNode { + readonly object: THREE.Object3D; + setPosition(position: THREE.Vector3): void; + setVisible(visible: boolean): void; + setScaleScalar(scale: number): void; + setRotationX(radians: number): void; + setRotationY(radians: number): void; + setRotationZ(radians: number): void; + setRenderOrder(order: number): void; + add(...children: SceneNode[]): void; + clear(): void; + lookAt(target: THREE.Vector3): void; + getWorldPosition(target?: THREE.Vector3): THREE.Vector3; + traverse(visitor: (child: THREE.Object3D) => void): void; + setOpacity(opacity: number): void; + setColor(color: THREE.ColorRepresentation): void; + setEmissive(color: THREE.ColorRepresentation, intensity?: number): void; +} + +class ThreeSceneNode implements SceneNode { + constructor(public readonly object: THREE.Object3D) {} + + setPosition(position: THREE.Vector3) { + this.object.position.copy(position); + } + + setVisible(visible: boolean) { + this.object.visible = visible; + } + + setScaleScalar(scale: number) { + this.object.scale.setScalar(scale); + } + + setRotationX(radians: number) { + this.object.rotation.x = radians; + } + + setRotationY(radians: number) { + this.object.rotation.y = radians; + } + + setRotationZ(radians: number) { + this.object.rotation.z = radians; + } + + setRenderOrder(order: number) { + this.object.renderOrder = order; + } + + add(...children: SceneNode[]) { + this.object.add(...children.map((child) => child.object)); + } + + clear() { + if ("clear" in this.object && typeof this.object.clear === "function") { + this.object.clear(); + } + } + + lookAt(target: THREE.Vector3) { + this.object.lookAt(target); + } + + getWorldPosition(target = new THREE.Vector3()) { + return this.object.getWorldPosition(target); + } + + traverse(visitor: (child: THREE.Object3D) => void) { + this.object.traverse(visitor); + } + + setOpacity(opacity: number) { + const visible = opacity > 0.02; + this.object.visible = visible; + this.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; + } + }); + } + + setColor(color: THREE.ColorRepresentation) { + this.object.traverse((child) => { + if (!("material" in child)) { + return; + } + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + if ("color" in material) { + material.color.set(color); + material.needsUpdate = true; + } + } + }); + } + + setEmissive(color: THREE.ColorRepresentation, intensity = 1) { + this.object.traverse((child) => { + if (!("material" in child)) { + return; + } + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + if ("emissive" in material) { + material.emissive.set(color); + if ("emissiveIntensity" in material) { + material.emissiveIntensity = intensity; + } + material.needsUpdate = true; + } + } + }); + } +} + +export function createSceneNode(object: T): SceneNode { + return new ThreeSceneNode(object); +} + +export function rawObject(node: SceneNode) { + return node.object; +} + +export function addToRawScene(scene: THREE.Scene, ...nodes: SceneNode[]) { + scene.add(...nodes.map((node) => node.object)); +} + +export function registerSelectableTarget( + selectableTargets: Map, + node: SceneNode, + selectable: unknown, +) { + selectableTargets.set(node.object, selectable); +} + +export function registerSelectableDescendants( + selectableTargets: Map, + node: SceneNode, + selectable: unknown, + predicate: (child: THREE.Object3D) => boolean, +) { + node.traverse((child) => { + if (predicate(child)) { + selectableTargets.set(child, selectable); + } + }); +} diff --git a/apps/viewer/src/viewerSceneSync.ts b/apps/viewer/src/viewerSceneSync.ts index 2aa98b9..518bd0b 100644 --- a/apps/viewer/src/viewerSceneSync.ts +++ b/apps/viewer/src/viewerSceneSync.ts @@ -8,6 +8,7 @@ import type { ClaimVisual, ConstructionSiteVisual, NodeVisual, + OrbitLineVisual, PlanetVisual, PresentationEntry, Selectable, @@ -39,6 +40,7 @@ import { computePlanetLocalPosition, toThreeVector, } from "./viewerMath"; +import { getAnimatedShipLocalPosition } from "./viewerPresentation"; import { createBubbleRing, createClaimMesh, @@ -55,10 +57,18 @@ import { createSystemSummaryVisual, createTacticalIcon, } from "./viewerSceneFactory"; +import { + createSceneNode, + rawObject, + registerSelectableDescendants, + registerSelectableTarget, +} from "./viewerScenePrimitives"; +import type { SceneNode } from "./viewerScenePrimitives"; interface SceneSyncContext { documentRef: Document; - worldGeneratedAtUtc?: string; + worldOrbitalTimeSeconds?: number; + orbitalSimulationSpeed: number; worldSeed: number; worldTimeSyncMs: number; systemGroup: THREE.Group; @@ -74,7 +84,7 @@ interface SceneSyncContext { systemVisuals: Map; systemSummaryVisuals: Map; planetVisuals: PlanetVisual[]; - orbitLines: THREE.Object3D[]; + orbitLines: OrbitLineVisual[]; spatialNodeVisuals: Map; bubbleVisuals: Map; nodeVisuals: Map; @@ -83,8 +93,8 @@ interface SceneSyncContext { constructionSiteVisuals: Map; shipVisuals: Map; registerPresentation: ( - detail: THREE.Object3D, - icon: THREE.Sprite, + detail: SceneNode, + icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string, @@ -111,8 +121,8 @@ interface SceneSyncContext { } export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) { - const worldTimeSeconds = context.worldGeneratedAtUtc - ? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97) + const worldTimeSeconds = context.worldOrbitalTimeSeconds !== undefined + ? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed) : 0; context.systemGroup.clear(); @@ -124,9 +134,9 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho 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 root = createSceneNode(new THREE.Group()); + root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z)); + const detailGroup = createSceneNode(new THREE.Group()); const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const starCluster = createStarCluster(system); @@ -136,7 +146,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho context.documentRef, new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z), ); - summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0); + summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0)); root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); context.registerPresentation(starCluster, systemIcon, true); context.systemVisuals.set(system.id, { @@ -150,18 +160,14 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho 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 }); + registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh); + registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id }); + registerSelectableTarget(context.selectableTargets, 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( + const planetMesh = createSceneNode(new THREE.Mesh( new THREE.SphereGeometry(renderedPlanetRadius, 18, 18), new THREE.MeshStandardMaterial({ color: planet.color, @@ -169,13 +175,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho metalness: 0.08, emissive: new THREE.Color(planet.color).multiplyScalar(0.04), }), - ); - planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds)); + )); + planetMesh.setPosition(computePlanetLocalPosition(planet, worldTimeSeconds)); const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2)); - planetIcon.position.copy(planetMesh.position); + planetIcon.setPosition(rawObject(planetMesh).position.clone()); const ring = planet.hasRing ? createPlanetRing(planet) : undefined; if (ring) { - ring.position.copy(planetMesh.position); + ring.setPosition(rawObject(planetMesh).position.clone()); } const moons = createMoonVisuals(planet, context.worldSeed); detailGroup.add(orbit, planetMesh, planetIcon); @@ -183,23 +189,35 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho detailGroup.add(ring); } for (const moon of moons) { - moon.orbit.position.copy(planetMesh.position); - moon.mesh.position.copy(planetMesh.position); + moon.systemId = system.id; + moon.planetIndex = planetIndex; + moon.orbit.setPosition(rawObject(planetMesh).position.clone()); + moon.mesh.setPosition(rawObject(planetMesh).position.clone()); detailGroup.add(moon.orbit, moon.mesh); - context.orbitLines.push(moon.orbit); + context.orbitLines.push({ + line: moon.orbit, + systemId: system.id, + kind: "moon", + planetIndex, + }); context.registerPresentation(moon.mesh, planetIcon, true, true, system.id); } - context.orbitLines.push(orbit); + context.orbitLines.push({ + line: orbit, + systemId: system.id, + kind: "planet", + planetIndex, + }); 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 }); + registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex }); + registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex }); } - context.systemGroup.add(root); + context.systemGroup.add(rawObject(root)); } } @@ -211,8 +229,8 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn 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); + mesh.setPosition(localPosition); + icon.setPosition(localPosition); context.spatialNodeVisuals.set(node.id, { id: node.id, systemId: node.systemId, @@ -221,10 +239,10 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn kind: node.kind, localPosition, }); - context.spatialNodeGroup.add(mesh, icon); + context.spatialNodeGroup.add(rawObject(mesh), rawObject(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 }); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id }); } } @@ -238,8 +256,8 @@ export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubble 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 }); + context.bubbleGroup.add(rawObject(mesh)); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id }); } } @@ -250,7 +268,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot 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); + icon.setPosition(rawObject(mesh).position.clone()); const localPosition = toThreeVector(node.localPosition); const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition); const orbital = context.deriveNodeOrbital(node, anchor); @@ -265,10 +283,10 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot orbitPhase: orbital.phase, orbitInclination: orbital.inclination, }); - context.nodeGroup.add(mesh, icon); + context.nodeGroup.add(rawObject(mesh), rawObject(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 }); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id }); } } @@ -279,7 +297,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho for (const station of stations) { const mesh = createStationMesh(station); const icon = createTacticalIcon(context.documentRef, station.color, 26); - icon.position.copy(mesh.position); + icon.setPosition(rawObject(mesh).position.clone()); const localPosition = toThreeVector(station.localPosition); const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition); const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); @@ -294,10 +312,10 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho orbitInclination: orbital.inclination, localPosition, }); - context.stationGroup.add(mesh, icon); + context.stationGroup.add(rawObject(mesh), rawObject(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 }); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id }); } } @@ -309,8 +327,8 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) { 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); + mesh.setPosition(localPosition); + icon.setPosition(localPosition); context.claimVisuals.set(claim.id, { id: claim.id, nodeId: claim.nodeId, @@ -319,10 +337,10 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) { icon, localPosition, }); - context.claimGroup.add(mesh, icon); + context.claimGroup.add(rawObject(mesh), rawObject(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 }); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id }); } } @@ -334,8 +352,8 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc 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); + mesh.setPosition(localPosition); + icon.setPosition(localPosition); context.constructionSiteVisuals.set(site.id, { id: site.id, nodeId: site.nodeId, @@ -344,10 +362,10 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc icon, localPosition, }); - context.constructionSiteGroup.add(mesh, icon); + context.constructionSiteGroup.add(rawObject(mesh), rawObject(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 }); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id }); } } @@ -360,11 +378,11 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick 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 }); + icon.setPosition(position); + icon.setColor(shipColor); + context.shipGroup.add(rawObject(mesh), rawObject(icon)); + registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id }); + registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id }); context.registerPresentation(mesh, icon, true, true, ship.systemId); context.shipVisuals.set(ship.id, { systemId: ship.systemId, @@ -390,9 +408,9 @@ export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: Spatial 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)); + visual.mesh.setPosition(visual.localPosition); + visual.icon.setPosition(visual.localPosition); + visual.mesh.setColor(context.spatialNodeColor(node.kind)); } } @@ -406,8 +424,8 @@ export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: Local 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)); + visual.mesh.setPosition(visual.localPosition); + visual.mesh.setScaleScalar(Math.max(bubble.radius, 60)); context.setBubbleVisualState(visual, bubble); } } @@ -427,7 +445,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe 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); + visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); } } @@ -445,9 +463,8 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD 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); + visual.mesh.setColor(station.color); + visual.mesh.setEmissive(station.color, 0.1); } } @@ -460,11 +477,10 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[] 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"); + visual.mesh.setPosition(visual.localPosition); + visual.icon.setPosition(visual.localPosition); + visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b"); + visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020"); } } @@ -477,11 +493,10 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co 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); + visual.mesh.setPosition(visual.localPosition); + visual.icon.setPosition(visual.localPosition); + visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c"); + visual.mesh.setScaleScalar(0.75 + site.progress * 0.35); } } @@ -493,16 +508,15 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t } visual.systemId = ship.systemId; - visual.startPosition.copy(visual.authoritativePosition); + visual.startPosition.copy(getAnimatedShipLocalPosition(visual)); 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); + visual.mesh.setColor(shipColor); + visual.mesh.setEmissive(shipColor, 0.18); + visual.icon.setColor(shipColor); } } diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts index bfd536a..61a744e 100644 --- a/apps/viewer/src/viewerSelection.ts +++ b/apps/viewer/src/viewerSelection.ts @@ -1,4 +1,4 @@ -import type { SystemSnapshot } from "./contracts"; +import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts"; import type { CameraMode, OrbitalAnchor, @@ -214,3 +214,205 @@ export function renderSystemDetails( ${followText} `; } + +export function describeShipState(world: WorldState | undefined, ship: ShipSnapshot): string { + const baseState = ship.state; + if (baseState === "capacitor-starved") { + return `${baseState} while ${describeControllerTask(ship.controllerTaskKind)}`; + } + + if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) { + return baseState; + } + + const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId; + if (!destinationNodeId) { + return baseState; + } + + const destinationNode = world.spatialNodes.get(destinationNodeId); + if (!destinationNode) { + return `${baseState} -> ${destinationNodeId}`; + } + + if (baseState === "warping" || baseState === "spooling-warp") { + const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId); + return `${baseState} -> ${destinationPath ?? destinationNodeId}`; + } + + const destinationSystem = world.systems.get(destinationNode.systemId); + return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`; +} + +function describeControllerTask(taskKind: string): string { + switch (taskKind) { + case "travel": + return "travel"; + case "extract": + return "mining"; + case "dock": + return "docking"; + case "unload": + return "transfer"; + case "refuel": + return "refuel"; + case "deliver-construction": + return "material delivery"; + case "build-construction-site": + return "site construction"; + case "construct-module": + return "module construction"; + case "undock": + return "undocking"; + case "load-workers": + return "worker loading"; + case "unload-workers": + return "worker unloading"; + default: + return taskKind; + } +} + +export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined { + if (!ship.currentAction) { + return undefined; + } + + return { + label: ship.currentAction.label, + progress: Math.max(0, Math.min(ship.currentAction.progress, 1)), + }; +} + +export function describeShipLocation(world: WorldState | undefined, ship: ShipSnapshot): { system: string; local?: string } { + const systemId = ship.spatialState.currentSystemId || ship.systemId; + const system = world?.systems.get(systemId); + const systemLabel = system?.label ?? systemId; + if (!world || !system) { + return { system: systemLabel }; + } + + if (ship.dockedStationId) { + const station = world.stations.get(ship.dockedStationId); + if (station) { + const anchorPath = station.anchorNodeId + ? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) + : undefined; + return { + system: systemLabel, + local: anchorPath ? `${anchorPath}/${station.label}` : station.label, + }; + } + } + + const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId; + if (currentNodeId) { + const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId); + if (nodePath) { + return { system: systemLabel, local: nodePath }; + } + } + + const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId; + if (currentBubbleId) { + const bubble = world.localBubbles.get(currentBubbleId); + if (bubble?.nodeId) { + const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId); + if (nodePath) { + return { system: systemLabel, local: nodePath }; + } + } + } + + return { system: systemLabel }; +} + +export function describeActiveSpace( + world: WorldState | undefined, + zoomLevel: "local" | "system" | "universe", + activeSystemId: string | undefined, + selectedItems: Selectable[], +): string { + if (!world || zoomLevel === "universe") { + return "deep-space"; + } + + const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined; + if (!activeSystem) { + return "deep-space"; + } + + if (zoomLevel !== "local") { + return activeSystem.label; + } + + const bubbleId = resolveFocusedBubbleId(world, selectedItems); + if (bubbleId) { + const bubble = world.localBubbles.get(bubbleId); + const localPath = bubble?.nodeId + ? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId) + : undefined; + return localPath + ? `${activeSystem.label} / ${localPath}` + : activeSystem.label; + } + + const selected = selectedItems.length === 1 ? selectedItems[0] : undefined; + if (selected?.kind === "planet" && selected.systemId === activeSystem.id) { + const planet = activeSystem.planets[selected.planetIndex]; + return planet + ? `${activeSystem.label} / ${planet.label}` + : activeSystem.label; + } + + return activeSystem.label; +} + +export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined { + const node = world.spatialNodes.get(nodeId); + const system = world.systems.get(systemId); + if (!node || !system) { + return undefined; + } + + if (node.parentNodeId) { + const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId); + const segment = describeSpatialNodeSegment(world, system, node); + return parentPath ? `${parentPath}/${segment}` : segment; + } + + if (node.kind === "star") { + return undefined; + } + + return describeSpatialNodeSegment(world, system, node); +} + +function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string { + const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/); + if (moonMatch) { + const moonIndex = Number.parseInt(moonMatch[2], 10); + return `Moon ${moonIndex}`; + } + + const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/); + if (lagrangeMatch) { + return lagrangeMatch[1].toUpperCase(); + } + + const planetMatch = node.id.match(/-planet-(\d+)$/); + if (planetMatch) { + const planetIndex = Number.parseInt(planetMatch[1], 10) - 1; + return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`; + } + + if (node.kind === "station" && node.occupyingStructureId) { + return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId; + } + + if (node.kind === "resource-site") { + return node.orbitReferenceId ?? "Resource Site"; + } + + return node.orbitReferenceId ?? node.kind; +} diff --git a/apps/viewer/src/viewerState.ts b/apps/viewer/src/viewerState.ts index abcde0e..9e1d90b 100644 --- a/apps/viewer/src/viewerState.ts +++ b/apps/viewer/src/viewerState.ts @@ -36,6 +36,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState { seed: snapshot.seed, sequence: snapshot.sequence, tickIntervalMs: snapshot.tickIntervalMs, + orbitalTimeSeconds: snapshot.orbitalTimeSeconds, + orbitalSimulation: snapshot.orbitalSimulation, generatedAtUtc: snapshot.generatedAtUtc, systems: new Map(snapshot.systems.map((system) => [system.id, system])), spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])), @@ -55,6 +57,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState { export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean { world.sequence = delta.sequence; world.tickIntervalMs = delta.tickIntervalMs; + world.orbitalTimeSeconds = delta.orbitalTimeSeconds; + world.orbitalSimulation = delta.orbitalSimulation; world.generatedAtUtc = delta.generatedAtUtc; world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18); diff --git a/apps/viewer/src/viewerTelemetry.ts b/apps/viewer/src/viewerTelemetry.ts index 9df95ca..103c29d 100644 --- a/apps/viewer/src/viewerTelemetry.ts +++ b/apps/viewer/src/viewerTelemetry.ts @@ -36,6 +36,17 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: ].join("\n"); } +export function summarizeNetworkStats(networkStats: NetworkStats): string { + const now = performance.now(); + 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 direction = networkStats.streamConnected ? "live" : "offline"; + return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`; +} + export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) { const now = performance.now(); performanceStats.lastFrameMs = frameMs; @@ -89,3 +100,14 @@ export function updatePerformancePanel( ].join("\n"); performanceStats.lastPanelUpdateAtMs = now; } + +export function summarizePerformanceStats(performanceStats: PerformanceStats): string { + const samples = performanceStats.frameSamples; + const elapsedWindowSeconds = samples.length > 1 + ? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25) + : 1; + const fps = samples.length > 1 + ? (samples.length - 1) / elapsedWindowSeconds + : 0; + return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`; +} diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts index 2aa4911..ffd7a2d 100644 --- a/apps/viewer/src/viewerTypes.ts +++ b/apps/viewer/src/viewerTypes.ts @@ -1,4 +1,5 @@ import * as THREE from "three"; +import type { SceneNode } from "./viewerScenePrimitives"; import type { ClaimSnapshot, ConstructionSiteSnapshot, @@ -13,6 +14,7 @@ import type { SpatialNodeSnapshot, StationSnapshot, SystemSnapshot, + OrbitalSimulationSnapshot, } from "./contracts"; export type ZoomLevel = "local" | "system" | "universe"; @@ -33,8 +35,8 @@ export type Selectable = export interface ShipVisual { systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; startPosition: THREE.Vector3; authoritativePosition: THREE.Vector3; targetPosition: THREE.Vector3; @@ -46,16 +48,25 @@ export interface ShipVisual { export interface PlanetVisual { systemId: string; planet: PlanetSnapshot; - orbit: THREE.LineLoop; - mesh: THREE.Mesh; - icon: THREE.Sprite; - ring?: THREE.Mesh; + orbit: SceneNode; + mesh: SceneNode; + icon: SceneNode; + ring?: SceneNode; moons: MoonVisual[]; } export interface MoonVisual { - mesh: THREE.Mesh; - orbit: THREE.LineLoop; + systemId: string; + planetIndex: number; + mesh: SceneNode; + orbit: SceneNode; +} + +export interface OrbitLineVisual { + line: SceneNode; + systemId: string; + kind: "planet" | "moon"; + planetIndex: number; } export type OrbitalAnchor = @@ -65,8 +76,8 @@ export type OrbitalAnchor = export interface NodeVisual { systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; sourceKind: string; anchor: OrbitalAnchor; localPosition: THREE.Vector3; @@ -78,8 +89,8 @@ export interface NodeVisual { export interface SpatialNodeVisual { id: string; systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; kind: string; localPosition: THREE.Vector3; } @@ -87,7 +98,7 @@ export interface SpatialNodeVisual { export interface BubbleVisual { id: string; systemId: string; - mesh: THREE.LineLoop; + mesh: SceneNode; localPosition: THREE.Vector3; radius: number; } @@ -96,8 +107,8 @@ export interface ClaimVisual { id: string; nodeId: string; systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; localPosition: THREE.Vector3; } @@ -105,16 +116,16 @@ export interface ConstructionSiteVisual { id: string; nodeId: string; systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; localPosition: THREE.Vector3; } export interface StructureVisual { id: string; systemId: string; - mesh: THREE.Mesh; - icon: THREE.Sprite; + mesh: SceneNode; + icon: SceneNode; anchor: OrbitalAnchor; orbitRadius: number; orbitPhase: number; @@ -123,12 +134,12 @@ export interface StructureVisual { } export interface SystemVisual { - root: THREE.Group; - starCluster: THREE.Group; - icon: THREE.Sprite; - shellReticle: THREE.Sprite; + root: SceneNode; + starCluster: SceneNode; + icon: SceneNode; + shellReticle: SceneNode; shellReticleBaseScale: number; - detailGroup: THREE.Group; + detailGroup: SceneNode; summary: SystemSummaryVisual; galaxyPosition: THREE.Vector3; } @@ -138,6 +149,8 @@ export interface WorldState { seed: number; sequence: number; tickIntervalMs: number; + orbitalTimeSeconds: number; + orbitalSimulation: OrbitalSimulationSnapshot; generatedAtUtc: string; systems: Map; spatialNodes: Map; @@ -183,15 +196,15 @@ export interface PerformanceStats { } export interface PresentationEntry { - detail: THREE.Object3D; - icon: THREE.Sprite; + detail: SceneNode; + icon: SceneNode; systemId?: string; hideDetailInUniverse?: boolean; hideIconInUniverse?: boolean; } export interface SystemSummaryVisual { - sprite: THREE.Sprite; + sprite: SceneNode; texture: THREE.CanvasTexture; anchor: THREE.Vector3; } diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 4fd2084..5b3b6fa 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -181,7 +181,7 @@ export class ViewerWorldLifecycle { } this.context.setWorldTimeSyncMs(performance.now()); - const factionsChanged = applyDeltaToWorld(world, delta); + applyDeltaToWorld(world, delta); this.context.applySpatialNodeDeltas(delta.spatialNodes); this.context.applyLocalBubbleDeltas(delta.localBubbles); this.context.applyNodeDeltas(delta.nodes); @@ -189,9 +189,7 @@ export class ViewerWorldLifecycle { this.context.applyClaimDeltas(delta.claims); this.context.applyConstructionSiteDeltas(delta.constructionSites); this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs); - if (factionsChanged) { - this.rebuildFactions(cloneFactions(world)); - } + this.rebuildFactions(cloneFactions(world)); this.context.updateSystemSummaries(); } @@ -201,6 +199,8 @@ export class ViewerWorldLifecycle { this.context.getSelectedItems(), this.context.getCameraMode(), this.context.getCameraTargetShipId(), + this.context.getZoomLevel(), + this.context.getActiveSystemId(), ); } diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts index 74574f2..0659a1a 100644 --- a/apps/viewer/src/viewerWorldPresentation.ts +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -7,12 +7,14 @@ import { resolveOrbitalAnchorPosition, toThreeVector, } from "./viewerMath"; +import { describeActiveSpace } from "./viewerSelection"; import { resolveShipHeading, updateSystemStarPresentation, updateSystemSummaryPresentation, getAnimatedShipLocalPosition, } from "./viewerPresentation"; +import { rawObject } from "./viewerScenePrimitives"; import type { LocalBubbleDelta, LocalBubbleSnapshot, @@ -22,6 +24,7 @@ import type { import type { BubbleVisual, ClaimVisual, + Selectable, ConstructionSiteVisual, NodeVisual, OrbitalAnchor, @@ -59,15 +62,17 @@ export interface WorldPresentationContext extends WorldOrbitalContext { systemSummaryVisuals: Map; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; updateSystemDetailVisibility: () => void; - setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; + setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void; } export interface GameStatusParams { statusEl: HTMLDivElement; + summaryEl?: HTMLSpanElement; world?: WorldState; activeSystemId?: string; cameraMode: CameraMode; zoomLevel: ZoomLevel; + selectedItems: Selectable[]; mode: string; } @@ -77,59 +82,59 @@ export function updateWorldPresentation(context: WorldPresentationContext) { 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); + visual.mesh.setPosition(context.toDisplayLocalPosition(worldPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const shipVisible = visual.systemId === context.activeSystemId; - visual.mesh.visible = shipVisible; - visual.icon.visible = shipVisible && visual.icon.visible; + visual.mesh.setVisible(shipVisible); + visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible); const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); if (desiredHeading.lengthSq() > 0.01) { - visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); + visual.mesh.lookAt(rawObject(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); + visual.mesh.setVisible(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); + visual.mesh.setVisible(visual.systemId === context.activeSystemId); + visual.icon.setVisible(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.setVisible(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); + visual.mesh.setVisible(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); + visual.mesh.setVisible(visual.systemId === context.activeSystemId); + visual.icon.setVisible(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; + visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.icon.setPosition(rawObject(visual.mesh).position.clone()); + visual.mesh.setVisible(visual.systemId === context.activeSystemId); + visual.icon.setVisible(visual.systemId === context.activeSystemId); } updateSystemStarPresentation( @@ -218,22 +223,26 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st } export function updateGameStatus(params: GameStatusParams) { - const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params; + const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, 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"; + const displayZoomLevel = activeSystemId ? zoomLevel : "universe"; + const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems); + const cameraModeLabel = cameraMode === "follow" ? "follow" : "map"; statusEl.textContent = [ `mode: ${mode}`, `camera: ${cameraModeLabel}`, - `zoom: ${zoomLevel}`, - `system: ${activeSystem}`, + `zoom: ${displayZoomLevel}`, + `space: ${activeSpace}`, `sequence: ${sequence}`, `snapshot: ${generatedAt}`, ].join("\n"); + if (summaryEl) { + summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`; + } } export function deriveNodeOrbital( @@ -371,7 +380,7 @@ export function computeSpatialNodeLocalPositionById( 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; + const material = (rawObject(visual.mesh) as THREE.LineLoop).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"); } diff --git a/shared/data/balance.json b/shared/data/balance.json index 9713327..1aeecf6 100644 --- a/shared/data/balance.json +++ b/shared/data/balance.json @@ -13,8 +13,5 @@ "warpDrain": 7, "shipRechargeRate": 10, "stationSolarCharge": 5 - }, - "fuel": { - "warpDrain": 4.5 } } diff --git a/shared/data/constructibles.json b/shared/data/constructibles.json index e3aa473..cfb0fcc 100644 --- a/shared/data/constructibles.json +++ b/shared/data/constructibles.json @@ -12,7 +12,7 @@ "bulk-liquid": 600, "bulk-gas": 600 }, - "modules": ["docking-clamps", "dock-bay-small", "power-core", "bulk-bay", "liquid-tank"] + "modules": ["dock-bay-small", "power-core", "bulk-bay", "liquid-tank"] }, { "id": "trade-hub", @@ -22,7 +22,7 @@ "radius": 20, "dockingCapacity": 4, "storage": { "container": 1200, "manufactured": 800 }, - "modules": ["habitat-ring", "docking-clamps", "container-bay"] + "modules": ["habitat-ring", "container-bay"] }, { "id": "refinery", @@ -32,7 +32,7 @@ "radius": 24, "dockingCapacity": 3, "storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 }, - "modules": ["docking-clamps", "power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"] + "modules": ["power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"] }, { "id": "farm-ring", @@ -52,7 +52,7 @@ "radius": 24, "dockingCapacity": 3, "storage": { "manufactured": 2200, "container": 1600 }, - "modules": ["fabricator-array", "fabricator-array", "container-bay", "docking-clamps"] + "modules": ["fabricator-array", "fabricator-array", "container-bay"] }, { "id": "shipyard", @@ -62,7 +62,7 @@ "radius": 28, "dockingCapacity": 5, "storage": { "manufactured": 1800, "container": 1200 }, - "modules": ["docking-clamps", "fabricator-array", "habitat-ring"] + "modules": ["component-factory", "ship-factory", "container-bay", "dock-bay-small", "power-core"] }, { "id": "defense-grid", @@ -82,6 +82,6 @@ "radius": 34, "dockingCapacity": 0, "storage": { "manufactured": 2400, "container": 800 }, - "modules": ["ftl-core", "fabricator-array", "docking-clamps"] + "modules": ["ftl-core", "fabricator-array"] } ] diff --git a/shared/data/items.json b/shared/data/items.json index ff11109..5595622 100644 --- a/shared/data/items.json +++ b/shared/data/items.json @@ -41,6 +41,96 @@ "storage": "manufactured", "summary": "High-value integration kits for hull fitting and final assembly." }, + { + "id": "command-bridge-module", + "label": "Command Bridge Module", + "storage": "container", + "summary": "Packaged bridge and combat-information-center assembly for final ship integration." + }, + { + "id": "reactor-core-module", + "label": "Reactor Core Module", + "storage": "container", + "summary": "Contained ship reactor package ready for installation into a hull." + }, + { + "id": "capacitor-bank-module", + "label": "Capacitor Bank Module", + "storage": "container", + "summary": "Buffered capacitor section for propulsion, weapons, and industrial loads." + }, + { + "id": "ion-drive-module", + "label": "Ion Drive Module", + "storage": "container", + "summary": "Preassembled sublight engine unit." + }, + { + "id": "ftl-core-module", + "label": "FTL Core Module", + "storage": "container", + "summary": "Integrated FTL drive package for inter-system transit." + }, + { + "id": "gun-turret-module", + "label": "Gun Turret Module", + "storage": "container", + "summary": "Shipboard turret mount and fire-control package." + }, + { + "id": "carrier-bay-module", + "label": "Carrier Bay Module", + "storage": "container", + "summary": "Hangar and launch-recovery assembly for capital ship integration." + }, + { + "id": "habitat-ring-module", + "label": "Habitat Ring Module", + "storage": "container", + "summary": "Crew habitat section packaged for large ship installation." + }, + { + "id": "bulk-bay-module", + "label": "Bulk Bay Module", + "storage": "container", + "summary": "Industrial cargo hold segment for raw-solid hauling ships." + }, + { + "id": "container-bay-module", + "label": "Container Bay Module", + "storage": "container", + "summary": "Freight rack segment for manufactured and palletized cargo." + }, + { + "id": "liquid-tank-module", + "label": "Liquid Tank Module", + "storage": "container", + "summary": "Pressurized liquid storage segment for fuel and energy logistics." + }, + { + "id": "gas-tank-module", + "label": "Gas Tank Module", + "storage": "container", + "summary": "Pressurized gas storage segment for volatile cargo hauling." + }, + { + "id": "mining-turret-module", + "label": "Mining Turret Module", + "storage": "container", + "summary": "Ship-mounted hard-rock extraction head." + }, + { + "id": "gas-extractor-module", + "label": "Gas Extractor Module", + "storage": "container", + "summary": "Cryogenic intake and compression package for gas harvesting ships." + }, + { + "id": "fabricator-array-module", + "label": "Fabricator Array Module", + "storage": "container", + "summary": "Mobile industrial fabrication block for constructors." + }, { "id": "gas", "label": "Volatile Gas", @@ -53,6 +143,12 @@ "storage": "bulk-liquid", "summary": "Processed liquid fuel consumed by ships and station power systems." }, + { + "id": "energy-cell", + "label": "Energy Cell", + "storage": "bulk-liquid", + "summary": "Charged energy reserves that can be stored, traded, and discharged into station power grids." + }, { "id": "water", "label": "Water", diff --git a/shared/data/module-recipes.json b/shared/data/module-recipes.json index 28f3e44..196074a 100644 --- a/shared/data/module-recipes.json +++ b/shared/data/module-recipes.json @@ -13,6 +13,13 @@ { "itemId": "refined-metals", "amount": 30 } ] }, + { + "moduleId": "container-bay", + "duration": 10, + "inputs": [ + { "itemId": "refined-metals", "amount": 26 } + ] + }, { "moduleId": "fuel-processor", "duration": 14, @@ -26,5 +33,36 @@ "inputs": [ { "itemId": "refined-metals", "amount": 38 } ] + }, + { + "moduleId": "fabricator-array", + "duration": 16, + "inputs": [ + { "itemId": "refined-metals", "amount": 48 } + ] + }, + { + "moduleId": "component-factory", + "duration": 18, + "inputs": [ + { "itemId": "refined-metals", "amount": 54 }, + { "itemId": "ship-equipment", "amount": 12 } + ] + }, + { + "moduleId": "ship-factory", + "duration": 22, + "inputs": [ + { "itemId": "refined-metals", "amount": 60 }, + { "itemId": "hull-sections", "amount": 24 }, + { "itemId": "ship-equipment", "amount": 14 } + ] + }, + { + "moduleId": "solar-array", + "duration": 12, + "inputs": [ + { "itemId": "refined-metals", "amount": 28 } + ] } ] diff --git a/shared/data/modules.json b/shared/data/modules.json index 89a5076..d7eaab0 100644 --- a/shared/data/modules.json +++ b/shared/data/modules.json @@ -65,12 +65,6 @@ "category": "cargo-container", "summary": "Standardized freight racks." }, - { - "id": "docking-clamps", - "label": "Docking Clamps", - "category": "dock", - "summary": "Docking collar and transfer arms." - }, { "id": "dock-bay-small", "label": "Small Dock Bay", @@ -113,12 +107,30 @@ "category": "production", "summary": "Assembly lines for manufactured goods." }, + { + "id": "component-factory", + "label": "Component Factory", + "category": "production", + "summary": "Dedicated lines for assembling ship-grade modules and subsystems." + }, + { + "id": "ship-factory", + "label": "Ship Factory", + "category": "shipyard", + "summary": "Final hull integration docks for assembling complete spacecraft from manufactured modules." + }, { "id": "power-core", "label": "Power Core", "category": "energy", "summary": "Primary station generator and power distribution." }, + { + "id": "solar-array", + "label": "Solar Array", + "category": "energy", + "summary": "External collector wings that generate station power and charge exportable energy cells." + }, { "id": "liquid-tank", "label": "Liquid Tank", diff --git a/shared/data/recipes.json b/shared/data/recipes.json index b0c904e..5d7d518 100644 --- a/shared/data/recipes.json +++ b/shared/data/recipes.json @@ -41,6 +41,31 @@ { "itemId": "gas", "amount": 20 } ] }, + { + "id": "fuel-processing", + "label": "Fuel Processing", + "facilityCategory": "station", + "duration": 6, + "priority": 96, + "requiredModules": ["fuel-processor", "power-core"], + "inputs": [ + { "itemId": "gas", "amount": 20 } + ], + "outputs": [ + { "itemId": "fuel", "amount": 20 } + ] + }, + { + "id": "energy-cell-charging", + "label": "Energy Cell Charging", + "facilityCategory": "station", + "duration": 12, + "priority": 72, + "requiredModules": ["solar-array", "liquid-tank"], + "outputs": [ + { "itemId": "energy-cell", "amount": 6 } + ] + }, { "id": "water-reclamation", "label": "Water Reclamation", @@ -143,6 +168,400 @@ { "itemId": "ship-parts", "amount": 20 } ] }, + { + "id": "command-bridge-module-assembly", + "label": "Command Bridge Module Assembly", + "facilityCategory": "station", + "duration": 9, + "priority": 52, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 20 }, + { "itemId": "ship-equipment", "amount": 10 } + ], + "outputs": [ + { "itemId": "command-bridge-module", "amount": 1 } + ] + }, + { + "id": "reactor-core-module-assembly", + "label": "Reactor Core Module Assembly", + "facilityCategory": "station", + "duration": 10, + "priority": 54, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 30 }, + { "itemId": "ship-equipment", "amount": 8 } + ], + "outputs": [ + { "itemId": "reactor-core-module", "amount": 1 } + ] + }, + { + "id": "capacitor-bank-module-assembly", + "label": "Capacitor Bank Module Assembly", + "facilityCategory": "station", + "duration": 9, + "priority": 52, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 18 }, + { "itemId": "energy-cell", "amount": 6 } + ], + "outputs": [ + { "itemId": "capacitor-bank-module", "amount": 1 } + ] + }, + { + "id": "ion-drive-module-assembly", + "label": "Ion Drive Module Assembly", + "facilityCategory": "station", + "duration": 10, + "priority": 53, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 22 }, + { "itemId": "ship-equipment", "amount": 8 } + ], + "outputs": [ + { "itemId": "ion-drive-module", "amount": 1 } + ] + }, + { + "id": "ftl-core-module-assembly", + "label": "FTL Core Module Assembly", + "facilityCategory": "station", + "duration": 12, + "priority": 56, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 34 }, + { "itemId": "ship-equipment", "amount": 14 }, + { "itemId": "fuel", "amount": 12 } + ], + "outputs": [ + { "itemId": "ftl-core-module", "amount": 1 } + ] + }, + { + "id": "gun-turret-module-assembly", + "label": "Gun Turret Module Assembly", + "facilityCategory": "station", + "duration": 8, + "priority": 58, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "naval-guns", "amount": 8 }, + { "itemId": "refined-metals", "amount": 12 } + ], + "outputs": [ + { "itemId": "gun-turret-module", "amount": 1 } + ] + }, + { + "id": "carrier-bay-module-assembly", + "label": "Carrier Bay Module Assembly", + "facilityCategory": "station", + "duration": 14, + "priority": 40, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "hull-sections", "amount": 18 }, + { "itemId": "refined-metals", "amount": 18 }, + { "itemId": "ship-equipment", "amount": 10 } + ], + "outputs": [ + { "itemId": "carrier-bay-module", "amount": 1 } + ] + }, + { + "id": "habitat-ring-module-assembly", + "label": "Habitat Ring Module Assembly", + "facilityCategory": "station", + "duration": 12, + "priority": 22, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "hull-sections", "amount": 14 }, + { "itemId": "ship-equipment", "amount": 8 }, + { "itemId": "water", "amount": 10 } + ], + "outputs": [ + { "itemId": "habitat-ring-module", "amount": 1 } + ] + }, + { + "id": "bulk-bay-module-assembly", + "label": "Bulk Bay Module Assembly", + "facilityCategory": "station", + "duration": 8, + "priority": 18, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 16 }, + { "itemId": "hull-sections", "amount": 10 } + ], + "outputs": [ + { "itemId": "bulk-bay-module", "amount": 1 } + ] + }, + { + "id": "container-bay-module-assembly", + "label": "Container Bay Module Assembly", + "facilityCategory": "station", + "duration": 8, + "priority": 18, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 12 }, + { "itemId": "ship-equipment", "amount": 4 } + ], + "outputs": [ + { "itemId": "container-bay-module", "amount": 1 } + ] + }, + { + "id": "liquid-tank-module-assembly", + "label": "Liquid Tank Module Assembly", + "facilityCategory": "station", + "duration": 8, + "priority": 18, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 14 }, + { "itemId": "ship-equipment", "amount": 4 } + ], + "outputs": [ + { "itemId": "liquid-tank-module", "amount": 1 } + ] + }, + { + "id": "gas-tank-module-assembly", + "label": "Gas Tank Module Assembly", + "facilityCategory": "station", + "duration": 8, + "priority": 18, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 14 }, + { "itemId": "ship-equipment", "amount": 4 } + ], + "outputs": [ + { "itemId": "gas-tank-module", "amount": 1 } + ] + }, + { + "id": "mining-turret-module-assembly", + "label": "Mining Turret Module Assembly", + "facilityCategory": "station", + "duration": 9, + "priority": 24, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 18 }, + { "itemId": "ship-equipment", "amount": 6 } + ], + "outputs": [ + { "itemId": "mining-turret-module", "amount": 1 } + ] + }, + { + "id": "gas-extractor-module-assembly", + "label": "Gas Extractor Module Assembly", + "facilityCategory": "station", + "duration": 9, + "priority": 24, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 18 }, + { "itemId": "ship-equipment", "amount": 6 }, + { "itemId": "gas", "amount": 8 } + ], + "outputs": [ + { "itemId": "gas-extractor-module", "amount": 1 } + ] + }, + { + "id": "fabricator-array-module-assembly", + "label": "Fabricator Array Module Assembly", + "facilityCategory": "station", + "duration": 11, + "priority": 20, + "requiredModules": ["component-factory", "container-bay"], + "inputs": [ + { "itemId": "refined-metals", "amount": 24 }, + { "itemId": "ship-equipment", "amount": 10 } + ], + "outputs": [ + { "itemId": "fabricator-array-module", "amount": 1 } + ] + }, + { + "id": "frigate-construction", + "label": "Frigate Construction", + "facilityCategory": "station", + "duration": 24, + "priority": 90, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "frigate", + "inputs": [ + { "itemId": "hull-sections", "amount": 26 }, + { "itemId": "fuel", "amount": 40 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "gun-turret-module", "amount": 1 } + ], + "outputs": [] + }, + { + "id": "destroyer-construction", + "label": "Destroyer Construction", + "facilityCategory": "station", + "duration": 34, + "priority": 70, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "destroyer", + "inputs": [ + { "itemId": "hull-sections", "amount": 44 }, + { "itemId": "fuel", "amount": 60 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "gun-turret-module", "amount": 2 } + ], + "outputs": [] + }, + { + "id": "cruiser-construction", + "label": "Cruiser Construction", + "facilityCategory": "station", + "duration": 42, + "priority": 54, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "cruiser", + "inputs": [ + { "itemId": "hull-sections", "amount": 60 }, + { "itemId": "fuel", "amount": 80 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "gun-turret-module", "amount": 2 } + ], + "outputs": [] + }, + { + "id": "carrier-construction", + "label": "Carrier Construction", + "facilityCategory": "station", + "duration": 60, + "priority": 28, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "carrier", + "inputs": [ + { "itemId": "hull-sections", "amount": 120 }, + { "itemId": "fuel", "amount": 140 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "carrier-bay-module", "amount": 2 }, + { "itemId": "gun-turret-module", "amount": 1 }, + { "itemId": "habitat-ring-module", "amount": 1 } + ], + "outputs": [] + }, + { + "id": "hauler-construction", + "label": "Hauler Construction", + "facilityCategory": "station", + "duration": 26, + "priority": 8, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "hauler", + "inputs": [ + { "itemId": "hull-sections", "amount": 34 }, + { "itemId": "fuel", "amount": 40 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "liquid-tank-module", "amount": 1 } + ], + "outputs": [] + }, + { + "id": "constructor-construction", + "label": "Constructor Construction", + "facilityCategory": "station", + "duration": 30, + "priority": 8, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "constructor", + "inputs": [ + { "itemId": "hull-sections", "amount": 42 }, + { "itemId": "fuel", "amount": 44 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "fabricator-array-module", "amount": 1 }, + { "itemId": "container-bay-module", "amount": 1 } + ], + "outputs": [] + }, + { + "id": "miner-construction", + "label": "Miner Construction", + "facilityCategory": "station", + "duration": 28, + "priority": 8, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "miner", + "inputs": [ + { "itemId": "hull-sections", "amount": 34 }, + { "itemId": "fuel", "amount": 42 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "mining-turret-module", "amount": 1 }, + { "itemId": "bulk-bay-module", "amount": 1 } + ], + "outputs": [] + }, + { + "id": "gas-harvester-construction", + "label": "Gas Harvester Construction", + "facilityCategory": "station", + "duration": 28, + "priority": 8, + "requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"], + "shipOutputId": "gas-miner", + "inputs": [ + { "itemId": "hull-sections", "amount": 34 }, + { "itemId": "fuel", "amount": 42 }, + { "itemId": "command-bridge-module", "amount": 1 }, + { "itemId": "reactor-core-module", "amount": 1 }, + { "itemId": "capacitor-bank-module", "amount": 1 }, + { "itemId": "ion-drive-module", "amount": 1 }, + { "itemId": "ftl-core-module", "amount": 1 }, + { "itemId": "gas-extractor-module", "amount": 1 }, + { "itemId": "gas-tank-module", "amount": 1 } + ], + "outputs": [] + }, { "id": "trade-hub-assembly", "label": "Trade Hub Assembly", diff --git a/shared/data/ships.json b/shared/data/ships.json index bbedcbb..68bf0cd 100644 --- a/shared/data/ships.json +++ b/shared/data/ships.json @@ -42,7 +42,7 @@ "hullColor": "#314562", "size": 10, "maxHealth": 340, - "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret", "docking-clamps"] + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret"] }, { "id": "carrier", @@ -70,13 +70,13 @@ "ftlSpeed": 2600, "spoolTime": 3.3, "cargoCapacity": 180, - "cargoKind": "container", - "cargoItemId": "drone-parts", + "cargoKind": "bulk-liquid", + "cargoItemId": "energy-cell", "color": "#b0ff8d", "hullColor": "#365f2a", "size": 8, "maxHealth": 180, - "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "container-bay", "docking-clamps"] + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "liquid-tank"] }, { "id": "constructor", @@ -93,7 +93,7 @@ "hullColor": "#2d5d47", "size": 9, "maxHealth": 220, - "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "fabricator-array", "container-bay", "docking-clamps"] + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "fabricator-array", "container-bay"] }, { "id": "miner", @@ -110,7 +110,7 @@ "hullColor": "#68552b", "size": 6, "maxHealth": 150, - "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"] + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay"] }, { "id": "gas-miner", @@ -127,6 +127,6 @@ "hullColor": "#2a5668", "size": 6, "maxHealth": 150, - "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank", "docking-clamps"] + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank"] } ]