using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Ships.Simulation; internal sealed class ShipControlService { private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault(); private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) => ship.CommanderId is null ? null : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship); private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander) { if (commander.ActiveBehavior is not null) { ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind; ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId; ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId; ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId; ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId; ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId; ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase; ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex; ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId; } if (commander.ActiveOrder is null) { ship.Order = null; } else { ship.Order = new ShipOrderRuntime { Kind = commander.ActiveOrder.Kind, Status = commander.ActiveOrder.Status, DestinationSystemId = commander.ActiveOrder.DestinationSystemId, DestinationPosition = commander.ActiveOrder.DestinationPosition, }; } if (commander.ActiveTask is not null) { ship.ControllerTask = new ControllerTaskRuntime { Kind = ParseControllerTaskKind(commander.ActiveTask.Kind), Status = commander.ActiveTask.Status, CommanderId = commander.Id, TargetEntityId = commander.ActiveTask.TargetEntityId, TargetNodeId = commander.ActiveTask.TargetNodeId, TargetPosition = commander.ActiveTask.TargetPosition, TargetSystemId = commander.ActiveTask.TargetSystemId, Threshold = commander.ActiveTask.Threshold, }; } } private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander) { commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind }; commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind; commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId; commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId; commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId; commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId; commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId; commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase; commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex; commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId; if (ship.Order is null) { commander.ActiveOrder = null; } else { commander.ActiveOrder ??= new CommanderOrderRuntime { Kind = ship.Order.Kind, DestinationSystemId = ship.Order.DestinationSystemId, DestinationPosition = ship.Order.DestinationPosition, }; commander.ActiveOrder.Status = ship.Order.Status; commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId; commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId; } commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() }; commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue(); commander.ActiveTask.Status = ship.ControllerTask.Status; commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId; commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId; commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition; commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId; commander.ActiveTask.Threshold = ship.ControllerTask.Threshold; } internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world) { var commander = GetShipCommander(world, ship); if (commander is not null) { SyncCommanderToShip(ship, commander); } if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued) { ship.Order.Status = OrderStatus.Accepted; if (commander?.ActiveOrder is not null) { commander.ActiveOrder.Status = ship.Order.Status; } } if (commander is not null) { SyncShipToCommander(ship, commander); } } internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) { var commander = GetShipCommander(world, ship); if (ship.Order is not null) { ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, Status = WorkStatus.Active, CommanderId = commander?.Id, TargetSystemId = ship.Order.DestinationSystemId, TargetNodeId = ship.SpatialState.DestinationNodeId, TargetPosition = ship.Order.DestinationPosition, Threshold = world.Balance.ArrivalThreshold, }; SyncCommanderTask(commander, ship.ControllerTask); return; } _shipBehaviorStateMachine.Plan(engine, ship, world); SyncCommanderTask(commander, ship.ControllerTask); } internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var target = ResolveAttackTarget(ship, world); if (target is null) { behavior.Kind = "idle"; behavior.TargetEntityId = null; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } behavior.TargetEntityId = target.EntityId; behavior.AreaSystemId = target.SystemId; ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.AttackTarget, TargetEntityId = target.EntityId, TargetSystemId = target.SystemId, TargetPosition = target.Position, Threshold = target.AttackRange, }; } internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId); if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId)) { behavior.Kind = "idle"; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } var carryingCargo = GetShipCargoAmount(ship) > 0.01f; if (carryingCargo) { if (ship.DockedStationId == destinationStation.Id) { behavior.Phase = "unload"; } else if (ship.DockedStationId is not null) { behavior.Phase = "undock-from-source"; } else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload") { behavior.Phase = "travel-to-destination"; } } else { if (ship.DockedStationId == sourceStation.Id) { var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId); behavior.Phase = available > 0.01f ? "load" : "wait-source"; } else if (ship.DockedStationId == destinationStation.Id) { behavior.Phase = "undock-from-destination"; } else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load") { behavior.Phase = "travel-to-source"; } } ship.ControllerTask = behavior.Phase switch { "travel-to-source" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, TargetEntityId = sourceStation.Id, TargetSystemId = sourceStation.SystemId, TargetPosition = sourceStation.Position, Threshold = sourceStation.Radius + 8f, ItemId = behavior.ItemId, }, "dock-source" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Dock, TargetEntityId = sourceStation.Id, TargetSystemId = sourceStation.SystemId, TargetPosition = sourceStation.Position, Threshold = sourceStation.Radius + 4f, ItemId = behavior.ItemId, }, "load" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Load, TargetEntityId = sourceStation.Id, TargetSystemId = sourceStation.SystemId, TargetPosition = sourceStation.Position, Threshold = 0f, ItemId = behavior.ItemId, }, "undock-from-source" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Undock, TargetEntityId = sourceStation.Id, TargetSystemId = sourceStation.SystemId, TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z), Threshold = 8f, ItemId = behavior.ItemId, }, "travel-to-destination" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, TargetEntityId = destinationStation.Id, TargetSystemId = destinationStation.SystemId, TargetPosition = destinationStation.Position, Threshold = destinationStation.Radius + 8f, ItemId = behavior.ItemId, }, "dock-destination" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Dock, TargetEntityId = destinationStation.Id, TargetSystemId = destinationStation.SystemId, TargetPosition = destinationStation.Position, Threshold = destinationStation.Radius + 4f, ItemId = behavior.ItemId, }, "unload" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Unload, TargetEntityId = destinationStation.Id, TargetSystemId = destinationStation.SystemId, TargetPosition = destinationStation.Position, Threshold = 0f, ItemId = behavior.ItemId, }, "undock-from-destination" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Undock, TargetEntityId = destinationStation.Id, TargetSystemId = destinationStation.SystemId, TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z), Threshold = 8f, ItemId = behavior.ItemId, }, _ => CreateIdleTask(world.Balance.ArrivalThreshold), }; } internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule) { var behavior = ship.DefaultBehavior; var cargoItemId = ship.Inventory.Keys.FirstOrDefault(); var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId); if (string.IsNullOrWhiteSpace(targetResourceItemId)) { behavior.Phase = null; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal)) { behavior.ItemId = targetResourceItemId; behavior.NodeId = null; } var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId); behavior.StationId = refinery?.Id; var node = behavior.NodeId is null ? world.Nodes .Where(candidate => candidate.ItemId == targetResourceItemId && candidate.OreRemaining > 0.01f && CanShipMineItem(world, ship, candidate.ItemId)) .OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0) .ThenByDescending(candidate => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) && candidate.OreRemaining > 0.01f); if (node is not null) { behavior.AreaSystemId = node.SystemId; } if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule)) { if (refinery is null && GetShipCargoAmount(ship) > 0.01f) { ship.Inventory.Clear(); } behavior.Phase = null; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } behavior.NodeId ??= node.Id; if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f && behavior.Phase is "travel-to-node" or "extract") { behavior.Phase = "travel-to-station"; } if (ship.DockedStationId == refinery.Id) { if (GetShipCargoAmount(ship) > 0.01f) { behavior.Phase = "unload"; } else if (behavior.Phase is "dock" or "unload") { behavior.Phase = "undock"; } } else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract") { behavior.Phase = "travel-to-station"; } switch (behavior.Phase) { case "extract": var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Extract, TargetEntityId = node.Id, TargetSystemId = node.SystemId, TargetPosition = extractionPosition, Threshold = 5f, }; break; case "travel-to-station": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = refinery.Radius + 8f, }; break; case "dock": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Dock, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = refinery.Radius + 4f, }; break; case "unload": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Unload, TargetEntityId = refinery.Id, TargetSystemId = refinery.SystemId, TargetPosition = refinery.Position, Threshold = 0f, }; break; case "undock": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.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 = ControllerTaskKind.Travel, TargetEntityId = node.Id, TargetSystemId = node.SystemId, TargetPosition = node.Position, Threshold = 18f, }; behavior.Phase = "travel-to-node"; break; } } private static string? SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string? fallbackItemId) { var candidateItemId = world.MarketOrders .Where(order => string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal) && order.Kind == MarketOrderKinds.Buy && order.ConstructionSiteId is null && order.State != MarketOrderStateKinds.Cancelled && order.RemainingAmount > 0.01f) .Select(order => new { ItemId = order.ItemId, Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation), }) .Where(entry => CanShipMineItem(world, ship, entry.ItemId)) .Where(entry => world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) .GroupBy(entry => entry.ItemId, StringComparer.Ordinal) .Select(group => new { ItemId = group.Key, Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f), }) .OrderByDescending(entry => entry.Score) .Select(entry => entry.ItemId) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(candidateItemId)) { return candidateItemId; } if (!string.IsNullOrWhiteSpace(fallbackItemId) && CanShipMineItem(world, ship, fallbackItemId) && world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) { return fallbackItemId; } return world.Nodes .Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId)) .OrderByDescending(node => node.OreRemaining) .Select(node => node.ItemId) .FirstOrDefault() ?? fallbackItemId; } private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) => world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition) && string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal) && HasShipCapabilities(ship.Definition, "mining"); internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId) { var preferred = preferredStationId is null ? null : world.Stations.FirstOrDefault(station => station.Id == preferredStationId); var bestOrder = world.MarketOrders .Where(order => order.Kind == MarketOrderKinds.Buy && order.ConstructionSiteId is null && order.State != MarketOrderStateKinds.Cancelled && order.ItemId == itemId && order.RemainingAmount > 0.01f) .Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId))) .Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal)) .Where(entry => CanStationReceiveItem(world, entry.Station!, itemId)) .OrderByDescending(entry => { var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; return entry.Order.Valuation - distancePenalty; }) .FirstOrDefault(); return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null); } private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId) { if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { return false; } var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal); } private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) => phase switch { "dock" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Dock, TargetEntityId = station.Id, TargetSystemId = station.SystemId, TargetPosition = station.Position, Threshold = 8f, }, "load" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Load, TargetEntityId = station.Id, TargetSystemId = station.SystemId, TargetPosition = station.Position, Threshold = 8f, }, "unload" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Unload, TargetEntityId = station.Id, TargetSystemId = station.SystemId, TargetPosition = station.Position, Threshold = 8f, }, "undock" => new ControllerTaskRuntime { Kind = ControllerTaskKind.Undock, TargetEntityId = station.Id, TargetSystemId = station.SystemId, TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z), Threshold = 8f, }, _ => CreateIdleTask(world.Balance.ArrivalThreshold), }; internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId) ? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId) : station is null ? null : GetConstructionSiteForStation(world, station.Id); if (station is null) { behavior.Kind = "idle"; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId)) { behavior.TargetEntityId = null; behavior.ModuleId = null; site = GetConstructionSiteForStation(world, station.Id); } var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); behavior.ModuleId = moduleId; if (moduleId is null) { ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } if (ship.DockedStationId is not null) { var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); if (dockedStation is not null) { dockedStation.DockedShipIds.Remove(ship.Id); ReleaseDockingPad(dockedStation, ship.Id); } ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; ship.Position = ResolveConstructionHoldPosition(ship, station, site, world); ship.TargetPosition = ship.Position; } var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world); var targetSystemId = site?.SystemId ?? station.SystemId; var targetCelestialId = site?.CelestialId ?? station.CelestialId; var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId) && string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal); var isAtConstructionHold = ship.SystemId == targetSystemId && (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial); if (isAtConstructionHold) { if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site)) { behavior.Phase = "deliver-to-site"; } else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site)) { behavior.Phase = "build-site"; } else if (site is not null) { behavior.Phase = "wait-for-materials"; } else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId])) { behavior.Phase = "construct-module"; } else { behavior.Phase = "wait-for-materials"; } } else if (behavior.Phase != "travel-to-station") { behavior.Phase = "travel-to-station"; } switch (behavior.Phase) { case "construct-module": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.ConstructModule, TargetEntityId = station.Id, TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; break; case "deliver-to-site": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.DeliverConstruction, TargetEntityId = site?.Id, TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; break; case "build-site": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.BuildConstructionSite, TargetEntityId = site?.Id, TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; break; case "wait-for-materials": ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, TargetEntityId = site?.Id ?? station.Id, TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 0f, }; break; default: ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, TargetEntityId = site?.Id ?? station.Id, TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; behavior.Phase = "travel-to-station"; break; } } internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) { var commander = GetShipCommander(world, ship); if (ship.Order is not null && controllerEvent == "arrived") { ship.Order = null; ship.ControllerTask.Kind = ControllerTaskKind.Idle; if (commander is not null) { commander.ActiveOrder = null; commander.ActiveTask = new CommanderTaskRuntime { Kind = ShipTaskKinds.Idle, Status = WorkStatus.Completed, TargetSystemId = ship.SystemId, Threshold = 0f, }; } return; } _shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent); if (commander is not null) { SyncShipToCommander(ship, commander); if (commander.ActiveTask is not null) { commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed; } } } internal void TrackHistory(ShipRuntime ship, string controllerEvent) { var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}"; if (signature == ship.LastSignature) { return; } ship.LastSignature = signature; var target = ship.ControllerTask.TargetEntityId ?? ship.ControllerTask.TargetSystemId ?? "none"; var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}"; ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}"); if (ship.History.Count > 18) { ship.History.RemoveAt(0); } } internal void EmitShipStateEvents( ShipRuntime ship, ShipState previousState, string previousBehavior, ControllerTaskKind 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.ToContractValue()} -> {ship.State.ToContractValue()}", 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.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc)); } if (controllerEvent != "none") { events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc)); } } internal static ControllerTaskRuntime CreateIdleTask(float threshold) => new() { Kind = ControllerTaskKind.Idle, Threshold = threshold, }; private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch { "travel" => ControllerTaskKind.Travel, "extract" => ControllerTaskKind.Extract, "dock" => ControllerTaskKind.Dock, "load" => ControllerTaskKind.Load, "unload" => ControllerTaskKind.Unload, "deliver-construction" => ControllerTaskKind.DeliverConstruction, "build-construction-site" => ControllerTaskKind.BuildConstructionSite, "attack-target" => ControllerTaskKind.AttackTarget, "construct-module" => ControllerTaskKind.ConstructModule, "undock" => ControllerTaskKind.Undock, _ => ControllerTaskKind.Idle, }; private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task) { if (commander is null) { return; } commander.ActiveTask = new CommanderTaskRuntime { Kind = task.Kind.ToContractValue(), Status = task.Status, TargetEntityId = task.TargetEntityId, TargetNodeId = task.TargetNodeId, TargetPosition = task.TargetPosition, TargetSystemId = task.TargetSystemId, Threshold = task.Threshold, }; } private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) { if (site is null || site.StationId is not null) { return GetConstructionHoldPosition(station, ship.Id); } var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); var anchorPosition = anchor?.Position ?? station.Position; return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); } private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world) { if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId)) { var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!); if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal)) { return direct; } } var hostileShips = world.Ships .Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f)) .ToList(); var hostileStations = world.Stations .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f)) .ToList(); var preferredSystemId = ship.DefaultBehavior.AreaSystemId; return hostileShips .Concat(hostileStations) .OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1) .ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) .FirstOrDefault(); } private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId) { var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f); if (ship is not null) { return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f); } var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId); return station is null ? null : new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f); } private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange); }