using SpaceGame.Simulation.Api.Data; using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; public sealed class SimulationEngine { private const float ShipFuelToEnergyRatio = 12f; private const float StationFuelToEnergyRatio = 18f; private const float CapacitorEnergyPerModule = 120f; private const float StationEnergyPerPowerCore = 480f; private const float ShipFuelPerReactor = 100f; private const float StationFuelPerTank = 500f; public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { var events = new List(); UpdateStationPower(world, deltaSeconds, events); UpdateStations(world, deltaSeconds, events); foreach (var ship in world.Ships) { var previousPosition = ship.Position; var previousState = ship.State; var previousBehavior = ship.DefaultBehavior.Kind; var previousTask = ship.ControllerTask.Kind; UpdateShipPower(ship, world, deltaSeconds, events); RefreshControlLayers(ship); PlanControllerTask(ship, world); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); AdvanceControlState(ship, controllerEvent); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); TrackHistory(ship); EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } world.GeneratedAtUtc = DateTimeOffset.UtcNow; return new WorldDelta( sequence, world.TickIntervalMs, world.GeneratedAtUtc, false, events, BuildNodeDeltas(world), BuildStationDeltas(world), BuildShipDeltas(world), BuildFactionDeltas(world)); } public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) { PrimeDeltaBaseline(world); return new WorldSnapshot( world.Label, world.Seed, sequence, world.TickIntervalMs, world.GeneratedAtUtc, world.Systems.Select((system) => new SystemSnapshot( system.Definition.Id, system.Definition.Label, ToDto(system.Position), system.Definition.StarKind, system.Definition.StarCount, system.Definition.StarColor, system.Definition.StarSize, system.Definition.Planets.Select((planet) => new PlanetSnapshot( planet.Label, planet.PlanetType, planet.Shape, planet.MoonCount, planet.OrbitRadius, planet.OrbitSpeed, planet.OrbitEccentricity, planet.OrbitInclination, planet.OrbitLongitudeOfAscendingNode, planet.OrbitArgumentOfPeriapsis, planet.OrbitPhaseAtEpoch, planet.Size, planet.Color, planet.HasRing)).ToList())).ToList(), world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot( node.Id, node.SystemId, node.LocalPosition, node.SourceKind, node.OreRemaining, node.MaxOre, node.ItemId)).ToList(), world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot( station.Id, station.Label, station.Category, station.SystemId, station.LocalPosition, station.Color, station.DockedShips, station.DockingPads, station.EnergyStored, station.Inventory, station.FactionId)).ToList(), world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot( ship.Id, ship.Label, ship.Role, ship.ShipClass, ship.SystemId, ship.LocalPosition, ship.LocalVelocity, ship.TargetLocalPosition, ship.State, ship.OrderKind, ship.DefaultBehaviorKind, ship.ControllerTaskKind, ship.CargoCapacity, ship.EnergyStored, ship.Inventory, ship.FactionId, ship.Health, ship.History)).ToList(), world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot( faction.Id, faction.Label, faction.Color, faction.Credits, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost)).ToList()); } public void PrimeDeltaBaseline(SimulationWorld world) { foreach (var node in world.Nodes) { node.LastDeltaSignature = BuildNodeSignature(node); } foreach (var station in world.Stations) { station.LastDeltaSignature = BuildStationSignature(station); } foreach (var ship in world.Ships) { ship.LastDeltaSignature = BuildShipSignature(ship); } foreach (var faction in world.Factions) { faction.LastDeltaSignature = BuildFactionSignature(faction); } } private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) { var deltas = new List(); foreach (var node in world.Nodes) { var signature = BuildNodeSignature(node); if (signature == node.LastDeltaSignature) { continue; } node.LastDeltaSignature = signature; deltas.Add(ToNodeDelta(node)); } return deltas; } private static IReadOnlyList BuildStationDeltas(SimulationWorld world) { var deltas = new List(); foreach (var station in world.Stations) { var signature = BuildStationSignature(station); if (signature == station.LastDeltaSignature) { continue; } station.LastDeltaSignature = signature; deltas.Add(ToStationDelta(station)); } return deltas; } private static IReadOnlyList BuildShipDeltas(SimulationWorld world) { var deltas = new List(); foreach (var ship in world.Ships) { var signature = BuildShipSignature(ship); if (signature == ship.LastDeltaSignature) { continue; } ship.LastDeltaSignature = signature; deltas.Add(ToShipDelta(ship)); } return deltas; } private static IReadOnlyList BuildFactionDeltas(SimulationWorld world) { var deltas = new List(); foreach (var faction in world.Factions) { var signature = BuildFactionSignature(faction); if (signature == faction.LastDeltaSignature) { continue; } faction.LastDeltaSignature = signature; deltas.Add(ToFactionDelta(faction)); } return deltas; } private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}"; private static string BuildStationSignature(StationRuntime station) => $"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{string.Join(",", station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}"; private static string BuildShipSignature(ShipRuntime ship) => string.Join("|", ship.SystemId, ship.Position.X.ToString("0.###"), ship.Position.Y.ToString("0.###"), ship.Position.Z.ToString("0.###"), ship.Velocity.X.ToString("0.###"), ship.Velocity.Y.ToString("0.###"), ship.Velocity.Z.ToString("0.###"), ship.TargetPosition.X.ToString("0.###"), ship.TargetPosition.Y.ToString("0.###"), ship.TargetPosition.Z.ToString("0.###"), ship.State, ship.Order?.Kind ?? "none", ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, GetShipCargoAmount(ship).ToString("0.###"), GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"), ship.EnergyStored.ToString("0.###"), ship.Health.ToString("0.###")); private static string BuildInventorySignature(IReadOnlyDictionary inventory) => string.Join(",", inventory .Where((entry) => entry.Value > 0.001f) .OrderBy((entry) => entry.Key, StringComparer.Ordinal) .Select((entry) => $"{entry.Key}:{entry.Value:0.###}")); private static string BuildFactionSignature(FactionRuntime faction) => $"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}"; private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( node.Id, node.SystemId, ToDto(node.Position), node.SourceKind, node.OreRemaining, node.MaxOre, node.ItemId); private static StationDelta ToStationDelta(StationRuntime station) => new( station.Id, station.Definition.Label, station.Definition.Category, station.SystemId, ToDto(station.Position), station.Definition.Color, station.DockedShipIds.Count, GetDockingPadCount(station), station.EnergyStored, ToInventoryEntries(station.Inventory), station.FactionId); private static ShipDelta ToShipDelta(ShipRuntime ship) => new( ship.Id, ship.Definition.Label, ship.Definition.Role, ship.Definition.ShipClass, ship.SystemId, ToDto(ship.Position), ToDto(ship.Velocity), ToDto(ship.TargetPosition), ship.State, ship.Order?.Kind, ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, ship.Definition.CargoCapacity, ship.EnergyStored, ToInventoryEntries(ship.Inventory), ship.FactionId, ship.Health, ship.History.ToList()); private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => inventory .Where((entry) => entry.Value > 0.001f) .OrderBy((entry) => entry.Key, StringComparer.Ordinal) .Select((entry) => new InventoryEntry(entry.Key, entry.Value)) .ToList(); private static FactionDelta ToFactionDelta(FactionRuntime faction) => new( faction.Id, faction.Label, faction.Color, faction.Credits, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost); private static void EmitShipStateEvents( ShipRuntime ship, string previousState, string previousBehavior, string previousTask, string controllerEvent, ICollection events) { var occurredAtUtc = DateTimeOffset.UtcNow; if (previousState != ship.State) { events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc)); } if (previousBehavior != ship.DefaultBehavior.Kind) { events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc)); } if (previousTask != ship.ControllerTask.Kind) { events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc)); } if (controllerEvent != "none") { events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc)); } } private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) { foreach (var station in world.Stations) { 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; } if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) { station.ProcessTimer = 0f; continue; } station.ProcessTimer += deltaSeconds; if (station.ProcessTimer < 8f) { continue; } station.ProcessTimer = 0f; RemoveInventory(station.Inventory, "ore", 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 += refined; faction.Credits += refined * 0.3f; } } } private static bool HasRefineryCapability(StationRuntime station) => HasStationModules(station, "refinery-stack", "power-core", "bulk-bay"); private static bool CanProcessFuel(StationRuntime station) => HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); private static bool HasShipModules(ShipDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); private static void UpdateStationPower( SimulationWorld world, float deltaSeconds, ICollection events) { foreach (var station in world.Stations) { var previousEnergy = station.EnergyStored; GenerateStationEnergy(station, deltaSeconds); if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } } } private static void UpdateShipPower( ShipRuntime ship, SimulationWorld world, float deltaSeconds, ICollection events) { var previousEnergy = ship.EnergyStored; GenerateShipEnergy(ship, world, deltaSeconds); if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } } private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) { var powerCores = CountModules(station.InstalledModules, "power-core"); var tanks = CountModules(station.InstalledModules, "liquid-tank"); if (powerCores <= 0 || tanks <= 0) { station.EnergyStored = 0f; station.Inventory.Remove("fuel"); return; } var energyCapacity = powerCores * StationEnergyPerPowerCore; var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); return; } var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds); var requiredFuel = generated / StationFuelToEnergyRatio; var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * StationFuelToEnergyRatio; RemoveInventory(station.Inventory, "fuel", consumedFuel); station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); } private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var reactors = CountModules(ship.Definition.Modules, "reactor-core"); var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank"); if (reactors <= 0 || capacitors <= 0) { ship.EnergyStored = 0f; ship.Inventory.Remove("fuel"); return; } var energyCapacity = capacitors * CapacitorEnergyPerModule; var fuelCapacity = reactors * ShipFuelPerReactor; var fuelStored = GetInventoryAmount(ship.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored); if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity); ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity); return; } var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds); var requiredFuel = generated / ShipFuelToEnergyRatio; var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * ShipFuelToEnergyRatio; RemoveInventory(ship.Inventory, "fuel", consumedFuel); ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated); } private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount) { if (ship.EnergyStored + 0.0001f < amount) { return false; } ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount); return true; } private static bool TryConsumeStationEnergy(StationRuntime station, float amount) { if (station.EnergyStored + 0.0001f < amount) { return false; } station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount); return true; } private static int CountModules(IEnumerable modules, string moduleId) => modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => inventory.TryGetValue(itemId, out var amount) ? amount : 0f; private static void AddInventory(IDictionary inventory, string itemId, float amount) { if (amount <= 0f) { return; } inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; } private static float RemoveInventory(IDictionary inventory, string itemId, float amount) { var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); var removed = MathF.Min(current, amount); var remaining = current - removed; if (remaining <= 0.001f) { inventory.Remove(itemId); } else { inventory[itemId] = remaining; } return removed; } private static bool HasStationModules(StationRuntime station, params string[] modules) => modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) => node.ItemId switch { "ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"), "gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"), _ => false, }; private static float GetShipFuelCapacity(ShipRuntime ship) => CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; private static bool NeedsRefuel(ShipRuntime ship) => GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f); private static 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; return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId); } private void RefreshControlLayers(ShipRuntime ship) { if (ship.Order is not null && ship.Order.Status == "queued") { ship.Order.Status = "accepted"; } } private void PlanControllerTask(ShipRuntime ship, SimulationWorld world) { if (ship.Order is not null) { ship.ControllerTask = new ControllerTaskRuntime { Kind = "travel", TargetEntityId = null, TargetSystemId = ship.Order.DestinationSystemId, TargetPosition = ship.Order.DestinationPosition, Threshold = world.Balance.ArrivalThreshold, }; return; } if (ship.DefaultBehavior.Kind == "auto-mine") { PlanResourceHarvest(ship, world, "ore", "mining-turret"); return; } if (ship.DefaultBehavior.Kind == "auto-harvest-gas") { PlanResourceHarvest(ship, world, "gas", "gas-extractor"); return; } if (ship.DefaultBehavior.Kind == "construct-station") { PlanStationConstruction(ship, world); return; } if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0) { ship.ControllerTask = new ControllerTaskRuntime { Kind = "travel", TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], TargetSystemId = ship.SystemId, Threshold = 18f, }; return; } ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold, }; } private void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) { var behavior = ship.DefaultBehavior; var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.StationId); var node = behavior.NodeId is null ? world.Nodes .Where((candidate) => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId) .OrderByDescending((candidate) => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId); if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) { behavior.Kind = "idle"; ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; return; } behavior.NodeId ??= node.Id; if (ship.DockedStationId == refinery.Id) { if (GetShipCargoAmount(ship) > 0.01f) { behavior.Phase = "unload"; } else if (NeedsRefuel(ship)) { behavior.Phase = "refuel"; } else if (behavior.Phase is "dock" or "unload" or "refuel") { behavior.Phase = "undock"; } } else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") { behavior.Phase = "travel-to-station"; } switch (behavior.Phase) { case "extract": ship.ControllerTask = new ControllerTaskRuntime { Kind = "extract", TargetEntityId = node.Id, TargetSystemId = node.SystemId, TargetPosition = node.Position, Threshold = 14f, }; break; case "travel-to-station": ship.ControllerTask = new ControllerTaskRuntime { Kind = "travel", TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = refinery.Definition.Radius + 8f, }; break; case "dock": ship.ControllerTask = new ControllerTaskRuntime { Kind = "dock", TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = refinery.Definition.Radius + 4f, }; break; case "unload": ship.ControllerTask = new ControllerTaskRuntime { Kind = "unload", TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = 0f, }; break; case "refuel": ship.ControllerTask = new ControllerTaskRuntime { Kind = "refuel", TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = 0f, }; break; case "undock": ship.ControllerTask = new ControllerTaskRuntime { Kind = "undock", TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), Threshold = 8f, }; break; default: ship.ControllerTask = new ControllerTaskRuntime { Kind = "travel", TargetEntityId = node.Id, TargetSystemId = node.SystemId, TargetPosition = node.Position, Threshold = 18f, }; behavior.Phase = "travel-to-node"; break; } } private 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; switch (task.Kind) { case "idle": TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; case "travel": return UpdateTravel(ship, world, deltaSeconds); case "extract": return UpdateExtract(ship, world, deltaSeconds); case "dock": return UpdateDock(ship, world, deltaSeconds); case "unload": return UpdateUnload(ship, world, deltaSeconds); case "refuel": return UpdateRefuel(ship, world, deltaSeconds); case "construct-module": return UpdateConstructModule(ship, world, deltaSeconds); case "undock": return UpdateUndock(ship, world, deltaSeconds); default: ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } } private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; if (task.TargetPosition is null || task.TargetSystemId is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = task.TargetPosition.Value; 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; ship.SystemId = task.TargetSystemId; ship.State = "arriving"; return "arrived"; } var speed = ship.Definition.Speed; var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds; if (ship.SystemId != task.TargetSystemId) { var spoolDuration = ship.Definition.SpoolTime; if (ship.State != "ftl") { if (ship.State != "spooling-ftl") { ship.ActionTimer = 0f; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "spooling-ftl"; if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) { return "none"; } ship.State = "ftl"; } speed = ship.Definition.FtlSpeed; energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else if (distance > 200f) { var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); if (ship.State != "warping") { if (ship.State != "spooling-warp") { ship.ActionTimer = 0f; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "spooling-warp"; if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) { return "none"; } ship.State = "warping"; } speed = ship.Definition.Speed; energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else { ship.ActionTimer = 0f; ship.State = "approaching"; speed = ship.Definition.Speed; } if (!TryConsumeShipEnergy(ship, energyCost)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds); return "none"; } private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node)) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = task.TargetPosition.Value; var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance > task.Threshold) { ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "mining-approach"; ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "mining"; if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) { return "none"; } var cargoAmount = GetShipCargoAmount(ship); var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount); mined = MathF.Min(mined, node.OreRemaining); if (ship.Definition.CargoItemId is not null) { AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined); } node.OreRemaining -= mined; if (node.OreRemaining <= 0f) { node.OreRemaining = node.MaxOre; } return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; } private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); if (station is null || task.TargetPosition is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); if (padIndex is null) { ship.ActionTimer = 0f; ship.State = "awaiting-dock"; ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds); } return "none"; } ship.AssignedDockingPadIndex = padIndex; var padPosition = GetDockingPadPosition(station, padIndex.Value); ship.TargetPosition = padPosition; var distance = ship.Position.DistanceTo(padPosition); if (distance > 4f) { ship.ActionTimer = 0f; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "docking-approach"; ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds); return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "docking"; if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) { return "none"; } ship.State = "docked"; ship.DockedStationId = station.Id; station.DockedShipIds.Add(ship.Id); ship.Position = padPosition; ship.TargetPosition = padPosition; return "docked"; } private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); if (station is null) { ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; ship.State = "transferring"; var cargoItemId = ship.Definition.CargoItemId; var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds); if (cargoItemId is not null) { var accepted = TryAddStationInventory(world, station, cargoItemId, moved); RemoveInventory(ship.Inventory, cargoItemId, accepted); moved = accepted; } var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); if (faction is not null && cargoItemId == "ore") { faction.OreMined += moved; faction.Credits += moved * 0.4f; } return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; } private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); if (station is null) { ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; ship.State = "refueling"; var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel")); var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); if (moved > 0.01f) { RemoveInventory(station.Inventory, "fuel", moved); AddInventory(ship.Inventory, "fuel", moved); } return !NeedsRefuel(ship) ? "refueled" : "none"; } private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) { ship.AssignedDockingPadIndex = null; ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) { ship.ActionTimer = 0f; ship.State = "waiting-materials"; ship.TargetPosition = GetShipDockedPosition(ship, station); return "none"; } if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) { ship.State = "construction-blocked"; ship.TargetPosition = GetShipDockedPosition(ship, station); return "none"; } ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.ActionTimer = 0f; ship.State = "constructing"; station.ActiveConstruction.ProgressSeconds += deltaSeconds; if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) { return "none"; } station.InstalledModules.Add(station.ActiveConstruction.ModuleId); station.ActiveConstruction = null; return "module-constructed"; } private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; if (ship.DockedStationId is null || task.TargetPosition is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); var undockTarget = station is null ? task.TargetPosition.Value : GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); ship.TargetPosition = undockTarget; if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "undocking"; if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) { if (station is not null) { ship.Position = GetShipDockedPosition(ship, station); } return "none"; } ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); if (ship.Position.DistanceTo(undockTarget) > task.Threshold) { return "none"; } if (station is not null) { station.DockedShipIds.Remove(ship.Id); ReleaseDockingPad(station, ship.Id); } ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; return "undocked"; } private void AdvanceControlState(ShipRuntime ship, string controllerEvent) { if (ship.Order is not null && controllerEvent == "arrived") { ship.Order = null; ship.ControllerTask.Kind = "idle"; return; } if (ship.DefaultBehavior.Kind == "auto-mine") { switch (ship.DefaultBehavior.Phase, controllerEvent) { case ("travel-to-node", "arrived"): ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; break; case ("extract", "cargo-full"): ship.DefaultBehavior.Phase = "travel-to-station"; break; case ("travel-to-station", "arrived"): ship.DefaultBehavior.Phase = "dock"; break; case ("dock", "docked"): ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; break; case ("unload", "unloaded"): ship.DefaultBehavior.Phase = "refuel"; break; case ("refuel", "refueled"): ship.DefaultBehavior.Phase = "undock"; break; case ("undock", "undocked"): ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.NodeId = null; break; } } if (ship.DefaultBehavior.Kind == "auto-harvest-gas") { switch (ship.DefaultBehavior.Phase, controllerEvent) { case ("travel-to-node", "arrived"): ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; break; case ("extract", "cargo-full"): ship.DefaultBehavior.Phase = "travel-to-station"; break; case ("travel-to-station", "arrived"): ship.DefaultBehavior.Phase = "dock"; break; case ("dock", "docked"): ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel"; break; case ("unload", "unloaded"): ship.DefaultBehavior.Phase = "refuel"; break; case ("refuel", "refueled"): ship.DefaultBehavior.Phase = "undock"; break; case ("undock", "undocked"): ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.NodeId = null; break; } } if (ship.DefaultBehavior.Kind == "construct-station") { switch (ship.DefaultBehavior.Phase, controllerEvent) { case ("travel-to-station", "arrived"): ship.DefaultBehavior.Phase = "dock"; break; case ("dock", "docked"): ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "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; } } private static void TrackHistory(ShipRuntime ship) { var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}"; if (signature == ship.LastSignature) { return; } ship.LastSignature = signature; ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}"); if (ship.History.Count > 18) { ship.History.RemoveAt(0); } } private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); }