diff --git a/NEXT-STEPS.md b/NEXT-STEPS.md index ac3f3b8..5643951 100644 --- a/NEXT-STEPS.md +++ b/NEXT-STEPS.md @@ -1,101 +1,74 @@ # Next Steps -## Galaxy / Viewer Fit +## Immediate Backend Follow-Up -The world is now much larger and more varied: +The simulation now has the beginnings of a self-sustaining bootstrap: -- roughly galaxy-scale system counts -- elevated system height variance -- procedural orbital metadata -- moons, rings, binary stars, asteroid belts, gas clouds -- special-case `Sol` system content +- ore miner +- gas miner +- station-side `gas -> fuel` +- constructor-led station module installation +- pad-based docking with reservation +- timed mining, spool-up, docking, and undocking -The next step is not “make the map larger.” That is already done for the current runtime. +The next step is to close the remaining logistics and simulation gaps around that bootstrap. Recommended work: -- tune galaxy readability at scale - - better starfield depth cues - - stronger color/size differentiation for star classes - - improved system label decluttering -- add galaxy navigation affordances - - jump-to-system search - - constellation / region overlays - - bookmarks for notable systems such as `Sol` -- tighten viewer performance - - reduce orbit/moon draw cost when zoomed out - - pool or simplify distant celestial meshes - - profile high-system-count scenes +- add constructor material logistics + - allow the constructor to fetch module materials instead of assuming they are already on the target station + - add pickup / load phases and delivery priorities +- expose station installed modules and active construction to the viewer + - installed modules + - current build target + - build progress +- harden dock management + - make dock eligibility depend on dock type / ship class if needed + - surface pad occupancy in debug UI +- add stronger action timing coverage + - timed final approach / berthing if desired + - timed station-side processing queues if multiple modules run concurrently +- add recovery behavior for stranded ships + - rescue / tow / emergency refuel + - better handling after full fuel and power depletion -## Economic Growth +## Economy / Logistics -The current economy already supports: +The economy now has a more explicit resource chain: 1. mining ore -2. hauling to refining -3. refining / fabricating goods -4. spending those goods on ships and outposts +2. harvesting gas +3. processing `gas -> fuel` +4. refining ore +5. spending refined goods on station growth -The next step is not “invent a use for refined goods.” That use already exists. - -The next step is to make faction growth more intentional and legible. +The next step is to make those logistics deliberate instead of bootstrap-scripted. Recommended work: -- make shipbuilding priorities reactive - - build more miners / haulers when ore throughput is low - - build escorts when industrial losses rise - - build warships when frontier pressure rises -- make expansion logic consume the economy more visibly - - use industrial stock to claim and fortify central systems -- expose production pressure in UI - - show ore throughput - - show fabricated goods - - show queued faction priorities -- make resource type differences matter - - ore belts vs gas clouds - - gas-aware logistics and production choices - -## Pirate Harassment - -Pirates already exist and can raid, fight, and destroy ships. - -What they are missing is sharper industrial harassment behavior. - -Recommended work: - -- prioritize miners, haulers, and refinery approaches as pirate targets -- add local threat weighting around: - - resource nodes - - refinery docking lanes - - undefended transport routes -- force empires to react by: - - escorting miners - - patrolling refinery systems - - building defensive stations sooner - -This will make the industrial loop produce strategic tension instead of just passive growth. - -## High-Value Gameplay Sequence - -The most useful short-term gameplay loop to solidify is: - -1. miners feed refining -2. refining feeds ship production -3. pirates harass industry -4. empires respond with escorts, patrols, and new outposts -5. stronger economies produce stronger military presence -6. system control shifts based on industrial strength and protection - -That turns the simulation into a real strategy loop. +- split logistics roles more clearly + - ore miner + - gas miner + - hauler / constructor transport +- make station build priorities responsive + - fuel chain first when reserves are low + - ore refining when fuel is stable + - docking expansion when traffic backs up +- make fuel scarcity visible in debug UI + - fuel throughput + - gas stock + - dock contention +- move away from generic node selection + - let miners prefer nearby valid nodes + - factor travel cost and dock turnaround into throughput planning ## Concrete Implementation Order -1. Add viewer-scale performance controls for the larger galaxy. -2. Add faction production heuristics based on current economy and losses. -3. Make pirate target selection explicitly prefer economic targets. -4. Surface faction stocks, throughput, and build priorities in the HUD/debug views. -5. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`. +1. Add constructor pickup / delivery behavior for module materials. +2. Expose station installed modules, dock pads, and active construction in the contracts and viewer. +3. Add rescue / recovery behavior for power-starved ships. +4. Add faction build priorities based on fuel, ore throughput, and dock saturation. +5. Improve pirate targeting so industrial ships and docking lanes are high-value harassment targets. 6. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules. ## Network / Multiplayer @@ -133,68 +106,31 @@ Recommended work: - versioning - reconnect / catch-up semantics +## Viewer / Debugging + +The viewer still works as an observer/debug client first. + +Recommended work: + +- fix the current `followedShipId` regression in `GameViewer.ts` +- show station module state and construction state +- show dock occupancy and waiting ships +- expose fuel-chain health + - gas stock + - fuel stock + - refinery / processor activity +- improve event typing for: + - dock request + - dock granted + - refuel + - construction started / completed + ## Interest Management -The current stream is world-wide. - -That means every observer receives deltas for the full simulation, even when only looking at one part of space. +The stream is still world-wide. Recommended work: - add observer/view-scoped subscriptions - - visible systems - - nearby ships / stations / nodes - - faction-scoped or player-scoped channels later -- support subscribe / unsubscribe as camera focus changes - send only relevant deltas per observer -- keep coarse strategic updates available for off-screen context - - system ownership - - major combat - - economy summaries - -This is the key step that makes many simultaneous observers practical without broadcasting the entire world to everyone. - -## Replication Quality - -The backend already sends: - -- initial snapshot -- incremental deltas -- event records - -Recommended work: - -- add stronger event typing - - spawn - - destroy - - dock - - undock - - cargo transfer - - combat hit / kill -- improve interpolation and extrapolation policies per entity type -- add per-layer presentation tuning in the viewer - - smoother fade bands between local / system / universe - - better visual density control at galaxy scale - - moon/orbit LOD based on zoom level -- add resync handling when a client falls too far behind -- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy - -## Celestial Depth - -The current celestial layer is procedurally rich, but still mostly decorative outside of resource nodes. - -Recommended work: - -- add authored moon metadata when needed - - labels - - resource-bearing moons - - special landmarks -- support multiple belts / cloud bands per system explicitly -- add stellar gameplay hooks - - hazardous neutron-star systems - - high-value binary systems - - rich-gas outer systems -- expose notable-system summaries in the viewer - - star class - - resource profile - - moon count +- keep strategic summaries for off-screen context diff --git a/SESSION.md b/SESSION.md index 61cfa39..b51313c 100644 --- a/SESSION.md +++ b/SESSION.md @@ -108,15 +108,27 @@ The backend simulation already includes: - autonomous ships - orbital travel -- docking and undocking +- pad-based docking and undocking + - stations expose docking capacity through installed dock-bay modules + - ships reserve an empty pad before docking + - ships wait in a holding pattern when no pad is available - mining and refinery delivery - module-gated ship and station capabilities - - ships require fitted modules such as reactor, capacitor, and mining or gun turrets - - stations require fitted modules such as power, refinery, and storage modules + - ships require fitted modules such as reactor, capacitor, mining turret, gas extractor, or gun turrets + - stations require installed modules such as power, docking, refinery, fuel processing, and storage modules - fuel-to-energy power simulation - - ship reactors consume `gas` fuel to charge capacitors - - station power cores consume `gas` fuel to charge station energy buffers + - gas clouds provide raw `gas` + - station fuel processors convert `gas` into `fuel` + - ship reactors consume `fuel` to charge capacitors + - station power cores consume `fuel` to charge station energy buffers - powered actions stop when fuel and energy are depleted +- constructor-led station module construction + - stations now track installed modules per instance instead of relying only on static constructible definitions + - module construction uses station inventory plus timed build progress +- explicit action timing in the control loop + - mining now runs on a fixed cycle + - warp and FTL travel require spool time + - docking and undocking have explicit durations - refining / fabrication - faction growth through ship and outpost production - pirate pressure and combat @@ -129,7 +141,7 @@ The backend simulation already includes: - systems in galaxy space - in-system entities in local space - item-based inventories for ships and stations - - ore, refined metals, gas fuel, and cargo now flow through per-item inventories instead of ad hoc stock fields + - ore, refined metals, raw gas, fuel, and cargo now flow through per-item inventories instead of ad hoc stock fields The runtime model still follows the intended layered control architecture: @@ -164,13 +176,20 @@ The runtime model still follows the intended layered control architecture: - one modular station - one constructor ship - one mining ship +- added a gas mining ship for bootstrap fuel logistics - moved the starting ships close to the `helios` star and next to each other - added modular ship and station data - new station power and storage modules - - new ship reactor, capacitor, mining turret, and gun turret modules + - new fuel processing and docking bay modules + - new ship reactor, capacitor, mining turret, gas extractor, and gun turret modules - refactored simulation inventories to per-item storage - stations and ships now replicate inventories instead of specialized ore/refined/cargo counters -- added fuel-driven power generation and energy consumption in the simulation loop +- split raw `gas` from burnable `fuel` in the simulation loop +- added module recipe data and per-station installed-module runtime state +- added constructor-led station module construction for the bootstrap station +- added gas harvesting, gas-to-fuel processing, and explicit ship refueling behavior +- reworked docking into pad reservation with visible stand-off positions instead of snapping ships into station centers +- added action timing for mining cycles, warp / FTL spool-up, and undocking - replaced the viewer bottom faction strip with a horizontal ship-card debugging rail - added movable, resizable, multi-window history panels in the viewer - fixed the auto-miner undock controller transition @@ -190,9 +209,14 @@ The runtime model still follows the intended layered control architecture: - the galaxy is much larger now, so viewer performance and visual density need active tuning - moon rendering is procedural from counts, not authored moon-by-moon data - resource extraction behavior still treats all resource nodes generically -- item inventories exist, but storage enforcement and module-slot restrictions are still lightweight - - cargo/storage compatibility is mostly data-convention driven +- item inventories exist, but storage/module restrictions are still partial + - station storage capacity is now enforced by storage class and installed module + - ship cargo compatibility is still mostly data-convention driven - hull-specific module restrictions are not enforced yet +- constructor logic only builds from station-local inventory + - it does not yet fetch module materials from other stations or ships +- station installed modules and active construction are not yet exposed in the viewer contract +- some viewer follow-camera code is currently broken by a pre-existing missing `followedShipId` property - piracy and faction growth are still functional rather than strategically deep - no persistence for saves, seeds, or reconnect state @@ -205,26 +229,31 @@ The runtime model still follows the intended layered control architecture: - [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) - simulation advancement - snapshot / delta composition for galaxy-space systems and local-space entities - - inventory, fuel, and energy processing - - auto-miner undock state transition fix + - inventory, gas/fuel, and energy processing + - station module construction + - gas harvesting, refueling, and pad-based docking + - action timing for mining, spool-up, docking, and undocking - [apps/backend/Simulation/ScenarioLoader.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) - faction bootstrap - galaxy generation - special systems - procedural celestial/resource content - normalization of authored placements into system-local space - - minimal startup seeding for ships, station, and fuel + - minimal startup seeding for ships, station, fuel, and module materials - [apps/backend/Contracts/WorldContracts.cs](/home/jbourdon/repos/space-game/apps/backend/Contracts/WorldContracts.cs) - snapshot and delta contract shape + - station dock-pad count - [apps/backend/Simulation/RuntimeModels.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) - runtime vector math and world model - per-item inventories on ships and stations + - per-station installed modules and docking pad assignments - [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) - camera, selection, streaming integration, and presentation - layered local/remote system presentation - orbital reconstruction and moon rendering - projected shell markers and hover labels - ship card list and multi-window history debugging UI + - station HUD docked/pad count readout - [apps/viewer/src/style.css](/home/jbourdon/repos/space-game/apps/viewer/src/style.css) - HUD layout - ship-card rail @@ -235,14 +264,19 @@ The runtime model still follows the intended layered control architecture: - viewer-side snapshot contract for galaxy-space systems and local-space entities - [shared/data](/home/jbourdon/repos/space-game/shared/data) - scenario and world data definitions + - `module-recipes.json` now defines timed module construction costs ## Validation Validation passing at the end of this session: - `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj` + +Validation currently failing / blocked: + - `cd apps/viewer && npm run build` + - fails because `apps/viewer/src/GameViewer.ts` references a missing `followedShipId` property ## Last Commit -- `1747d84` +- `ef62577` diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index 100b93b..f746657 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -86,6 +86,7 @@ public sealed record StationSnapshot( Vector3Dto LocalPosition, string Color, int DockedShips, + int DockingPads, float EnergyStored, IReadOnlyList Inventory, string FactionId); @@ -98,6 +99,7 @@ public sealed record StationDelta( Vector3Dto LocalPosition, string Color, int DockedShips, + int DockingPads, float EnergyStored, IReadOnlyList Inventory, string FactionId); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 99c707a..999f58d 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -5,8 +5,10 @@ public sealed class BalanceDefinition public float YPlane { get; set; } public float ArrivalThreshold { get; set; } public float MiningRate { get; set; } + public float MiningCycleSeconds { get; set; } public float TransferRate { get; set; } public float DockingDuration { get; set; } + public float UndockingDuration { get; set; } public float UndockDistance { get; set; } public EnergyBalanceDefinition Energy { get; set; } = new(); public FuelBalanceDefinition Fuel { get; set; } = new(); @@ -68,6 +70,19 @@ public sealed class ItemDefinition public string Summary { get; set; } = string.Empty; } +public sealed class RecipeInputDefinition +{ + public required string ItemId { get; set; } + public float Amount { get; set; } +} + +public sealed class ModuleRecipeDefinition +{ + public required string ModuleId { get; set; } + public float Duration { get; set; } + public required List Inputs { get; set; } +} + public sealed class PlanetDefinition { public required string Label { get; set; } diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index 9bd22c2..b7054c0 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -14,6 +14,7 @@ public sealed class SimulationWorld public required List Factions { get; init; } public required Dictionary ShipDefinitions { get; init; } public required Dictionary ItemDefinitions { get; init; } + public required Dictionary ModuleRecipes { get; init; } public int TickIntervalMs { get; init; } = 200; public DateTimeOffset GeneratedAtUtc { get; set; } } @@ -43,13 +44,24 @@ public sealed class StationRuntime public required ConstructibleDefinition Definition { get; init; } public required Vector3 Position { get; init; } public required string FactionId { get; init; } + public HashSet InstalledModules { get; } = new(StringComparer.Ordinal); public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public Dictionary DockingPadAssignments { get; } = new(); public float EnergyStored { get; set; } public float ProcessTimer { get; set; } public HashSet DockedShipIds { get; } = []; + public ModuleConstructionRuntime? ActiveConstruction { get; set; } public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class ModuleConstructionRuntime +{ + public required string ModuleId { get; init; } + public float ProgressSeconds { get; set; } + public float RequiredSeconds { get; init; } + public string AssignedConstructorShipId { get; set; } = string.Empty; +} + public sealed class ShipRuntime { public required string Id { get; init; } @@ -67,6 +79,7 @@ public sealed class ShipRuntime public Dictionary Inventory { get; } = new(StringComparer.Ordinal); public float EnergyStored { get; set; } public string? DockedStationId { get; set; } + public int? AssignedDockingPadIndex { get; set; } public float Health { get; set; } public List History { get; } = []; public string LastSignature { get; set; } = string.Empty; @@ -98,8 +111,10 @@ public sealed class DefaultBehaviorRuntime { public required string Kind { get; set; } public string? AreaSystemId { get; set; } + public string? StationId { get; set; } public string? RefineryId { get; set; } public string? NodeId { get; set; } + public string? ModuleId { get; set; } public string? Phase { get; set; } public List PatrolPoints { get; set; } = []; public int PatrolIndex { get; set; } diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index aa40eaa..1eda561 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -88,11 +88,13 @@ public sealed class ScenarioLoader var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); var items = Read>("items.json"); + var moduleRecipes = Read>("module-recipes.json"); var balance = Read("balance.json"); var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); + var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal); var systemRuntimes = systems .Select((definition) => new SystemRuntime { @@ -141,17 +143,23 @@ public sealed class ScenarioLoader Position = ResolveStationPosition(system, plan, balance), FactionId = plan.FactionId ?? DefaultFactionId, }); + + foreach (var moduleId in definition.Modules) + { + stations[^1].InstalledModules.Add(moduleId); + } } foreach (var station in stations) { - station.Inventory["gas"] = 320f; + station.Inventory["fuel"] = 240f; + station.Inventory["refined-metals"] = 120f; } var refinery = stations.FirstOrDefault((station) => - HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank") && + HasInstalledModules(station, "power-core", "liquid-tank") && station.SystemId == scenario.MiningDefaults.RefinerySystemId) - ?? stations.FirstOrDefault((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")); + ?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank")); var patrolRoutes = scenario.PatrolRoutes.ToDictionary( (route) => route.SystemId, @@ -185,9 +193,15 @@ public sealed class ScenarioLoader }); shipsRuntime[^1].Inventory["gas"] = definition.Id switch + { + _ => 0f, + }; + shipsRuntime[^1].Inventory.Remove("gas"); + shipsRuntime[^1].Inventory["fuel"] = definition.Id switch { "constructor" => 90f, "miner" => 90f, + "gas-miner" => 90f, _ => 120f, }; } @@ -208,6 +222,7 @@ public sealed class ScenarioLoader Factions = factions, ShipDefinitions = shipDefinitions, ItemDefinitions = itemDefinitions, + ModuleRecipes = moduleRecipeDefinitions, GeneratedAtUtc = DateTimeOffset.UtcNow, }; } @@ -818,13 +833,33 @@ public sealed class ScenarioLoader IReadOnlyDictionary> patrolRoutes, StationRuntime? refinery) { + if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "construct-station", + StationId = refinery.Id, + Phase = "travel-to-station", + }; + } + + if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null) + { + return new DefaultBehaviorRuntime + { + Kind = "auto-harvest-gas", + StationId = refinery.Id, + Phase = "travel-to-node", + }; + } + if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null) { return new DefaultBehaviorRuntime { Kind = "auto-mine", AreaSystemId = scenario.MiningDefaults.NodeSystemId, - RefineryId = refinery.Id, + StationId = refinery.Id, Phase = "travel-to-node", }; } @@ -883,6 +918,9 @@ public sealed class ScenarioLoader private static bool HasModules(ConstructibleDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); + private static bool HasInstalledModules(StationRuntime station, params string[] modules) => + modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); + private static bool HasModules(ShipDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index 812a415..09ae246 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -100,6 +100,7 @@ public sealed class SimulationEngine station.LocalPosition, station.Color, station.DockedShips, + station.DockingPads, station.EnergyStored, station.Inventory, station.FactionId)).ToList(), @@ -232,7 +233,7 @@ public sealed class SimulationEngine $"{node.SystemId}|{node.OreRemaining:0.###}"; private static string BuildStationSignature(StationRuntime station) => - $"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}"; + $"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{string.Join(",", station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}"; private static string BuildShipSignature(ShipRuntime ship) => string.Join("|", @@ -251,7 +252,7 @@ public sealed class SimulationEngine ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, GetShipCargoAmount(ship).ToString("0.###"), - GetInventoryAmount(ship.Inventory, "gas").ToString("0.###"), + GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"), ship.EnergyStored.ToString("0.###"), ship.Health.ToString("0.###")); @@ -282,6 +283,7 @@ public sealed class SimulationEngine ToDto(station.Position), station.Definition.Color, station.DockedShipIds.Count, + GetDockingPadCount(station), station.EnergyStored, ToInventoryEntries(station.Inventory), station.FactionId); @@ -358,8 +360,32 @@ public sealed class SimulationEngine { foreach (var station in world.Stations) { - if (!HasRefineryCapability(station.Definition) || GetInventoryAmount(station.Inventory, "ore") < 60f) + if (CanProcessFuel(station) && GetInventoryAmount(station.Inventory, "gas") >= 20f) { + if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + station.ProcessTimer = 0f; + continue; + } + + station.ProcessTimer += deltaSeconds; + if (station.ProcessTimer >= 6f) + { + station.ProcessTimer = 0f; + RemoveInventory(station.Inventory, "gas", 20f); + var addedFuel = TryAddStationInventory(world, station, "fuel", 18f); + if (addedFuel > 0.01f) + { + events.Add(new SimulationEventRecord("station", station.Id, "fuel-processed", $"{station.Definition.Label} processed 20 gas into {addedFuel:0.#} fuel", DateTimeOffset.UtcNow)); + } + } + + continue; + } + + if (!HasRefineryCapability(station) || GetInventoryAmount(station.Inventory, "ore") < 60f) + { + station.ProcessTimer = 0f; continue; } @@ -377,24 +403,27 @@ public sealed class SimulationEngine station.ProcessTimer = 0f; RemoveInventory(station.Inventory, "ore", 60f); - AddInventory(station.Inventory, "refined-metals", 60f); + var refined = TryAddStationInventory(world, station, "refined-metals", 60f); + if (refined <= 0.01f) + { + continue; + } + events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow)); var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId); if (faction is not null) { - faction.GoodsProduced += 60f; - faction.Credits += 18f; + faction.GoodsProduced += refined; + faction.Credits += refined * 0.3f; } } } - private static bool HasRefineryCapability(ConstructibleDefinition definition) => - definition.Modules.Contains("refinery-stack", StringComparer.Ordinal) - && definition.Modules.Contains("power-core", StringComparer.Ordinal) - && definition.Modules.Contains("liquid-tank", StringComparer.Ordinal) - && definition.Modules.Contains("gas-tank", StringComparer.Ordinal) - && definition.Storage.ContainsKey("bulk-solid") - && definition.Storage.ContainsKey("manufactured"); + private static bool HasRefineryCapability(StationRuntime station) => + HasStationModules(station, "refinery-stack", "power-core", "bulk-bay"); + + private static bool CanProcessFuel(StationRuntime station) => + HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); private static bool HasShipModules(ShipDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); @@ -409,7 +438,7 @@ public sealed class SimulationEngine var previousEnergy = station.EnergyStored; GenerateStationEnergy(station, deltaSeconds); - if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "gas") <= 0.01f) + if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } @@ -425,7 +454,7 @@ public sealed class SimulationEngine var previousEnergy = ship.EnergyStored; GenerateShipEnergy(ship, world, deltaSeconds); - if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "gas") <= 0.01f) + if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } @@ -433,22 +462,22 @@ public sealed class SimulationEngine private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) { - var powerCores = CountModules(station.Definition.Modules, "power-core"); - var tanks = CountModules(station.Definition.Modules, "gas-tank"); + var powerCores = CountModules(station.InstalledModules, "power-core"); + var tanks = CountModules(station.InstalledModules, "liquid-tank"); if (powerCores <= 0 || tanks <= 0) { station.EnergyStored = 0f; - station.Inventory.Remove("gas"); + station.Inventory.Remove("fuel"); return; } var energyCapacity = powerCores * StationEnergyPerPowerCore; - var fuelStored = GetInventoryAmount(station.Inventory, "gas"); + var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); - station.Inventory["gas"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); + station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); return; } @@ -457,7 +486,7 @@ public sealed class SimulationEngine var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * StationFuelToEnergyRatio; - RemoveInventory(station.Inventory, "gas", consumedFuel); + RemoveInventory(station.Inventory, "fuel", consumedFuel); station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); } @@ -468,18 +497,18 @@ public sealed class SimulationEngine if (reactors <= 0 || capacitors <= 0) { ship.EnergyStored = 0f; - ship.Inventory.Remove("gas"); + ship.Inventory.Remove("fuel"); return; } var energyCapacity = capacitors * CapacitorEnergyPerModule; var fuelCapacity = reactors * ShipFuelPerReactor; - var fuelStored = GetInventoryAmount(ship.Inventory, "gas"); + var fuelStored = GetInventoryAmount(ship.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored); if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity); - ship.Inventory["gas"] = MathF.Min(fuelStored, fuelCapacity); + ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity); return; } @@ -488,7 +517,7 @@ public sealed class SimulationEngine var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * ShipFuelToEnergyRatio; - RemoveInventory(ship.Inventory, "gas", consumedFuel); + RemoveInventory(ship.Inventory, "fuel", consumedFuel); ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated); } @@ -547,6 +576,211 @@ public sealed class SimulationEngine return removed; } + private static bool HasStationModules(StationRuntime station, params string[] modules) => + modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); + + private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) => + node.ItemId switch + { + "ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"), + "gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"), + _ => false, + }; + + private static float GetShipFuelCapacity(ShipRuntime ship) => + CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; + + private static bool NeedsRefuel(ShipRuntime ship) => + GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f); + + private static string? GetStorageRequirement(string storageClass) => + storageClass switch + { + "bulk-solid" => "bulk-bay", + "bulk-liquid" => "liquid-tank", + "bulk-gas" => "gas-tank", + _ => null, + }; + + private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) + { + if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + return 0f; + } + + var storageClass = itemDefinition.Storage; + var requiredModule = GetStorageRequirement(storageClass); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + { + return 0f; + } + + if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity)) + { + return 0f; + } + + var used = station.Inventory + .Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass) + .Sum((entry) => entry.Value); + var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); + if (accepted <= 0.01f) + { + return 0f; + } + + AddInventory(station.Inventory, itemId, accepted); + return accepted; + } + + private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) => + recipe.Inputs.All((input) => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount); + + private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) + { + if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal)) + { + return true; + } + + if (station.ActiveConstruction is not null) + { + return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) + && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); + } + + if (!CanStartModuleConstruction(station, recipe)) + { + return false; + } + + foreach (var input in recipe.Inputs) + { + RemoveInventory(station.Inventory, input.ItemId, input.Amount); + } + + station.ActiveConstruction = new ModuleConstructionRuntime + { + ModuleId = recipe.ModuleId, + RequiredSeconds = recipe.Duration, + AssignedConstructorShipId = shipId, + }; + + return true; + } + + private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) + { + foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) + { + if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) + && world.ModuleRecipes.ContainsKey(moduleId)) + { + return moduleId; + } + } + + return null; + } + + private static int GetDockingPadCount(StationRuntime station) => + CountModules(station.InstalledModules, "dock-bay-small") * 2; + + private static int? ReserveDockingPad(StationRuntime station, string shipId) + { + if (station.DockingPadAssignments.FirstOrDefault((entry) => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing + && !string.IsNullOrEmpty(existing.Value)) + { + return existing.Key; + } + + var padCount = GetDockingPadCount(station); + for (var padIndex = 0; padIndex < padCount; padIndex += 1) + { + if (station.DockingPadAssignments.ContainsKey(padIndex)) + { + continue; + } + + station.DockingPadAssignments[padIndex] = shipId; + return padIndex; + } + + return null; + } + + private static void ReleaseDockingPad(StationRuntime station, string shipId) + { + var assignment = station.DockingPadAssignments.FirstOrDefault((entry) => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); + if (!string.IsNullOrEmpty(assignment.Value)) + { + station.DockingPadAssignments.Remove(assignment.Key); + } + } + + private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) + { + var padCount = Math.Max(1, GetDockingPadCount(station)); + var angle = ((MathF.PI * 2f) / padCount) * padIndex; + var radius = station.Definition.Radius + 14f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + private static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) + { + var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); + var angle = (hash % 360) * (MathF.PI / 180f); + var radius = station.Definition.Radius + 34f; + return new Vector3( + station.Position.X + (MathF.Cos(angle) * radius), + station.Position.Y, + station.Position.Z + (MathF.Sin(angle) * radius)); + } + + private static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) + { + if (padIndex is null) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var pad = GetDockingPadPosition(station, padIndex.Value); + var dx = pad.X - station.Position.X; + var dz = pad.Z - station.Position.Z; + var length = MathF.Sqrt((dx * dx) + (dz * dz)); + if (length <= 0.001f) + { + return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); + } + + var scale = distance / length; + return new Vector3( + pad.X + (dx * scale), + station.Position.Y, + pad.Z + (dz * scale)); + } + + private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => + ship.AssignedDockingPadIndex is int padIndex + ? GetDockingPadPosition(station, padIndex) + : station.Position; + + private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds) + { + ship.ActionTimer += deltaSeconds; + if (ship.ActionTimer < requiredSeconds) + { + return false; + } + + ship.ActionTimer = 0f; + return true; + } + private static float GetShipCargoAmount(ShipRuntime ship) { var cargoItemId = ship.Definition.CargoItemId; @@ -578,7 +812,19 @@ public sealed class SimulationEngine if (ship.DefaultBehavior.Kind == "auto-mine") { - PlanAutoMine(ship, world); + PlanResourceHarvest(ship, world, "ore", "mining-turret"); + return; + } + + if (ship.DefaultBehavior.Kind == "auto-harvest-gas") + { + PlanResourceHarvest(ship, world, "gas", "gas-extractor"); + return; + } + + if (ship.DefaultBehavior.Kind == "construct-station") + { + PlanStationConstruction(ship, world); return; } @@ -601,18 +847,18 @@ public sealed class SimulationEngine }; } - private void PlanAutoMine(ShipRuntime ship, SimulationWorld world) + private void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) { var behavior = ship.DefaultBehavior; - var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId); + var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.StationId); var node = behavior.NodeId is null ? world.Nodes - .Where((candidate) => candidate.SystemId == behavior.AreaSystemId) + .Where((candidate) => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId) .OrderByDescending((candidate) => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId); - if (refinery is null || node is null) + if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) { behavior.Kind = "idle"; ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; @@ -620,6 +866,26 @@ public sealed class SimulationEngine } behavior.NodeId ??= node.Id; + if (ship.DockedStationId == refinery.Id) + { + if (GetShipCargoAmount(ship) > 0.01f) + { + behavior.Phase = "unload"; + } + else if (NeedsRefuel(ship)) + { + behavior.Phase = "refuel"; + } + else if (behavior.Phase is "dock" or "unload" or "refuel") + { + behavior.Phase = "undock"; + } + } + else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") + { + behavior.Phase = "travel-to-station"; + } + switch (behavior.Phase) { case "extract": @@ -662,6 +928,16 @@ public sealed class SimulationEngine Threshold = 0f, }; break; + case "refuel": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "refuel", + TargetEntityId = refinery.Id, + TargetSystemId = refinery.SystemId, + TargetPosition = refinery.Position, + Threshold = 0f, + }; + break; case "undock": ship.ControllerTask = new ControllerTaskRuntime { @@ -686,6 +962,101 @@ public sealed class SimulationEngine } } + private void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) + { + var behavior = ship.DefaultBehavior; + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == behavior.StationId); + if (station is null) + { + behavior.Kind = "idle"; + ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; + return; + } + + var moduleId = GetNextStationModuleToBuild(station, world); + behavior.ModuleId = moduleId; + if (moduleId is null) + { + ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; + return; + } + + if (ship.DockedStationId == station.Id) + { + if (NeedsRefuel(ship)) + { + behavior.Phase = "refuel"; + } + else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId])) + { + behavior.Phase = "construct-module"; + } + else + { + behavior.Phase = "wait-for-materials"; + } + } + else if (behavior.Phase is not "travel-to-station" and not "dock") + { + behavior.Phase = "travel-to-station"; + } + + switch (behavior.Phase) + { + case "dock": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "dock", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = station.Definition.Radius + 4f, + }; + break; + case "refuel": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "refuel", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + case "construct-module": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "construct-module", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + case "wait-for-materials": + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "idle", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = 0f, + }; + break; + default: + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = "travel", + TargetEntityId = station.Id, + TargetSystemId = station.SystemId, + TargetPosition = station.Position, + Threshold = station.Definition.Radius + 8f, + }; + behavior.Phase = "travel-to-station"; + break; + } + } + private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; @@ -704,6 +1075,10 @@ public sealed class SimulationEngine return UpdateDock(ship, world, deltaSeconds); case "unload": return UpdateUnload(ship, world, deltaSeconds); + case "refuel": + return UpdateRefuel(ship, world, deltaSeconds); + case "construct-module": + return UpdateConstructModule(ship, world, deltaSeconds); case "undock": return UpdateUndock(ship, world, deltaSeconds); default: @@ -727,6 +1102,7 @@ public sealed class SimulationEngine var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance <= task.Threshold) { + ship.ActionTimer = 0f; TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); ship.Position = task.TargetPosition.Value; ship.TargetPosition = ship.Position; @@ -739,18 +1115,63 @@ public sealed class SimulationEngine var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds; if (ship.SystemId != task.TargetSystemId) { - ship.State = distance > 800f ? "ftl" : "spooling-ftl"; + var spoolDuration = ship.Definition.SpoolTime; + if (ship.State != "ftl") + { + if (ship.State != "spooling-ftl") + { + ship.ActionTimer = 0f; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "spooling-ftl"; + if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) + { + return "none"; + } + + ship.State = "ftl"; + } speed = ship.Definition.FtlSpeed; energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else if (distance > 200f) { - ship.State = distance > 500f ? "warping" : "spooling-warp"; - speed = ship.Definition.Speed * 4.5f; + var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + if (ship.State != "warping") + { + if (ship.State != "spooling-warp") + { + ship.ActionTimer = 0f; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.State = "spooling-warp"; + if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) + { + return "none"; + } + + ship.State = "warping"; + } + speed = ship.Definition.Speed; energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else { + ship.ActionTimer = 0f; ship.State = "approaching"; speed = ship.Definition.Speed; } @@ -768,16 +1189,9 @@ public sealed class SimulationEngine private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { - if (!HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret")) - { - ship.State = "idle"; - ship.TargetPosition = ship.Position; - return "none"; - } - var task = ship.ControllerTask; var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); - if (node is null || task.TargetPosition is null) + if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node)) { ship.State = "idle"; ship.TargetPosition = ship.Position; @@ -788,6 +1202,7 @@ public sealed class SimulationEngine var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance > task.Threshold) { + ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; @@ -808,13 +1223,11 @@ public sealed class SimulationEngine } ship.State = "mining"; - ship.ActionTimer += deltaSeconds; - if (ship.ActionTimer < 1f) + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) { return "none"; } - ship.ActionTimer = 0f; var cargoAmount = GetShipCargoAmount(ship); var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount); mined = MathF.Min(mined, node.OreRemaining); @@ -842,10 +1255,28 @@ public sealed class SimulationEngine return "none"; } - ship.TargetPosition = task.TargetPosition.Value; - var distance = ship.Position.DistanceTo(task.TargetPosition.Value); - if (distance > task.Threshold) + var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); + if (padIndex is null) { + ship.ActionTimer = 0f; + ship.State = "awaiting-dock"; + ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); + var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); + if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds); + } + + return "none"; + } + + ship.AssignedDockingPadIndex = padIndex; + var padPosition = GetDockingPadPosition(station, padIndex.Value); + ship.TargetPosition = padPosition; + var distance = ship.Position.DistanceTo(padPosition); + if (distance > 4f) + { + ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; @@ -854,7 +1285,7 @@ public sealed class SimulationEngine } ship.State = "docking-approach"; - ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); + ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds); return "none"; } @@ -873,18 +1304,16 @@ public sealed class SimulationEngine } ship.State = "docking"; - ship.ActionTimer += deltaSeconds; - if (ship.ActionTimer < world.Balance.DockingDuration) + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) { return "none"; } - ship.ActionTimer = 0f; ship.State = "docked"; ship.DockedStationId = station.Id; station.DockedShipIds.Add(ship.Id); - ship.Position = station.Position; - ship.TargetPosition = station.Position; + ship.Position = padPosition; + ship.TargetPosition = padPosition; return "docked"; } @@ -901,6 +1330,7 @@ public sealed class SimulationEngine if (station is null) { ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; @@ -914,17 +1344,20 @@ public sealed class SimulationEngine return "none"; } - ship.TargetPosition = station.Position; + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; ship.State = "transferring"; var cargoItemId = ship.Definition.CargoItemId; var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds); if (cargoItemId is not null) { - RemoveInventory(ship.Inventory, cargoItemId, moved); - AddInventory(station.Inventory, cargoItemId, moved); + var accepted = TryAddStationInventory(world, station, cargoItemId, moved); + RemoveInventory(ship.Inventory, cargoItemId, accepted); + moved = accepted; } var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); - if (faction is not null) + if (faction is not null && cargoItemId == "ore") { faction.OreMined += moved; faction.Credits += moved * 0.4f; @@ -933,6 +1366,104 @@ public sealed class SimulationEngine return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; } + private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) + || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = "refueling"; + var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel")); + var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); + if (moved > 0.01f) + { + RemoveInventory(station.Inventory, "fuel", moved); + AddInventory(ship.Inventory, "fuel", moved); + } + + return !NeedsRefuel(ship) ? "refueled" : "none"; + } + + private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) + { + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) + { + ship.AssignedDockingPadIndex = null; + ship.State = "idle"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) + || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + ship.State = "power-starved"; + ship.TargetPosition = ship.Position; + return "none"; + } + + if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) + { + ship.ActionTimer = 0f; + ship.State = "waiting-materials"; + ship.TargetPosition = GetShipDockedPosition(ship, station); + return "none"; + } + + if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) + { + ship.State = "construction-blocked"; + ship.TargetPosition = GetShipDockedPosition(ship, station); + return "none"; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.ActionTimer = 0f; + ship.State = "constructing"; + station.ActiveConstruction.ProgressSeconds += deltaSeconds; + if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) + { + return "none"; + } + + station.InstalledModules.Add(station.ActiveConstruction.ModuleId); + station.ActiveConstruction = null; + return "module-constructed"; + } + private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; @@ -943,8 +1474,11 @@ public sealed class SimulationEngine return "none"; } - ship.TargetPosition = task.TargetPosition.Value; var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); + var undockTarget = station is null + ? task.TargetPosition.Value + : GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + ship.TargetPosition = undockTarget; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; @@ -959,15 +1493,30 @@ public sealed class SimulationEngine return "none"; } - station?.DockedShipIds.Remove(ship.Id); ship.State = "undocking"; - ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); - if (ship.Position.DistanceTo(task.TargetPosition.Value) > task.Threshold) + if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) + { + if (station is not null) + { + ship.Position = GetShipDockedPosition(ship, station); + } + return "none"; + } + + ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); + if (ship.Position.DistanceTo(undockTarget) > task.Threshold) { return "none"; } + if (station is not null) + { + station.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(station, ship.Id); + } + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; return "undocked"; } @@ -994,9 +1543,12 @@ public sealed class SimulationEngine ship.DefaultBehavior.Phase = "dock"; break; case ("dock", "docked"): - ship.DefaultBehavior.Phase = "unload"; + ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; break; case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = "refuel"; + break; + case ("refuel", "refueled"): ship.DefaultBehavior.Phase = "undock"; break; case ("undock", "undocked"): @@ -1006,6 +1558,55 @@ public sealed class SimulationEngine } } + if (ship.DefaultBehavior.Kind == "auto-harvest-gas") + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-node", "arrived"): + ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; + break; + case ("extract", "cargo-full"): + ship.DefaultBehavior.Phase = "travel-to-station"; + break; + case ("travel-to-station", "arrived"): + ship.DefaultBehavior.Phase = "dock"; + break; + case ("dock", "docked"): + ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; + break; + case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = "refuel"; + break; + case ("refuel", "refueled"): + ship.DefaultBehavior.Phase = "undock"; + break; + case ("undock", "undocked"): + ship.DefaultBehavior.Phase = "travel-to-node"; + ship.DefaultBehavior.NodeId = null; + break; + } + } + + if (ship.DefaultBehavior.Kind == "construct-station") + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-station", "arrived"): + ship.DefaultBehavior.Phase = "dock"; + break; + case ("dock", "docked"): + ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "construct-module"; + break; + case ("refuel", "refueled"): + ship.DefaultBehavior.Phase = "construct-module"; + break; + case ("construct-module", "module-constructed"): + ship.DefaultBehavior.Phase = "travel-to-station"; + ship.DefaultBehavior.ModuleId = null; + break; + } + } + if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0) { ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count; diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 47b0225..5046e64 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -20,6 +20,7 @@ import type { type ZoomLevel = "local" | "system" | "universe"; type SelectionGroup = "ships" | "structures" | "celestials"; type DragMode = "orbit" | "marquee"; +type CameraMode = "tactical" | "follow"; type Selectable = | { kind: "ship"; id: string } | { kind: "station"; id: string } @@ -245,6 +246,7 @@ export class GameViewer { private desiredDistance = ZOOM_DISTANCE.system; private orbitYaw = -2.3; private orbitPitch = 0.62; + private cameraMode: CameraMode = "tactical"; private dragMode?: DragMode; private dragPointerId?: number; private dragStart = new THREE.Vector2(); @@ -252,7 +254,12 @@ export class GameViewer { private marqueeActive = false; private suppressClickSelection = false; private activeSystemId?: string; - private followedShipId?: string; + private cameraTargetShipId?: string; + private readonly followCameraPosition = new THREE.Vector3(); + private readonly followCameraFocus = new THREE.Vector3(); + private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1); + private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1); + private readonly followCameraOffset = new THREE.Vector3(); private readonly historyWindows: HistoryWindowState[] = []; private historyWindowCounter = 0; private historyWindowZCounter = 10; @@ -334,6 +341,7 @@ export class GameViewer { this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick); this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false }); this.factionStripEl.addEventListener("click", this.onShipStripClick); + this.factionStripEl.addEventListener("dblclick", this.onShipStripDoubleClick); this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick); this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown); window.addEventListener("pointermove", this.onHistoryWindowPointerMove); @@ -709,18 +717,30 @@ export class GameViewer { .map((ship) => { const fuel = this.inventoryAmount(ship.inventory, "gas"); const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id; - const isFollowed = this.followedShipId === ship.id; + const isFollowed = this.cameraMode === "follow" && this.cameraTargetShipId === ship.id; return `

