using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService; namespace SpaceGame.Api.Ships.Simulation; internal sealed class ShipAiService { private const float WarpEngageDistanceKilometers = 250_000f; private const float FrigateDps = 7f; private const float DestroyerDps = 12f; private const float CruiserDps = 18f; private const float CapitalDps = 26f; internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) { if (ship.ReplanCooldownSeconds > 0f) { ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); } var previousState = ship.State; var previousPlanId = ship.ActivePlan?.Id; var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; EnsurePlan(world, ship, events); ExecutePlan(world, ship, deltaSeconds, events); TrackHistory(ship); EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); } private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) { var emergencyPlan = BuildEmergencyPlan(world, ship); if (emergencyPlan is not null) { ship.LastReplanReason = "rule-safety"; ReplacePlan(ship, emergencyPlan, "rule-safety", events); return; } var topOrder = GetTopOrder(ship); if (topOrder is not null && topOrder.Status == OrderStatus.Queued) { topOrder.Status = OrderStatus.Active; } var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; var currentPlan = ship.ActivePlan; if (currentPlan is not null && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted && currentPlan.SourceKind == desiredSourceKind && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) && !ship.NeedsReplan) { return; } if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) { return; } ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order ? BuildOrderPlan(world, ship, topOrder!) : BuildBehaviorPlan(world, ship); if (nextPlan is null) { nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); } if (nextPlan.Kind != "idle") { ship.LastAccessFailureReason = null; } ReplacePlan(ship, nextPlan, "replanned", events); } private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) { var plan = ship.ActivePlan; if (plan is null) { ship.State = ShipState.Idle; ship.TargetPosition = ship.Position; return; } if (plan.CurrentStepIndex >= plan.Steps.Count) { CompletePlan(ship, plan, events); return; } plan.Status = AiPlanStatus.Running; plan.UpdatedAtUtc = DateTimeOffset.UtcNow; var step = plan.Steps[plan.CurrentStepIndex]; if (step.Status == AiPlanStepStatus.Planned) { step.Status = AiPlanStepStatus.Running; } if (step.CurrentSubTaskIndex >= step.SubTasks.Count) { CompleteStep(plan, step); return; } var subTask = step.SubTasks[step.CurrentSubTaskIndex]; if (subTask.Status == WorkStatus.Pending) { subTask.Status = WorkStatus.Active; } var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); switch (outcome) { case SubTaskOutcome.Active: step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; return; case SubTaskOutcome.Completed: subTask.Status = WorkStatus.Completed; subTask.Progress = 1f; step.CurrentSubTaskIndex += 1; step.BlockingReason = null; if (step.CurrentSubTaskIndex >= step.SubTasks.Count) { CompleteStep(plan, step); } return; case SubTaskOutcome.Failed: subTask.Status = WorkStatus.Failed; step.Status = AiPlanStepStatus.Failed; plan.Status = AiPlanStatus.Failed; plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; ship.NeedsReplan = true; ship.ReplanCooldownSeconds = 0.5f; ship.LastReplanReason = plan.FailureReason; return; } } private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) { step.Status = AiPlanStepStatus.Completed; step.BlockingReason = null; plan.CurrentStepIndex += 1; if (plan.CurrentStepIndex >= plan.Steps.Count) { plan.Status = AiPlanStatus.Completed; } } private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) { plan.Status = AiPlanStatus.Completed; var completedOrder = plan.SourceKind == AiPlanSourceKind.Order ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) : null; if (completedOrder is not null) { completedOrder.Status = OrderStatus.Completed; ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); } else if (plan.SourceKind == AiPlanSourceKind.DefaultBehavior && string.Equals(ship.DefaultBehavior.Kind, "repeat-orders", StringComparison.Ordinal) && ship.DefaultBehavior.RepeatOrders.Count > 0) { ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; } ship.ActivePlan = null; ship.NeedsReplan = true; ship.ReplanCooldownSeconds = 0.25f; ship.LastReplanReason = "plan-completed"; events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Label} completed {plan.Kind}.", DateTimeOffset.UtcNow)); } private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) { if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) { ship.ActivePlan.Status = AiPlanStatus.Interrupted; ship.ActivePlan.InterruptReason = reason; } ship.ActivePlan = nextPlan; ship.NeedsReplan = false; ship.ReplanCooldownSeconds = 0f; ship.LastReplanReason = reason; events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Label} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); } private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) { var policy = ResolvePolicy(world, ship.PolicySetId); if (policy is null) { return null; } var hullRatio = ship.Definition.MaxHealth <= 0.01f ? 1f : ship.Health / ship.Definition.MaxHealth; if (hullRatio > policy.FleeHullRatio) { return null; } var hostileNearby = world.Ships.Any(candidate => candidate.Health > 0f && candidate.FactionId != ship.FactionId && candidate.SystemId == ship.SystemId && candidate.Position.DistanceTo(ship.Position) <= 200f); if (!hostileNearby) { return null; } var safeStation = world.Stations .Where(station => station.FactionId == ship.FactionId) .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) .ThenBy(station => station.Position.DistanceTo(ship.Position)) .FirstOrDefault(); var plan = new ShipPlanRuntime { Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", SourceKind = AiPlanSourceKind.Rule, SourceId = ShipOrderKinds.Flee, Kind = "safety-flee", Summary = "Emergency retreat", }; if (safeStation is null) { plan.Steps.Add(CreateStep("step-flee-hold", "hold-position", "Hold position away from hostiles", [ CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) ])); return plan; } plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", [ CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(world.Balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) ])); plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", [ CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) ])); return plan; } private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { return order.Kind switch { var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), _ => null, }; } private ShipPlanRuntime? BuildBehaviorPlan(SimulationWorld world, ShipRuntime ship) { var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); return behaviorKind switch { "local-auto-mine" => BuildMiningBehaviorPlan(world, ship, "local-auto-mine", sourceId), "advanced-auto-mine" => BuildMiningBehaviorPlan(world, ship, "advanced-auto-mine", sourceId), "expert-auto-mine" => BuildMiningBehaviorPlan(world, ship, "expert-auto-mine", sourceId), "local-auto-trade" => BuildTradeBehaviorPlan(world, ship, "local-auto-trade", sourceId), "advanced-auto-trade" => BuildTradeBehaviorPlan(world, ship, "advanced-auto-trade", sourceId), "fill-shortages" => BuildTradeBehaviorPlan(world, ship, "fill-shortages", sourceId), "find-build-tasks" => BuildTradeBehaviorPlan(world, ship, "find-build-tasks", sourceId), "revisit-known-stations" => BuildTradeBehaviorPlan(world, ship, "revisit-known-stations", sourceId), "supply-fleet" => BuildTradeBehaviorPlan(world, ship, "supply-fleet", sourceId), "construct-station" => BuildConstructionBehaviorPlan(world, ship, sourceId), "attack-target" => BuildAttackBehaviorPlan(world, ship, sourceId), "protect-position" => BuildProtectPositionBehaviorPlan(world, ship, sourceId), "protect-ship" => BuildProtectShipBehaviorPlan(world, ship, sourceId), "protect-station" => BuildProtectStationBehaviorPlan(world, ship, sourceId), "police" => BuildPoliceBehaviorPlan(world, ship, sourceId), "patrol" => BuildPatrolBehaviorPlan(world, ship, sourceId), "dock-and-wait" => BuildDockAndWaitBehaviorPlan(world, ship, sourceId), "fly-and-wait" => BuildFlyAndWaitBehaviorPlan(ship, sourceId), "fly-to-object" => BuildFlyToObjectBehaviorPlan(world, ship, sourceId), "follow-ship" => BuildFollowShipBehaviorPlan(world, ship, sourceId), "hold-position" => BuildBehaviorHoldPositionPlan(ship, sourceId), "auto-salvage" => BuildAutoSalvageBehaviorPlan(world, ship, sourceId), "repeat-orders" => BuildRepeatOrdersBehaviorPlan(world, ship, sourceId), _ => CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"), }; } private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) { var assignment = ResolveAssignment(world, ship); return assignment is null ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) : (assignment.BehaviorKind, assignment.ObjectiveId); } private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) { var targetSystemId = order.TargetSystemId ?? ship.SystemId; var targetPosition = order.TargetPosition ?? ship.Position; return CreatePlan( ship, AiPlanSourceKind.Order, order.Id, "move", order.Label ?? "Move order", [ CreateStep("step-move", "travel", order.Label ?? "Travel", [ CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) ]) ]); } private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); if (station is null) { order.FailureReason = "station-missing"; return null; } return CreatePlan( ship, AiPlanSourceKind.Order, order.Id, "dock-at-station", order.Label ?? $"Dock at {station.Label}", [ CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", [ CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(world.Balance.ArrivalThreshold, station.Radius + 12f), 0f) ]), CreateStep("step-dock", "dock", $"Dock at {station.Label}", [ CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f) ]) ]); } private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) { order.FailureReason = "trade-order-incomplete"; return null; } var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); if (route is null) { order.FailureReason = "trade-route-missing"; return null; } return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); } private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var homeStation = ResolveStation(world, order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); var node = ResolveNode(world, order.NodeId); if (homeStation is null || node is null) { order.FailureReason = "mine-order-incomplete"; return null; } return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, homeStation, order.Label ?? $"Mine {node.ItemId}"); } private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); if (site is null) { order.FailureReason = "construction-site-missing"; return null; } var supportStation = ResolveSupportStation(world, ship, site); if (supportStation is null) { order.FailureReason = "support-station-missing"; return null; } return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); } private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var targetId = order.TargetEntityId; if (targetId is null) { order.FailureReason = "attack-target-missing"; return null; } return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); } private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) { return CreatePlan( ship, AiPlanSourceKind.Order, order.Id, "hold-position", order.Label ?? "Hold position", [ CreateStep("step-hold", "hold-position", order.Label ?? "Hold position", [ CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f) ]) ]); } private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); if (station is null) { order.FailureReason = "station-missing"; return null; } return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); } private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) { var systemId = order.TargetSystemId ?? ship.SystemId; var targetPosition = order.TargetPosition ?? ship.Position; return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); } private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var targetEntityId = order.TargetEntityId; if (targetEntityId is null) { order.FailureReason = "target-missing"; return null; } var objectTarget = ResolveObjectTarget(world, targetEntityId); if (objectTarget is null) { order.FailureReason = "target-missing"; return null; } return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); } private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); if (targetShip is null) { order.FailureReason = "target-ship-missing"; return null; } return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Label}"); } private ShipPlanRuntime? BuildMiningBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) { var assignment = ResolveAssignment(world, ship); var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); if (homeStation is null) { return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No home station"); } var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); return opportunity is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No mineable node") : BuildMiningPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, opportunity.Node, opportunity.DropOffStation, opportunity.Summary); } private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) { var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); return CreatePlan( ship, sourceKind, sourceId, "mine-and-deliver", summary, [ CreateStep("step-mine", "mine", $"Mine {node.ItemId}", [ CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.CargoCapacity) ]), CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}", [ CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity), CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) ]) ]); } private ShipPlanRuntime? BuildTradeBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) { var assignment = ResolveAssignment(world, ship); var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); if (string.Equals(behaviorKind, "supply-fleet", StringComparison.Ordinal)) { var fleetPlan = SelectFleetSupplyPlan(world, ship, homeStation); return fleetPlan is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No fleet to supply") : BuildFleetSupplyPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, fleetPlan); } var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); if (route is not null) { return BuildTradePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, route, route.Summary); } if (string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) { return BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); } return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No trade route"); } private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) { return CreatePlan( ship, sourceKind, sourceId, "trade-route", summary, [ CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", [ CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f), CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f) ]), CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}", [ CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f), CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f) ]) ]); } private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) { return CreatePlan( ship, sourceKind, sourceId, "supply-fleet", plan.Summary, [ CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", [ CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f), CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId), CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f), ]), CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Label}", [ CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Label}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f), CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId), ]) ]); } private ShipPlanRuntime? BuildConstructionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) ?? world.ConstructionSites .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) .FirstOrDefault(); if (site is null) { return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No construction site"); } var supportStation = ResolveSupportStation(world, ship, site); return supportStation is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No support station") : BuildConstructionPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, site, supportStation, $"Build {site.BlueprintId}"); } private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) { var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; return CreatePlan( ship, sourceKind, sourceId, "construction-support", summary, [ CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", [ CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) ]), CreateStep("step-construction-build", "build-site", $"Build {site.Id}", [ CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) ]) ]); } private ShipPlanRuntime? BuildAttackBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var targetId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; if (targetId is null) { return BuildPatrolBehaviorPlan(world, ship, sourceId); } return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetId, assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId, "Attack target"); } private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) { return CreatePlan( ship, sourceKind, sourceId, "attack-target", summary, [ CreateStep("step-attack", "attack-target", summary, [ CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) ]) ]); } private ShipPlanRuntime BuildPatrolBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); if (patrolThreat is not null) { return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, patrolThreat.EntityId, patrolThreat.SystemId, "Patrol intercept"); } var patrolPoints = ship.DefaultBehavior.PatrolPoints; Vector3 targetPosition; string targetSystemId; if (patrolPoints.Count > 0) { var index = ship.DefaultBehavior.PatrolIndex % patrolPoints.Count; targetPosition = patrolPoints[index]; ship.DefaultBehavior.PatrolIndex = (index + 1) % patrolPoints.Count; targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; } else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? ResolveAssignment(world, ship)?.HomeStationId) is { } homeStation) { var patrolRadius = homeStation.Radius + 90f; targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); targetSystemId = homeStation.SystemId; } else { targetPosition = ship.Position; targetSystemId = ship.SystemId; } return CreatePlan( ship, AiPlanSourceKind.DefaultBehavior, sourceId, "patrol", "Patrol sector", [ CreateStep("step-patrol-travel", "travel", "Travel patrol waypoint", [ CreateSubTask("sub-patrol-travel", ShipTaskKinds.Travel, "Travel patrol waypoint", targetSystemId, targetPosition, null, 10f, 0f) ]), CreateStep("step-patrol-hold", "hold-position", "Hold patrol waypoint", [ CreateSubTask("sub-patrol-hold", ShipTaskKinds.HoldPosition, "Hold patrol waypoint", targetSystemId, targetPosition, null, 0f, 2f) ]) ]); } private ShipPlanRuntime? BuildPoliceBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; var contact = SelectPoliceContact(world, ship, systemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); if (contact is null) { return BuildPatrolBehaviorPlan(world, ship, sourceId); } return contact.Engage ? BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, "Police engage") : BuildFollowPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Police inspect"); } private ShipPlanRuntime BuildProtectPositionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); if (threat is not null) { return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, "Protect position"); } return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Protect position"); } private ShipPlanRuntime BuildProtectShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var guardTarget = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); if (guardTarget is null) { return BuildPatrolBehaviorPlan(world, ship, sourceId); } var threat = SelectThreatTarget(world, ship, guardTarget.SystemId, guardTarget.Position, MathF.Max(90f, ship.DefaultBehavior.Radius), excludeEntityId: guardTarget.Id); if (threat is not null) { return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {guardTarget.Definition.Label}"); } return BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, guardTarget, MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Escort {guardTarget.Definition.Label}"); } private ShipPlanRuntime BuildProtectStationBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); if (station is null) { return BuildPatrolBehaviorPlan(world, ship, sourceId); } var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); if (threat is not null) { return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {station.Label}"); } return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Guard {station.Label}"); } private ShipPlanRuntime BuildDockAndWaitBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var station = ResolveStation(world, ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); return station is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No station to dock") : BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Dock and wait at {station.Label}"); } private ShipPlanRuntime BuildFlyAndWaitBehaviorPlan(ShipRuntime ship, string sourceId) { var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Fly and wait"); } private ShipPlanRuntime BuildFlyToObjectBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var targetEntityId = ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; var objectTarget = ResolveObjectTarget(world, targetEntityId); return objectTarget is null || targetEntityId is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No object target") : BuildFlyToObjectPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, "Fly to object"); } private ShipPlanRuntime BuildFollowShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); return targetShip is null ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No ship to follow") : BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetShip, MathF.Max(16f, ship.DefaultBehavior.Radius), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Follow {targetShip.Definition.Label}"); } private ShipPlanRuntime BuildBehaviorHoldPositionPlan(ShipRuntime ship, string sourceId) { var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Hold position"); } private ShipPlanRuntime BuildAutoSalvageBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { var assignment = ResolveAssignment(world, ship); var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); var salvage = SelectSalvageOpportunity(world, ship, homeStation); if (salvage is null || homeStation is null) { return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No salvage target"); } var approach = GetFormationPosition(salvage.Wreck.Position, ship.Id, MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f)); return CreatePlan( ship, AiPlanSourceKind.DefaultBehavior, sourceId, "auto-salvage", salvage.Summary, [ CreateStep("step-salvage-collect", "salvage", $"Salvage {salvage.Wreck.ItemId}", [ CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {salvage.Wreck.Id}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, 0f), CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {salvage.Wreck.ItemId}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), ]), CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}", [ CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), ]) ]); } private ShipPlanRuntime BuildRepeatOrdersBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) { if (ship.DefaultBehavior.RepeatOrders.Count == 0) { return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No repeat orders"); } var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; var syntheticOrder = new ShipOrderRuntime { Id = $"repeat-{ship.Id}-{ship.DefaultBehavior.RepeatIndex}", Kind = template.Kind, Label = template.Label, TargetEntityId = template.TargetEntityId, TargetSystemId = template.TargetSystemId, TargetPosition = template.TargetPosition, SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, NodeId = template.NodeId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = template.WaitSeconds, Radius = template.Radius, MaxSystemRange = template.MaxSystemRange, KnownStationsOnly = template.KnownStationsOnly, }; return BuildOrderPlan(world, ship, syntheticOrder) ?? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Invalid repeat order"); } private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) { return CreatePlan( ship, sourceKind, sourceId, "dock-and-wait", summary, [ CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", [ CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f), CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f), CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds), ]) ]); } private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) { return CreatePlan( ship, sourceKind, sourceId, "fly-and-wait", summary, [ CreateStep("step-fly-wait", "fly-and-wait", summary, [ CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f), CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds), ]) ]); } private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) { return CreatePlan( ship, sourceKind, sourceId, "fly-to-object", summary, [ CreateStep("step-fly-object", "fly-to-object", summary, [ CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)), ]) ]); } private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) { return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); } private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) { return CreatePlan( ship, sourceKind, sourceId, "follow-ship", summary, [ CreateStep("step-follow", "follow-target", summary, [ CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), ]) ]); } private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) { return CreatePlan( ship, sourceKind, sourceId, "idle", summary, [ CreateStep("step-idle", "hold-position", summary, [ CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) ]) ]); } private static ShipPlanRuntime CreatePlan( ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string kind, string summary, IReadOnlyList steps) { var plan = new ShipPlanRuntime { Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", SourceKind = sourceKind, SourceId = sourceId, Kind = kind, Summary = summary, }; plan.Steps.AddRange(steps); return plan; } private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) { var step = new ShipPlanStepRuntime { Id = id, Kind = kind, Summary = summary, }; step.SubTasks.AddRange(subTasks); return step; } private static ShipSubTaskRuntime CreateSubTask( string id, string kind, string summary, string targetSystemId, Vector3 targetPosition, string? targetEntityId, float threshold, float amount, string? itemId = null, string? moduleId = null, string? targetNodeId = null) => new() { Id = id, Kind = kind, Summary = summary, TargetSystemId = targetSystemId, TargetPosition = targetPosition, TargetEntityId = targetEntityId, TargetNodeId = targetNodeId, ItemId = itemId, ModuleId = moduleId, Threshold = threshold, Amount = amount, }; private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) { return subTask.Kind switch { var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), _ => SubTaskOutcome.Failed, }; } private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { ship.State = ShipState.HoldingPosition; ship.TargetPosition = subTask.TargetPosition ?? ship.Position; ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); if (targetShip is null) { subTask.BlockingReason = "follow-target-missing"; return SubTaskOutcome.Failed; } var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); subTask.TargetSystemId = targetShip.SystemId; subTask.TargetPosition = desiredPosition; subTask.BlockingReason = null; if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) { return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); } ship.State = ShipState.HoldingPosition; ship.TargetPosition = desiredPosition; ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) { if (subTask.TargetPosition is null || subTask.TargetSystemId is null) { subTask.BlockingReason = "travel-target-missing"; ship.State = ShipState.Blocked; return SubTaskOutcome.Failed; } var targetPosition = ResolveCurrentTargetPosition(world, subTask); var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); ship.TargetPosition = targetPosition; if (ship.SystemId != subTask.TargetSystemId) { if (!HasShipCapabilities(ship.Definition, "ftl")) { subTask.BlockingReason = "ftl-unavailable"; ship.State = ShipState.Blocked; return SubTaskOutcome.Failed; } var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); } var currentCelestial = ResolveCurrentCelestial(world, ship); if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) { if (!HasShipCapabilities(ship.Definition, "warp")) { return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); } return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); } if (targetCelestial is not null && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers && HasShipCapabilities(ship.Definition, "warp")) { return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); } return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); } private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); var hostileStation = hostileShip is null ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) : null; if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) { subTask.BlockingReason = "friendly-target"; return SubTaskOutcome.Failed; } if (hostileShip is null && hostileStation is null) { return SubTaskOutcome.Completed; } var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; var targetPosition = hostileShip?.Position ?? hostileStation!.Position; var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; subTask.TargetSystemId = targetSystemId; subTask.TargetPosition = targetPosition; subTask.Threshold = attackRange; if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) { return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); } ship.State = ShipState.EngagingTarget; ship.TargetPosition = targetPosition; ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); subTask.Progress = 1f; if (hostileShip is not null) { hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); if (node is null || !CanExtractNode(ship, node, world)) { subTask.BlockingReason = "node-missing"; ship.State = ShipState.Blocked; return SubTaskOutcome.Failed; } var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); ship.TargetPosition = targetPosition; if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) { ship.State = ShipState.MiningApproach; ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; } var cargoAmount = GetShipCargoAmount(ship); if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) { return SubTaskOutcome.Completed; } ship.State = ShipState.Mining; if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.MiningCycleSeconds)) { return SubTaskOutcome.Active; } var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); var mined = MathF.Min(world.Balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); mined = MathF.Min(mined, node.OreRemaining); if (mined <= 0.01f) { return SubTaskOutcome.Completed; } AddInventory(ship.Inventory, node.ItemId, mined); node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f || node.OreRemaining <= 0.01f) { return SubTaskOutcome.Completed; } subTask.ElapsedSeconds = 0f; return SubTaskOutcome.Active; } private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var station = ResolveStation(world, subTask.TargetEntityId); if (station is null) { subTask.BlockingReason = "dock-target-missing"; ship.State = ShipState.Blocked; return SubTaskOutcome.Failed; } var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); if (padIndex is null) { ship.State = ShipState.AwaitingDock; ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) { ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); } subTask.Status = WorkStatus.Blocked; subTask.BlockingReason = "waiting-for-pad"; return SubTaskOutcome.Active; } subTask.Status = WorkStatus.Active; subTask.BlockingReason = null; ship.AssignedDockingPadIndex = padIndex; var padPosition = GetDockingPadPosition(station, padIndex.Value); ship.TargetPosition = padPosition; if (ship.Position.DistanceTo(padPosition) > 4f) { ship.State = ShipState.DockingApproach; ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; } ship.State = ShipState.Docking; if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.DockingDuration)) { return SubTaskOutcome.Active; } ship.State = ShipState.Docked; ship.DockedStationId = station.Id; station.DockedShipIds.Add(ship.Id); ship.KnownStationIds.Add(station.Id); ship.Position = padPosition; ship.TargetPosition = padPosition; return SubTaskOutcome.Completed; } private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { if (ship.DockedStationId is null) { return SubTaskOutcome.Completed; } var station = ResolveStation(world, ship.DockedStationId); if (station is null) { ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; return SubTaskOutcome.Completed; } var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); ship.TargetPosition = undockTarget; ship.State = ShipState.Undocking; if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.UndockingDuration)) { ship.Position = GetShipDockedPosition(ship, station); return SubTaskOutcome.Active; } ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) { return SubTaskOutcome.Active; } station.DockedShipIds.Remove(ship.Id); ReleaseDockingPad(station, ship.Id); ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; return SubTaskOutcome.Completed; } private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { if (ship.DockedStationId is null) { subTask.BlockingReason = "not-docked"; return SubTaskOutcome.Failed; } var station = ResolveStation(world, ship.DockedStationId); if (station is null) { subTask.BlockingReason = "station-missing"; return SubTaskOutcome.Failed; } ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.State = ShipState.Loading; var itemId = subTask.ItemId; if (itemId is null) { return SubTaskOutcome.Completed; } var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity; var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Trade); var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); if (moved > 0.01f) { RemoveInventory(station.Inventory, itemId, moved); AddInventory(ship.Inventory, itemId, moved); } var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { if (ship.DockedStationId is null) { subTask.BlockingReason = "not-docked"; return SubTaskOutcome.Failed; } var station = ResolveStation(world, ship.DockedStationId); if (station is null) { subTask.BlockingReason = "station-missing"; return SubTaskOutcome.Failed; } ship.TargetPosition = GetShipDockedPosition(ship, station); ship.Position = ship.TargetPosition; ship.State = ShipState.Transferring; var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); if (subTask.ItemId is not null) { var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); RemoveInventory(ship.Inventory, subTask.ItemId, accepted); subTask.Progress = subTask.Amount <= 0.01f ? 1f : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) { var moved = MathF.Min(amount, transferRate * deltaSeconds); var accepted = TryAddStationInventory(world, station, itemId, moved); RemoveInventory(ship.Inventory, itemId, accepted); if (accepted > 0.01f) { return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } } return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); if (targetShip is null) { subTask.BlockingReason = "target-ship-missing"; return SubTaskOutcome.Failed; } var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); subTask.TargetSystemId = targetShip.SystemId; subTask.TargetPosition = desiredPosition; if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) { return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); } ship.State = ShipState.Transferring; ship.TargetPosition = desiredPosition; ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); if (subTask.ItemId is null) { return SubTaskOutcome.Completed; } var targetCapacity = MathF.Max(0f, targetShip.Definition.CargoCapacity - GetShipCargoAmount(targetShip)); if (targetCapacity <= 0.01f) { subTask.BlockingReason = "target-cargo-full"; return SubTaskOutcome.Failed; } var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); if (moved > 0.01f) { RemoveInventory(ship.Inventory, subTask.ItemId, moved); AddInventory(targetShip.Inventory, subTask.ItemId, moved); } var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.CargoCapacity - 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); if (wreck is null) { return SubTaskOutcome.Completed; } var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); ship.TargetPosition = desiredPosition; if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) { subTask.TargetSystemId = wreck.SystemId; subTask.TargetPosition = desiredPosition; return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); } ship.State = ShipState.Transferring; var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); if (remainingCapacity <= 0.01f) { return SubTaskOutcome.Completed; } if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, world.Balance.MiningCycleSeconds * 0.8f))) { return SubTaskOutcome.Active; } var salvageRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); if (recovered > 0.01f) { AddInventory(ship.Inventory, wreck.ItemId, recovered); wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); } if (wreck.RemainingAmount <= 0.01f) { world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); } subTask.ElapsedSeconds = 0f; return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); var station = site is null ? null : ResolveSupportStation(world, ship, site); if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) { subTask.BlockingReason = "construction-target-missing"; return SubTaskOutcome.Failed; } var supportPosition = ResolveSupportPosition(ship, station, site, world); if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) { ship.State = ShipState.LocalFlight; ship.TargetPosition = supportPosition; ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; } ship.TargetPosition = supportPosition; ship.Position = supportPosition; ship.State = ShipState.DeliveringConstruction; var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Construction); foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) { var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); var remaining = MathF.Max(0f, required.Value - delivered); if (remaining <= 0.01f) { continue; } var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); if (moved <= 0.01f) { continue; } RemoveInventory(station.Inventory, required.Key, moved); AddInventory(site.Inventory, required.Key, moved); AddInventory(site.DeliveredItems, required.Key, moved); break; } subTask.Progress = site.RequiredItems.Count == 0 ? 1f : site.RequiredItems.Sum(required => required.Value <= 0.01f ? 1f : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); var station = site is null ? null : ResolveSupportStation(world, ship, site); if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) { subTask.BlockingReason = "construction-site-missing"; return SubTaskOutcome.Failed; } var supportPosition = ResolveSupportPosition(ship, station, site, world); if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) { ship.State = ShipState.LocalFlight; ship.TargetPosition = supportPosition; ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; } if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) { ship.State = ShipState.WaitingMaterials; subTask.Status = WorkStatus.Blocked; subTask.BlockingReason = "waiting-materials"; return SubTaskOutcome.Active; } subTask.Status = WorkStatus.Active; subTask.BlockingReason = null; ship.TargetPosition = supportPosition; ship.Position = supportPosition; ship.State = ShipState.Constructing; site.AssignedConstructorShipIds.Add(ship.Id); site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); if (site.Progress < recipe.Duration) { return SubTaskOutcome.Active; } if (site.StationId is null) { CompleteStationFoundation(world, station, site); } else { AddStationModule(world, station, site.BlueprintId); PrepareNextConstructionSiteStep(world, station, site); } site.State = ConstructionSiteStateKinds.Completed; return SubTaskOutcome.Completed; } private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) { subTask.TotalSeconds = requiredSeconds; subTask.ElapsedSeconds += deltaSeconds; subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); if (subTask.ElapsedSeconds < requiredSeconds) { return false; } subTask.ElapsedSeconds = 0f; return true; } private SubTaskOutcome UpdateLocalTravel( SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) { var distance = ship.Position.DistanceTo(targetPosition); ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; ship.SpatialState.Transit = null; ship.SpatialState.DestinationNodeId = targetCelestial?.Id; subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); if (distance <= MathF.Max(subTask.Threshold, world.Balance.ArrivalThreshold)) { ship.Position = targetPosition; ship.TargetPosition = targetPosition; ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } ship.State = ShipState.LocalFlight; ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); return SubTaskOutcome.Active; } private SubTaskOutcome UpdateWarpTransit( SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial, bool completeOnArrival) { var transit = ship.SpatialState.Transit; if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id) { transit = new ShipTransitRuntime { Regime = MovementRegimeKinds.Warp, OriginNodeId = ship.SpatialState.CurrentCelestialId, DestinationNodeId = targetCelestial.Id, StartedAtUtc = world.GeneratedAtUtc, }; ship.SpatialState.Transit = transit; subTask.ElapsedSeconds = 0f; } ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; ship.SpatialState.CurrentCelestialId = null; ship.SpatialState.DestinationNodeId = targetCelestial.Id; var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); if (ship.State != ShipState.Warping) { ship.State = ShipState.SpoolingWarp; if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) { return SubTaskOutcome.Active; } ship.State = ShipState.Warping; } var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null ? ship.Position.DistanceTo(targetPosition) : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); subTask.Progress = transit.Progress; if (ship.Position.DistanceTo(targetPosition) > 18f) { return SubTaskOutcome.Active; } return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); } private SubTaskOutcome UpdateFtlTransit( SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, string targetSystemId, Vector3 entryPosition, CelestialRuntime? targetCelestial, bool completeOnArrival, Vector3 finalTargetPosition) { var destinationNodeId = targetCelestial?.Id; var transit = ship.SpatialState.Transit; if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId) { transit = new ShipTransitRuntime { Regime = MovementRegimeKinds.FtlTransit, OriginNodeId = ship.SpatialState.CurrentCelestialId, DestinationNodeId = destinationNodeId, StartedAtUtc = world.GeneratedAtUtc, }; ship.SpatialState.Transit = transit; subTask.ElapsedSeconds = 0f; } ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; ship.SpatialState.CurrentCelestialId = null; ship.SpatialState.DestinationNodeId = destinationNodeId; if (ship.State != ShipState.Ftl) { ship.State = ShipState.SpoolingFtl; if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) { return SubTaskOutcome.Active; } ship.State = ShipState.Ftl; } var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); subTask.Progress = transit.Progress; if (transit.Progress < 0.999f) { return SubTaskOutcome.Active; } ship.Position = entryPosition; ship.TargetPosition = finalTargetPosition; ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.Transit = null; ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; ship.SpatialState.DestinationNodeId = targetCelestial?.Id; ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) { ship.Position = targetPosition; ship.TargetPosition = targetPosition; ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.Transit = null; ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; ship.SpatialState.DestinationNodeId = targetCelestial?.Id; ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) { if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) { var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); if (ship is not null) { return ship.Position; } var station = ResolveStation(world, subTask.TargetEntityId); if (station is not null) { return station.Position; } var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); if (celestial is not null) { return celestial.Position; } var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); if (wreck is not null) { return wreck.Position; } } return subTask.TargetPosition ?? Vector3.Zero; } private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) { if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) { var station = ResolveStation(world, subTask.TargetEntityId); if (station?.CelestialId is not null) { return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); } var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); if (site?.CelestialId is not null) { return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); } var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); if (celestial is not null) { return celestial; } if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) { return world.Celestials .Where(candidate => candidate.SystemId == wreck.SystemId) .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) .FirstOrDefault(); } } return world.Celestials .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) .FirstOrDefault(); } private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) { if (ship.SpatialState.CurrentCelestialId is not null) { return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); } return world.Celestials .Where(candidate => candidate.SystemId == ship.SystemId) .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) .FirstOrDefault(); } private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; private static float GetLocalTravelSpeed(ShipRuntime ship) => SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); private static float GetWarpTravelSpeed(ShipRuntime ship) => SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); private static float GetSkillFactor(int skillLevel) => Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); private static int GetEffectiveSkillLevel( SimulationWorld world, ShipRuntime ship, Func captainSelector, Func managerSelector) { var captainLevel = captainSelector(ship.Skills); if (ship.CommanderId is null) { return captainLevel; } var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); var manager = shipCommander?.ParentCommanderId is null ? shipCommander : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); } private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) { if (explicitRange > 0) { return explicitRange; } var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); return behaviorKind switch { "local-auto-mine" or "local-auto-trade" => 0, "advanced-auto-mine" => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), "advanced-auto-trade" => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), "expert-auto-mine" => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), "fill-shortages" or "find-build-tasks" or "revisit-known-stations" or "supply-fleet" => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), _ => Math.Max(world.Systems.Count - 1, 0), }; } private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) { if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) { return 0; } var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); return world.Systems .OrderBy(system => system.Position.DistanceTo(originPosition)) .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) .Select(system => system.Definition.Id) .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) .Count(); } private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; private static float GetShipDamagePerSecond(ShipRuntime ship) => ship.Definition.Class switch { "frigate" => FrigateDps, "destroyer" => DestroyerDps, "cruiser" => CruiserDps, "capital" => CapitalDps, _ => 4f, }; private static MiningOpportunity? SelectMiningOpportunity( SimulationWorld world, ShipRuntime ship, StationRuntime homeStation, CommanderAssignmentRuntime? assignment, string behaviorKind) { var policy = ResolvePolicy(world, ship.PolicySetId); var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.PreferredItemId; var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); string? deniedReason = null; var opportunity = world.Nodes .Where(node => { if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) { return false; } if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) { deniedReason ??= reason; return false; } return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); }) .Select(node => { var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) + (node.OreRemaining * 0.025f) + (demandScore * (string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal) ? 22f : 12f)) + (effectiveMiningSkill * 10f) - distancePenalty - routeRiskPenalty - node.Position.DistanceTo(ship.Position); return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); }) .OrderByDescending(candidate => candidate.Score) .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) .FirstOrDefault(); if (opportunity is null && deniedReason is not null) { ship.LastAccessFailureReason = deniedReason; } return opportunity; } private static TradeRoutePlan? SelectTradeRoute( SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation, string behaviorKind, bool knownStationsOnly) { var policy = ResolvePolicy(world, ship.PolicySetId); var stationsById = world.Stations .Where(station => station.FactionId == ship.FactionId) .ToDictionary(station => station.Id, StringComparer.Ordinal); var originSystemId = homeStation?.SystemId ?? ship.SystemId; var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal); string? deniedReason = null; var route = world.MarketOrders .Where(order => order.FactionId == ship.FactionId && order.Kind == MarketOrderKinds.Buy && order.RemainingAmount > 0.01f) .Select(order => { StationRuntime? destination = null; ConstructionSiteRuntime? destinationSite = null; if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) { destination = destinationStation; } else if (order.ConstructionSiteId is not null) { destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); if (destinationSite is not null) { destination = ResolveSupportStation(world, ship, destinationSite); } } if (destination is null) { return null; } if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) { deniedReason ??= destinationDeniedReason; return null; } if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) { return null; } if (requireKnownStations && ship.KnownStationIds.Count > 0 && !ship.KnownStationIds.Contains(destination.Id) && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) { return null; } if (string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is null) { return null; } if (!string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is not null) { return null; } var source = stationsById.Values .Where(station => { if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) { return false; } if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) { deniedReason ??= sourceDeniedReason; return false; } if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) { return false; } return !requireKnownStations || ship.KnownStationIds.Count == 0 || ship.KnownStationIds.Contains(station.Id) || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); }) .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) .ThenBy(station => station.Id, StringComparer.Ordinal) .FirstOrDefault(); if (source is null) { return null; } var shortageBias = string.Equals(behaviorKind, "fill-shortages", StringComparison.Ordinal) ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f : 0f; var buildBias = destinationSite is null ? 0f : 65f; var revisitBias = string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) ? 28f : 0f; var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; var riskPenalty = (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); var score = (order.Valuation * 50f) + shortageBias + buildBias + revisitBias + regionalNeedBias + (effectiveTradeSkill * 12f) - systemRangePenalty - riskPenalty - distanceScore; var summary = destinationSite is null ? $"{order.ItemId}: {source.Label} -> {destination.Label}" : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; return new TradeRoutePlan(source, destination, order.ItemId, score, summary); }) .Where(route => route is not null) .Cast() .OrderByDescending(route => route.Score) .ThenBy(route => route.ItemId, StringComparer.Ordinal) .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) .FirstOrDefault(); if (route is null && deniedReason is not null) { ship.LastAccessFailureReason = deniedReason; } return route; } private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) { var assignment = ResolveAssignment(world, ship); var targetCandidates = world.Ships .Where(candidate => candidate.Id != ship.Id && candidate.FactionId == ship.FactionId && candidate.Definition.CargoCapacity > 0.01f && (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) .OrderByDescending(candidate => candidate.Definition.Kind == "military" ? 1 : 0) .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) .ToList(); if (targetCandidates.Count == 0) { return null; } var sourceStations = world.Stations .Where(station => station.FactionId == ship.FactionId) .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) .ThenBy(station => station.Id, StringComparer.Ordinal) .ToList(); foreach (var target in targetCandidates) { var itemId = assignment?.ItemId ?? sourceStations .SelectMany(station => station.Inventory) .Where(entry => entry.Value > 2f) .OrderByDescending(entry => entry.Value) .ThenBy(entry => entry.Key, StringComparer.Ordinal) .Select(entry => entry.Key) .FirstOrDefault(); if (itemId is null) { continue; } var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); if (source is null) { continue; } var amount = MathF.Min(MathF.Max(10f, ship.Definition.CargoCapacity * 0.5f), GetInventoryAmount(source.Inventory, itemId)); return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Label} with {itemId}"); } return null; } private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) { var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null ? [homeStation.Id] : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); return candidateIds .Select(id => ResolveStation(world, id)) .Where(station => station is not null && station.FactionId == ship.FactionId) .Cast() .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) .ThenBy(station => station.Position.DistanceTo(ship.Position)) .FirstOrDefault(); } private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) { if (!string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal)) { return homeStation; } return world.Stations .Where(station => station.FactionId == ship.FactionId) .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) .ThenBy(station => station.Id, StringComparer.Ordinal) .FirstOrDefault() ?? homeStation; } private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) { var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? .CommoditySignals .FirstOrDefault(candidate => candidate.ItemId == itemId); var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) .Join( world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), bottleneck => bottleneck.RegionId, region => region.Id, (bottleneck, _) => bottleneck.Severity) .DefaultIfEmpty() .Max() ?? 0f; if (signal is null) { return regionalBottleneckScore * 8f; } return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); } private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) { var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); if (region is null) { return 0f; } var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); } private static ThreatTargetCandidate? SelectThreatTarget( SimulationWorld world, ShipRuntime ship, string targetSystemId, Vector3 anchorPosition, float radius, string? excludeEntityId = null) { var policy = ResolvePolicy(world, ship.PolicySetId); return world.Ships .Where(candidate => candidate.Id != excludeEntityId && candidate.Health > 0f && candidate.FactionId != ship.FactionId && string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) .Select(candidate => new ThreatTargetCandidate( candidate.Id, candidate.SystemId, candidate.Position, 100f + (candidate.Definition.Kind == "military" ? 30f : 0f) - candidate.Position.DistanceTo(anchorPosition) - candidate.Position.DistanceTo(ship.Position) + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) .Concat(world.Stations .Where(candidate => candidate.Id != excludeEntityId && candidate.FactionId != ship.FactionId && string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) .OrderByDescending(candidate => candidate.Score) .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) .FirstOrDefault(); } private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) { var policy = ResolvePolicy(world, ship.PolicySetId); return world.Ships .Where(candidate => candidate.Id != ship.Id && candidate.Health > 0f && candidate.FactionId != ship.FactionId && string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) .Select(candidate => { var engage = candidate.Definition.Kind == "military" || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); var score = (engage ? 80f : 40f) - candidate.Position.DistanceTo(anchorPosition) - candidate.Position.DistanceTo(ship.Position) + (candidate.Definition.Kind == "transport" ? 8f : 0f); return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); }) .OrderByDescending(candidate => candidate.Score) .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) .FirstOrDefault(); } private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) { if (homeStation is null) { return null; } var rangeBudget = ResolveBehaviorSystemRange(world, ship, "auto-salvage", ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); return world.Wrecks .Where(wreck => wreck.RemainingAmount > 0.01f && IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) .Select(wreck => new SalvageOpportunity( wreck, (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) .OrderByDescending(candidate => candidate.Score) .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) .FirstOrDefault(); } private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) { if (entityId is null) { return null; } if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) { return (ship.SystemId, ship.Position); } if (ResolveStation(world, entityId) is { } station) { return (station.SystemId, station.Position); } if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) { return (celestial.SystemId, celestial.Position); } if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) { var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; return (site.SystemId, position); } if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) { return (wreck.SystemId, wreck.Position); } return null; } private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) { var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); var angle = (hash % 360) * (MathF.PI / 180f); return new Vector3( anchorPosition.X + (MathF.Cos(angle) * radius), anchorPosition.Y, anchorPosition.Z + (MathF.Sin(angle) * radius)); } private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) { var source = ResolveStation(world, sourceStationId); var destination = ResolveStation(world, destinationStationId); return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); } private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); private static bool IsSystemAllowed( SimulationWorld world, PolicySetRuntime? policy, string factionId, string systemId, string accessKind) => TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); private static bool TryCheckSystemAllowed( SimulationWorld world, PolicySetRuntime? policy, string factionId, string systemId, string accessKind, out string? denialReason) { denialReason = null; if (policy?.BlacklistedSystemIds.Contains(systemId) == true) { denialReason = $"blacklisted:{systemId}"; return false; } var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) { return true; } var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); if (!hasAccess) { denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; return false; } if (policy?.AvoidHostileSystems != true) { return true; } if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) { denialReason = $"hostile-authority:{authorityFactionId}"; return false; } var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => !string.Equals(candidate, factionId, StringComparison.Ordinal) && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); if (hostileInfluencer is not null) { denialReason = $"hostile-influence:{hostileInfluencer}"; return false; } return true; } private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => ship.CommanderId is null ? null : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => ship.OrderQueue .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) .OrderByDescending(order => order.Priority) .ThenBy(order => order.CreatedAtUtc) .FirstOrDefault(); private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) { return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) ?? world.Stations .Where(station => station.FactionId == ship.FactionId) .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) .ThenBy(station => station.Id, StringComparer.Ordinal) .FirstOrDefault(); } private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) { if (ship.DockedStationId is not null) { return GetShipDockedPosition(ship, station); } if (site?.StationId is null && site is not null) { var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); } return GetConstructionHoldPosition(station, ship.Id); } private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); private static void TrackHistory(ShipRuntime ship) { var plan = ship.ActivePlan; var step = GetCurrentStep(plan); var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; if (ship.LastSignature == signature) { return; } ship.LastSignature = signature; ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); if (ship.History.Count > 24) { ship.History.RemoveAt(0); } } private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) { var currentPlanId = ship.ActivePlan?.Id; var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; var occurredAtUtc = DateTimeOffset.UtcNow; if (previousState != ship.State) { events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); } if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) { events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Label} switched active plan.", occurredAtUtc)); } if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) { events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Label} advanced plan step.", occurredAtUtc)); } } private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) { var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); if (anchor is null || site.BlueprintId is null) { site.State = ConstructionSiteStateKinds.Destroyed; return; } var station = new StationRuntime { Id = $"station-{world.Stations.Count + 1}", SystemId = site.SystemId, Label = BuildFoundedStationLabel(site.TargetDefinitionId), Category = "station", Objective = DetermineFoundationObjective(site.TargetDefinitionId), Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, Position = anchor.Position, FactionId = site.FactionId, CelestialId = site.CelestialId, Health = 600f, MaxHealth = 600f, }; foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) { AddStationModule(world, station, moduleId); } world.Stations.Add(station); StationLifecycleService.EnsureStationCommander(world, station); anchor.OccupyingStructureId = station.Id; site.StationId = station.Id; PrepareNextConstructionSiteStep(world, station, site); } private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) { var modules = new List { "module_arg_dock_m_01_lowtech" }; foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) { if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { var storageModule = GetStorageRequirement(itemDefinition.CargoKind); if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) { modules.Add(storageModule); } else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) { modules.Add("module_arg_stor_container_m_01"); } } } if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) { modules.Add("module_arg_stor_container_m_01"); } if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) { modules.Add("module_gen_prod_energycells_01"); } modules.Add(primaryModuleId); return modules.Distinct(StringComparer.Ordinal).ToList(); } private static string DetermineFoundationObjective(string commodityId) => commodityId switch { "energycells" => "power", "water" => "water", "refinedmetals" => "refinery", "hullparts" => "hullparts", "claytronics" => "claytronics", "shipyard" => "shipyard", _ => "general", }; private static string BuildFoundedStationLabel(string commodityId) => $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; private enum SubTaskOutcome { Active, Completed, Failed, } private sealed record TradeRoutePlan( StationRuntime SourceStation, StationRuntime DestinationStation, string ItemId, float Score, string Summary); private sealed record MiningOpportunity( ResourceNodeRuntime Node, StationRuntime DropOffStation, float Score, string Summary); private sealed record FleetSupplyPlan( StationRuntime SourceStation, ShipRuntime TargetShip, string ItemId, float Amount, float Radius, string Summary); private sealed record ThreatTargetCandidate( string EntityId, string SystemId, Vector3 Position, float Score); private sealed record PoliceContactCandidate( string EntityId, string SystemId, Vector3 Position, bool Engage, float Score); private sealed record SalvageOpportunity( WreckRuntime Wreck, float Score, string Summary); }