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.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}"; 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, "gas").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, 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 (!HasRefineryCapability(station.Definition) || GetInventoryAmount(station.Inventory, "ore") < 60f) { 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); AddInventory(station.Inventory, "refined-metals", 60f); 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; } } } 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 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, "gas") <= 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, "gas") <= 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.Definition.Modules, "power-core"); var tanks = CountModules(station.Definition.Modules, "gas-tank"); if (powerCores <= 0 || tanks <= 0) { station.EnergyStored = 0f; station.Inventory.Remove("gas"); return; } var energyCapacity = powerCores * StationEnergyPerPowerCore; var fuelStored = GetInventoryAmount(station.Inventory, "gas"); 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); 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, "gas", 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("gas"); return; } var energyCapacity = capacitors * CapacitorEnergyPerModule; var fuelCapacity = reactors * ShipFuelPerReactor; var fuelStored = GetInventoryAmount(ship.Inventory, "gas"); 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); 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, "gas", 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 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") { PlanAutoMine(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 PlanAutoMine(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId); var node = behavior.NodeId is null ? world.Nodes .Where((candidate) => candidate.SystemId == behavior.AreaSystemId) .OrderByDescending((candidate) => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId); if (refinery is null || node is null) { behavior.Kind = "idle"; ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; return; } behavior.NodeId ??= node.Id; 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 "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 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 "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) { 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) { ship.State = distance > 800f ? "ftl" : "spooling-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; energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else { 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) { 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) { 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) { 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"; ship.ActionTimer += deltaSeconds; if (ship.ActionTimer < 1f) { 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); 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"; } ship.TargetPosition = task.TargetPosition.Value; var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance > task.Threshold) { 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(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"; } if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) { ship.State = "power-starved"; ship.TargetPosition = ship.Position; return "none"; } ship.State = "docking"; ship.ActionTimer += deltaSeconds; if (ship.ActionTimer < 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; 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.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 = station.Position; 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 faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); if (faction is not null) { faction.OreMined += moved; faction.Credits += moved * 0.4f; } return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; } private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; if (ship.DockedStationId is null || task.TargetPosition is null) { ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = task.TargetPosition.Value; var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); 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"; } station?.DockedShipIds.Remove(ship.Id); ship.DockedStationId = null; ship.State = "undocking"; ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none"; } 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 = "unload"; break; case ("unload", "unloaded"): ship.DefaultBehavior.Phase = "undock"; break; case ("undock", "undocked"): ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.NodeId = 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); }