${ship.label}

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

${ship.systemId}

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

State ${ship.state}

-

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

- +
+

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

+

Behavior ${ship.defaultBehaviorKind}

+

Task ${ship.controllerTaskKind}

+
`; }) @@ -767,14 +787,12 @@ export class GameViewer { this.detailTitleEl.textContent = ship.label; const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); this.detailBodyEl.innerHTML = ` -

${ship.shipClass} · ${ship.role} · ${ship.systemId}

Parent ${parent}

-

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

+

State ${ship.state}

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

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

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

-

${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}

-

History available from the ship card list.

+

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

`; return; } @@ -789,7 +807,7 @@ export class GameViewer { this.detailBodyEl.innerHTML = `

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

Parent ${parent}

-

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

+

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

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

History available in the separate history window.

`; @@ -879,7 +897,10 @@ export class GameViewer { this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); this.zoomLevel = this.classifyZoomLevel(this.currentDistance); this.updateActiveSystem(); - this.updateFollowCamera(delta); + if (this.cameraMode === "follow" && this.updateFollowCamera(delta)) { + return; + } + this.updatePanFromKeyboard(delta); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); @@ -895,10 +916,6 @@ export class GameViewer { } private updatePanFromKeyboard(delta: number) { - if (this.followedShipId) { - return; - } - const move = new THREE.Vector3(); if (this.keyState.has("w")) { move.z -= 1; @@ -944,8 +961,8 @@ export class GameViewer { const iconAlpha = isProjectedSystemIcon ? 0 : entry.hideIconInUniverse - ? blend.systemWeight * (isActiveDetail ? 1 : 0) - : Math.max(blend.systemWeight, blend.universeWeight); + ? blend.systemWeight * (isActiveDetail ? 1 : 0) + : Math.max(blend.systemWeight, blend.universeWeight); this.setObjectOpacity(entry.detail, detailAlpha); this.setObjectOpacity(entry.icon, iconAlpha); @@ -1064,21 +1081,14 @@ export class GameViewer { const now = performance.now(); const worldTimeSeconds = this.currentWorldTimeSeconds(); for (const visual of this.shipVisuals.values()) { - const elapsedMs = now - visual.receivedAtMs; - const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); - const worldPosition = new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); - - if (blendT >= 1) { - const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35); - worldPosition.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds); - } + const worldPosition = this.getAnimatedShipLocalPosition(visual, now); visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); const shipVisible = visual.systemId === this.activeSystemId; visual.mesh.visible = shipVisible; visual.icon.visible = shipVisible && visual.icon.visible; - const desiredHeading = visual.targetPosition.clone().sub(worldPosition); + const desiredHeading = this.resolveShipHeading(visual, worldPosition); if (desiredHeading.lengthSq() > 0.01) { visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); } @@ -1091,8 +1101,7 @@ export class GameViewer { visual.mesh.visible = visual.systemId === this.activeSystemId; } for (const visual of this.stationVisuals.values()) { - const animatedLocalPosition = this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09); - visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); + visual.mesh.position.copy(this.toDisplayLocalPosition(visual.localPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); visual.mesh.visible = visual.systemId === this.activeSystemId; } @@ -1102,6 +1111,25 @@ export class GameViewer { this.updateSystemSummaryPresentation(); } + private getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { + const elapsedMs = now - visual.receivedAtMs; + const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); + return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); + } + + private resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3) { + const desiredHeading = visual.targetPosition.clone().sub(worldPosition); + if (desiredHeading.lengthSq() > 0.01) { + return desiredHeading; + } + + if (visual.velocity.lengthSq() > 0.01) { + return visual.velocity.clone(); + } + + return new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw)); + } + private updatePlanetPresentation() { const nowSeconds = this.currentWorldTimeSeconds(); for (const visual of this.planetVisuals) { @@ -1652,8 +1680,10 @@ export class GameViewer { ? new Date(this.world.generatedAtUtc).toLocaleTimeString() : "n/a"; const activeSystem = this.activeSystemId ?? "deep-space"; + const cameraModeLabel = this.cameraMode === "follow" ? "camera-follow" : "tactical"; this.statusEl.textContent = [ `mode: ${mode}`, + `camera: ${cameraModeLabel}`, `zoom: ${this.zoomLevel}`, `system: ${activeSystem}`, `sequence: ${sequence}`, @@ -2009,6 +2039,30 @@ export class GameViewer { this.updatePanels(); }; + private onShipStripDoubleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest("[data-history-ship-id]")) { + return; + } + + const card = target.closest("[data-ship-id]"); + const shipId = card?.dataset.shipId; + if (!shipId) { + return; + } + + this.selectedItems = [{ kind: "ship", id: shipId }]; + this.syncFollowStateFromSelection(); + this.focusOnSelection(this.selectedItems[0]); + this.toggleCameraMode("follow"); + this.updatePanels(); + this.updateGamePanel("Live"); + }; + private openHistoryWindow(target: Selectable) { const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target)); if (existing) { @@ -2286,14 +2340,7 @@ export class GameViewer { if (this.selectedItems.length !== 1) { return; } - const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]); - if (nextFocus) { - if (this.activeSystemId && this.isSelectionInActiveSystem(this.selectedItems[0])) { - this.systemFocusLocal.copy(nextFocus); - } else { - this.galaxyFocus.copy(nextFocus); - } - } + this.focusOnSelection(this.selectedItems[0]); this.syncFollowStateFromSelection(); }; @@ -2312,7 +2359,10 @@ export class GameViewer { const key = event.key.toLowerCase(); this.keyState.add(key); if (["w", "a", "s", "d"].includes(key)) { - this.followedShipId = undefined; + this.cameraMode = "tactical"; + } + if (key === "c") { + this.toggleCameraMode(); } if (key === "1") { this.desiredDistance = ZOOM_DISTANCE.local; @@ -2328,6 +2378,51 @@ export class GameViewer { this.keyState.delete(event.key.toLowerCase()); }; + private toggleCameraMode(forceMode?: CameraMode) { + const nextMode = forceMode ?? (this.cameraMode === "follow" ? "tactical" : "follow"); + if (nextMode === "tactical") { + this.cameraMode = "tactical"; + return; + } + + if (!this.cameraTargetShipId && this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { + this.cameraTargetShipId = this.selectedItems[0].id; + } + + if (!this.cameraTargetShipId) { + return; + } + + this.cameraMode = "follow"; + this.desiredDistance = Math.min(this.desiredDistance, 1800); + this.followCameraPosition.set(0, 0, 0); + this.followCameraFocus.set(0, 0, 0); + } + + private focusOnSelection(selection: Selectable) { + const nextFocus = this.resolveSelectionPosition(selection); + if (!nextFocus) { + return; + } + + const selectionSystemId = this.resolveSelectableSystemId(selection); + if (selectionSystemId && selection.kind !== "system" && this.world) { + const system = this.world.systems.get(selectionSystemId); + if (system) { + this.galaxyFocus.copy(this.toThreeVector(system.galaxyPosition)); + this.systemFocusLocal.copy(nextFocus); + return; + } + } + + if (this.activeSystemId && this.isSelectionInActiveSystem(selection)) { + this.systemFocusLocal.copy(nextFocus); + return; + } + + this.galaxyFocus.copy(nextFocus); + } + private resolveSelectionPosition(selection: Selectable) { if (!this.world) { return undefined; @@ -2339,10 +2434,7 @@ export class GameViewer { } if (selection.kind === "station") { const station = this.world.stations.get(selection.id); - const visual = station ? this.stationVisuals.get(station.id) : undefined; - return visual - ? this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09) - : (station ? this.toThreeVector(station.localPosition) : undefined); + return station ? this.toThreeVector(station.localPosition) : undefined; } if (selection.kind === "node") { const node = this.world.nodes.get(selection.id); @@ -2473,7 +2565,15 @@ export class GameViewer { } private determineActiveSystemId() { - if (!this.world || this.currentDistance >= 12000) { + if (!this.world) { + return undefined; + } + + if (this.cameraMode === "follow" && this.cameraTargetShipId) { + return this.world.ships.get(this.cameraTargetShipId)?.systemId; + } + + if (this.currentDistance >= 12000) { return undefined; } @@ -2491,10 +2591,6 @@ export class GameViewer { } } - if (this.followedShipId) { - return this.world.ships.get(this.followedShipId)?.systemId; - } - let nearestSystemId: string | undefined; let nearestDistance = Number.POSITIVE_INFINITY; for (const system of this.world.systems.values()) { @@ -2512,28 +2608,62 @@ export class GameViewer { } private updateFollowCamera(delta: number) { - if (!this.followedShipId || !this.world) { - return; + if (!this.cameraTargetShipId || !this.world) { + this.cameraMode = "tactical"; + return false; } - const ship = this.world.ships.get(this.followedShipId); - if (!ship) { - this.followedShipId = undefined; - return; + const ship = this.world.ships.get(this.cameraTargetShipId); + const visual = this.shipVisuals.get(this.cameraTargetShipId); + if (!ship || !visual) { + this.cameraTargetShipId = undefined; + this.cameraMode = "tactical"; + return false; } - const target = this.toThreeVector(ship.localPosition); - this.systemFocusLocal.lerp(target, 1 - Math.exp(-delta * 8)); + const shipLocalPosition = this.getAnimatedShipLocalPosition(visual); + const shipWorldPosition = this.toDisplayLocalPosition(shipLocalPosition, ship.systemId); + this.systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8)); + + this.followCameraDesiredDirection.copy(this.resolveShipHeading(visual, shipLocalPosition)).normalize(); + this.followCameraDirection.lerp(this.followCameraDesiredDirection, 1 - Math.exp(-delta * 5)); + this.followCameraDirection.normalize(); + + const distance = THREE.MathUtils.clamp(this.currentDistance * 0.72, 320, 6800); + const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100); + const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400); + this.followCameraOffset.copy(this.followCameraDirection).multiplyScalar(-distance); + this.followCameraOffset.y += height; + + const desiredPosition = shipWorldPosition.clone().add(this.followCameraOffset); + const desiredFocus = shipWorldPosition.clone().addScaledVector(this.followCameraDirection, lookAhead); + desiredFocus.y += height * 0.28; + + const positionLerp = 1 - Math.exp(-delta * 6); + const focusLerp = 1 - Math.exp(-delta * 8); + if (this.followCameraPosition.lengthSq() === 0) { + this.followCameraPosition.copy(desiredPosition); + this.followCameraFocus.copy(desiredFocus); + } else { + this.followCameraPosition.lerp(desiredPosition, positionLerp); + this.followCameraFocus.lerp(desiredFocus, focusLerp); + } + + this.camera.position.copy(this.followCameraPosition); + this.camera.lookAt(this.followCameraFocus); + return true; } private syncFollowStateFromSelection() { if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { - this.followedShipId = this.selectedItems[0].id; - this.desiredDistance = Math.min(this.desiredDistance, 1600); + this.cameraTargetShipId = this.selectedItems[0].id; return; } - this.followedShipId = undefined; + this.cameraTargetShipId = undefined; + if (this.cameraMode === "follow") { + this.cameraMode = "tactical"; + } } private updateSystemDetailVisibility() { @@ -2694,8 +2824,8 @@ export class GameViewer { return; } - if (this.followedShipId) { - const followedShip = this.world.ships.get(this.followedShipId); + if (this.cameraMode === "follow" && this.cameraTargetShipId) { + const followedShip = this.world.ships.get(this.cameraTargetShipId); if (followedShip?.systemId === systemId) { this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition)); return; @@ -2761,8 +2891,8 @@ export class GameViewer { moonCount += planet.moonCount; } - const followText = activeContext && this.followedShipId - ? `

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

` + const followText = activeContext && this.cameraMode === "follow" && this.cameraTargetShipId + ? `

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

` : ""; return ` diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 2a744ae..96c807a 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -90,6 +90,7 @@ export interface StationSnapshot { localPosition: Vector3Dto; color: string; dockedShips: number; + dockingPads: number; energyStored: number; inventory: InventoryEntry[]; factionId: string; diff --git a/shared/data/balance.json b/shared/data/balance.json index 998d3e0..9713327 100644 --- a/shared/data/balance.json +++ b/shared/data/balance.json @@ -1,9 +1,11 @@ { "yPlane": 4, "arrivalThreshold": 16, - "miningRate": 28, + "miningRate": 10, + "miningCycleSeconds": 10, "transferRate": 56, "dockingDuration": 1.2, + "undockingDuration": 1.2, "undockDistance": 42, "energy": { "idleDrain": 0.7, diff --git a/shared/data/constructibles.json b/shared/data/constructibles.json index 9606378..e3aa473 100644 --- a/shared/data/constructibles.json +++ b/shared/data/constructibles.json @@ -12,7 +12,7 @@ "bulk-liquid": 600, "bulk-gas": 600 }, - "modules": ["docking-clamps", "refinery-stack", "fabricator-array", "power-core", "bulk-bay", "liquid-tank", "gas-tank"] + "modules": ["docking-clamps", "dock-bay-small", "power-core", "bulk-bay", "liquid-tank"] }, { "id": "trade-hub", @@ -32,7 +32,7 @@ "radius": 24, "dockingCapacity": 3, "storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 }, - "modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array", "power-core", "liquid-tank", "gas-tank"] + "modules": ["docking-clamps", "power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"] }, { "id": "farm-ring", diff --git a/shared/data/items.json b/shared/data/items.json index 0397e7a..ff11109 100644 --- a/shared/data/items.json +++ b/shared/data/items.json @@ -47,6 +47,12 @@ "storage": "bulk-gas", "summary": "Compressed gas reserves for future chemical and fuel chains." }, + { + "id": "fuel", + "label": "Reactor Fuel", + "storage": "bulk-liquid", + "summary": "Processed liquid fuel consumed by ships and station power systems." + }, { "id": "water", "label": "Water", diff --git a/shared/data/module-recipes.json b/shared/data/module-recipes.json new file mode 100644 index 0000000..28f3e44 --- /dev/null +++ b/shared/data/module-recipes.json @@ -0,0 +1,30 @@ +[ + { + "moduleId": "dock-bay-small", + "duration": 12, + "inputs": [ + { "itemId": "refined-metals", "amount": 34 } + ] + }, + { + "moduleId": "gas-tank", + "duration": 10, + "inputs": [ + { "itemId": "refined-metals", "amount": 30 } + ] + }, + { + "moduleId": "fuel-processor", + "duration": 14, + "inputs": [ + { "itemId": "refined-metals", "amount": 42 } + ] + }, + { + "moduleId": "refinery-stack", + "duration": 14, + "inputs": [ + { "itemId": "refined-metals", "amount": 38 } + ] + } +] diff --git a/shared/data/modules.json b/shared/data/modules.json index 42078b8..89a5076 100644 --- a/shared/data/modules.json +++ b/shared/data/modules.json @@ -41,6 +41,12 @@ "category": "mining", "summary": "Articulated mining head for shipborne extraction." }, + { + "id": "gas-extractor", + "label": "Gas Extractor", + "category": "mining", + "summary": "Cryogenic intake and compression rig for harvesting gas clouds." + }, { "id": "gun-turret", "label": "Gun Turret", @@ -65,6 +71,12 @@ "category": "dock", "summary": "Docking collar and transfer arms." }, + { + "id": "dock-bay-small", + "label": "Small Dock Bay", + "category": "dock", + "summary": "External docking truss with two independent ship pads." + }, { "id": "carrier-bay", "label": "Carrier Bay", @@ -77,6 +89,12 @@ "category": "refinery", "summary": "Ore cracking and metal separation." }, + { + "id": "fuel-processor", + "label": "Fuel Processor", + "category": "refinery", + "summary": "Gas cracking and catalytic processing for reactor fuel." + }, { "id": "turret-grid", "label": "Turret Grid", diff --git a/shared/data/scenario.json b/shared/data/scenario.json index cf19edc..a0ce0c5 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -4,7 +4,8 @@ ], "shipFormations": [ { "shipId": "constructor", "count": 1, "center": [45, 0, 20], "systemId": "helios" }, - { "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" } + { "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" }, + { "shipId": "gas-miner", "count": 1, "center": [60, 0, 28], "systemId": "helios" } ], "patrolRoutes": [], "miningDefaults": { diff --git a/shared/data/ships.json b/shared/data/ships.json index 2bef949..bbedcbb 100644 --- a/shared/data/ships.json +++ b/shared/data/ships.json @@ -111,5 +111,22 @@ "size": 6, "maxHealth": 150, "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"] + }, + { + "id": "gas-miner", + "label": "Nimbus Gas Harvester", + "role": "mining", + "shipClass": "industrial", + "speed": 24, + "ftlSpeed": 2350, + "spoolTime": 3.2, + "cargoCapacity": 120, + "cargoKind": "bulk-gas", + "cargoItemId": "gas", + "color": "#8ce5ff", + "hullColor": "#2a5668", + "size": 6, + "maxHealth": 150, + "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank", "docking-clamps"] } ]