From 8503855a4c9353b66c71c3aec1a3aba2c33edbcc Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 9 Apr 2026 12:42:52 -0400 Subject: [PATCH] Refine ship orders and viewer controls --- SpaceGame.slnx | 4 + apps/backend/Definitions/WorldDefinitions.cs | 3 + .../Factions/AI/CommanderPlanningService.cs | 16 +- .../Simulation/PlayerFactionService.cs | 146 ++-- apps/backend/Program.cs | 2 +- apps/backend/Properties/AssemblyInfo.cs | 3 + .../Shared/Runtime/ShipAutomationCatalog.cs | 23 +- .../backend/Shared/Runtime/SimulationKinds.cs | 23 - .../backend/Shared/Runtime/SimulationUnits.cs | 16 + .../Ships/AI/ShipAiService.BehaviorQueue.cs | 67 +- .../Ships/AI/ShipAiService.Execution.cs | 38 +- .../backend/Ships/AI/ShipAiService.Helpers.cs | 46 +- .../AI/ShipAiService.Planning.Behaviors.cs | 380 +++------- .../Ships/AI/ShipAiService.Planning.Orders.cs | 292 ++----- apps/backend/Ships/AI/ShipAiService.cs | 280 +++---- .../Ships/Api/ReorderShipOrderHandler.cs | 31 + .../Ships/Api/UpdateShipOrderHandler.cs | 39 + apps/backend/Ships/Contracts/ShipCommands.cs | 22 + apps/backend/Ships/Contracts/Ships.cs | 27 - .../Ships/Runtime/ShipRuntimeModels.cs | 210 +++++- .../Core/SimulationProjectionService.cs | 72 +- .../Universe/Scenario/SpatialBuilder.cs | 54 +- .../backend/Universe/Scenario/WorldBuilder.cs | 6 +- .../Universe/Scenario/WorldTopologyBuilder.cs | 16 + .../Simulation/OrbitalStateUpdater.cs | 5 +- .../Universe/Simulation/WorldService.cs | 177 ++++- apps/viewer/src/ViewerAppController.ts | 90 ++- apps/viewer/src/api.ts | 9 + .../components/ViewerEntityBrowserPanel.vue | 4 +- .../components/ViewerEntityInspectorPanel.vue | 591 +++++++-------- apps/viewer/src/components/ViewerOpsStrip.vue | 184 ----- .../components/ViewerShipOrderContextMenu.vue | 19 +- apps/viewer/src/components/gm/GmOpsWindow.vue | 8 +- .../components/gm/GmPlayerFactionPanel.vue | 2 +- apps/viewer/src/contractsShips.ts | 27 - apps/viewer/src/shipCommands.ts | 20 + apps/viewer/src/styles/viewer.css | 226 ++---- .../src/ui/stores/viewerOrderContextMenu.ts | 7 +- apps/viewer/src/viewerCamera.ts | 13 +- apps/viewer/src/viewerConstants.ts | 7 +- apps/viewer/src/viewerControllerFactory.ts | 3 + apps/viewer/src/viewerControls.ts | 9 +- apps/viewer/src/viewerHudState.ts | 43 -- apps/viewer/src/viewerInteraction.ts | 8 +- .../viewer/src/viewerInteractionController.ts | 123 ++- apps/viewer/src/viewerLocalLayer.ts | 30 +- apps/viewer/src/viewerMath.ts | 15 +- apps/viewer/src/viewerNavigationController.ts | 2 +- apps/viewer/src/viewerOpsStrip.ts | 172 ----- apps/viewer/src/viewerPanels.ts | 5 +- .../src/viewerPresentationController.ts | 43 +- apps/viewer/src/viewerSceneAppearance.ts | 10 +- apps/viewer/src/viewerSceneDataController.ts | 6 +- apps/viewer/src/viewerSceneFactory.ts | 75 +- apps/viewer/src/viewerSceneSync.ts | 2 +- apps/viewer/src/viewerSelection.ts | 11 +- apps/viewer/src/viewerWorldLifecycle.ts | 10 - apps/viewer/src/viewerWorldPresentation.ts | 78 +- docs/VALIDATION-WORKSHEET.md | 26 + docs/VALIDATION.md | 713 ++++++++++++++++++ shared/data/scenarios/minimal.json | 66 ++ tests/backend/ShipAiServiceExecutionTests.cs | 153 ++++ tests/backend/ShipOrderQueueTests.cs | 140 ++++ tests/backend/SpaceGame.Api.Tests.csproj | 28 + 64 files changed, 2939 insertions(+), 2037 deletions(-) create mode 100644 apps/backend/Properties/AssemblyInfo.cs create mode 100644 apps/backend/Ships/Api/ReorderShipOrderHandler.cs create mode 100644 apps/backend/Ships/Api/UpdateShipOrderHandler.cs delete mode 100644 apps/viewer/src/components/ViewerOpsStrip.vue delete mode 100644 apps/viewer/src/viewerOpsStrip.ts create mode 100644 docs/VALIDATION-WORKSHEET.md create mode 100644 docs/VALIDATION.md create mode 100644 shared/data/scenarios/minimal.json create mode 100644 tests/backend/ShipAiServiceExecutionTests.cs create mode 100644 tests/backend/ShipOrderQueueTests.cs create mode 100644 tests/backend/SpaceGame.Api.Tests.csproj diff --git a/SpaceGame.slnx b/SpaceGame.slnx index bc0df43..faa558c 100644 --- a/SpaceGame.slnx +++ b/SpaceGame.slnx @@ -3,4 +3,8 @@ + + + + diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index ea9fdcf..7000a5d 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -559,6 +559,9 @@ public sealed class ShipCargoDefinition public sealed class ScenarioDefinition { public required WorldGenerationOptions WorldGeneration { get; set; } + // Temporary QA escape hatch so a scenario can pin an exact topology. + // Do not treat this as the long-term world authoring model. + public List? Systems { get; set; } public required List InitialStations { get; set; } public required List ShipFormations { get; set; } public required List PatrolRoutes { get; set; } diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 50c701b..b35dfac 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -2834,7 +2834,7 @@ internal sealed class CommanderPlanningService TargetEntityId = objective.TargetEntityId, TargetSystemId = targetSystemId, TargetPosition = targetPosition, - DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null, + DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null, ItemId = objective.ItemId, WaitSeconds = 0f, Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f), @@ -2874,13 +2874,13 @@ internal sealed class CommanderPlanningService private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder) { - var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0; + var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0; if (desiredOrder is null) { return changed; } - var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); + var existing = ship.OrderQueue.FindById(desiredOrder.Id); if (existing is not null) { if (ShipOrdersEqual(existing, desiredOrder)) @@ -2888,18 +2888,18 @@ internal sealed class CommanderPlanningService return changed; } - ship.OrderQueue.Remove(existing); - changed = true; + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); + return true; } if (ship.OrderQueue.Count >= MaxAiOrdersPerShip) { - changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0; + changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0; } - if (ship.OrderQueue.Count < 8) + if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders) { - ship.OrderQueue.Add(desiredOrder); + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); changed = true; } diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs index c80b971..879f47f 100644 --- a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -672,12 +672,7 @@ internal sealed class PlayerFactionService return null; } - if (ship.OrderQueue.Count >= 8) - { - throw new InvalidOperationException("Order queue is full."); - } - - ship.OrderQueue.Add(new ShipOrderRuntime + ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime { Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Kind = request.Kind, @@ -704,12 +699,7 @@ internal sealed class PlayerFactionService AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId); player.UpdatedAtUtc = DateTimeOffset.UtcNow; ship.ControlSourceKind = "player-order"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = request.Label ?? request.Kind; ship.NeedsReplan = true; ship.LastReplanReason = "player-order-enqueued"; @@ -731,28 +721,18 @@ internal sealed class PlayerFactionService return null; } - var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); - if (removed > 0) + var removed = ship.OrderQueue.RemoveById(orderId); + if (removed) { AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId); player.UpdatedAtUtc = DateTimeOffset.UtcNow; } - ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "player-order" : "player-manual"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); - ship.ControlReason = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Label ?? order.Kind) - .FirstOrDefault() + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? "manual-player-control"; ship.NeedsReplan = true; ship.LastReplanReason = "player-order-removed"; @@ -760,6 +740,93 @@ internal sealed class PlayerFactionService return ship; } + internal ShipRuntime? UpdateDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, ShipOrderUpdateCommandRequest request) + { + var player = EnsureInitializedDomain(world, playerStateStore, playerId); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var order = ship.OrderQueue.FindById(orderId); + if (order is null || order.SourceKind != ShipOrderSourceKind.Player) + { + return null; + } + + order.Priority = request.Priority; + order.InterruptCurrentPlan = request.InterruptCurrentPlan; + order.Label = request.Label; + order.TargetEntityId = request.TargetEntityId; + order.TargetSystemId = request.TargetSystemId; + order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + order.SourceStationId = request.SourceStationId; + order.DestinationStationId = request.DestinationStationId; + order.ItemId = request.ItemId; + order.AnchorId = request.AnchorId; + order.ConstructionSiteId = request.ConstructionSiteId; + order.ModuleId = request.ModuleId; + order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f); + order.Radius = MathF.Max(0f, request.Radius ?? 0f); + order.MaxSystemRange = request.MaxSystemRange; + order.KnownStationsOnly = request.KnownStationsOnly ?? false; + order.Status = OrderStatus.Queued; + order.FailureReason = null; + + AddDecision(player, "ship-order-updated", $"Updated order {orderId} on {ship.Definition.Name}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) + ? "player-order" + : "player-manual"; + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) + ?? request.Label + ?? request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-updated"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + internal ShipRuntime? ReorderDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, int targetIndex) + { + var player = EnsureInitializedDomain(world, playerStateStore, playerId); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex)) + { + return ship; + } + + AddDecision(player, "ship-order-reordered", $"Reordered order {orderId} on {ship.Definition.Name}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) + ? "player-order" + : "player-manual"; + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) + ?? "manual-player-control"; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-reordered"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request) { var player = EnsureInitializedDomain(world, playerStateStore, playerId); @@ -1321,25 +1388,15 @@ internal sealed class PlayerFactionService ? "player-directive" : automation is not null ? "player-automation" - : ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) + : ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "player-order" : "player-manual"; var desiredControlSourceId = directive?.Id ?? automation?.Id - ?? ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); + ?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; var desiredControlReason = directive?.Label ?? automation?.Label - ?? ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Label ?? order.Kind) - .FirstOrDefault() + ?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control"); var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment); @@ -1438,7 +1495,7 @@ internal sealed class PlayerFactionService private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) { var aiOrderId = directive is null ? null : $"player-order-{directive.Id}"; - var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0; + var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0; var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false; if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind)) @@ -1470,17 +1527,16 @@ internal sealed class PlayerFactionService KnownStationsOnly = directive.KnownStationsOnly, }; - var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId); + var existing = ship.OrderQueue.FindById(aiOrderId!); if (existing is null) { - ship.OrderQueue.Add(desiredOrder); + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); return true; } if (!ShipOrdersEqual(existing, desiredOrder)) { - ship.OrderQueue.Remove(existing); - ship.OrderQueue.Add(desiredOrder); + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); return true; } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index 8bbc951..00ec68c 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens; using Npgsql; using SpaceGame.Api.Universe.Bootstrap; -const string StartupScenarioPath = "scenarios/empty.json"; +const string StartupScenarioPath = "scenarios/minimal.json"; var builder = WebApplication.CreateBuilder(args); diff --git a/apps/backend/Properties/AssemblyInfo.cs b/apps/backend/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..593b5b9 --- /dev/null +++ b/apps/backend/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")] diff --git a/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs b/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs index 6d8986e..8bc6c98 100644 --- a/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs +++ b/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs @@ -34,8 +34,8 @@ public static class ShipBehaviorKinds public const string AdvancedAutoMine = "advanced-auto-mine"; public const string ExpertAutoMine = "expert-auto-mine"; - public const string DockAndWait = "dock-and-wait"; - public const string FlyAndWait = "fly-and-wait"; + public const string DockAtStation = "dock-at-station"; + public const string Move = "move"; public const string FlyToObject = "fly-to-object"; public const string FollowShip = "follow-ship"; public const string HoldPosition = "hold-position"; @@ -60,29 +60,29 @@ public static class ShipAutomationCatalog { public static readonly IReadOnlyList Behaviors = [ - new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the active patrol context."), + new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the active patrol context."), new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."), - new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the defended position context."), + new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the defended position context."), new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."), - new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait guard orders from the defended station context."), + new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move guard orders from the defended station context."), new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."), new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."), new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."), - new(ShipBehaviorKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), - new(ShipBehaviorKinds.FlyAndWait, "Fly And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), + new(ShipBehaviorKinds.Move, "Fly To Position", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."), new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."), new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."), - new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from the current market context."), + new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from the current market context."), new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."), new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."), new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."), - new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from known-station context."), + new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from known-station context."), new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."), new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."), @@ -94,12 +94,11 @@ public static class ShipAutomationCatalog public static readonly IReadOnlyList Orders = [ - new(ShipOrderKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), - new(ShipOrderKinds.FlyAndWait, "Fly To And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), + new(ShipOrderKinds.Move, "Fly To", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order completes on arrival."), new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."), - new(ShipOrderKinds.Move, "Move", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Low-level direct movement order; viewer may present richer labels such as Fly To And Wait instead."), new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."), diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index c2151ef..fe9ec40 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -46,16 +46,6 @@ public enum AiPlanStatus Interrupted, } -public enum AiPlanStepStatus -{ - Planned, - Running, - Blocked, - Completed, - Failed, - Interrupted, -} - public enum AiPlanSourceKind { Rule, @@ -165,8 +155,6 @@ public static class ShipOrderKinds { public const string Move = "move"; public const string DockAtStation = "dock-at-station"; - public const string DockAndWait = "dock-and-wait"; - public const string FlyAndWait = "fly-and-wait"; public const string FlyToObject = "fly-to-object"; public const string FollowShip = "follow-ship"; public const string TradeRoute = "trade-route"; @@ -324,17 +312,6 @@ public static class SimulationEnumMappings _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), }; - public static string ToContractValue(this AiPlanStepStatus status) => status switch - { - AiPlanStepStatus.Planned => "planned", - AiPlanStepStatus.Running => "running", - AiPlanStepStatus.Blocked => "blocked", - AiPlanStepStatus.Completed => "completed", - AiPlanStepStatus.Failed => "failed", - AiPlanStepStatus.Interrupted => "interrupted", - _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), - }; - public static string ToContractValue(this AiPlanSourceKind kind) => kind switch { AiPlanSourceKind.Rule => "rule", diff --git a/apps/backend/Shared/Runtime/SimulationUnits.cs b/apps/backend/Shared/Runtime/SimulationUnits.cs index c9ffb11..3fc0ae6 100644 --- a/apps/backend/Shared/Runtime/SimulationUnits.cs +++ b/apps/backend/Shared/Runtime/SimulationUnits.cs @@ -7,6 +7,22 @@ public static class SimulationUnits public static float AuToKilometers(float au) => au * KilometersPerAu; + public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer; + + public static float MetersToKilometers(float meters) => meters / MetersPerKilometer; + + public static Vector3 KilometersToMeters(Vector3 kilometers) => + new( + KilometersToMeters(kilometers.X), + KilometersToMeters(kilometers.Y), + KilometersToMeters(kilometers.Z)); + + public static Vector3 MetersToKilometers(Vector3 meters) => + new( + MetersToKilometers(meters.X), + MetersToKilometers(meters.Y), + MetersToKilometers(meters.Z)); + public static float AuPerSecondToKilometersPerSecond(float auPerSecond) => auPerSecond * KilometersPerAu; diff --git a/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs index 837364f..97955b5 100644 --- a/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs +++ b/apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs @@ -6,27 +6,12 @@ namespace SpaceGame.Api.Ships.AI; public sealed partial class ShipAiService { - private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => - ship.OrderQueue - .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) - .OrderByDescending(GetOrderSourcePriority) - .ThenByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .FirstOrDefault(); - - private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch - { - ShipOrderSourceKind.Player => 300, - ShipOrderSourceKind.Commander => 200, - ShipOrderSourceKind.Behavior => 100, - _ => 0, - }; - private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship) { var desiredOrder = BuildManagedBehaviorOrder(world, ship); - ship.OrderQueue.RemoveAll(order => + ship.OrderQueue.RemoveWhere(order => order.SourceKind == ShipOrderSourceKind.Behavior + && order.Id.StartsWith("behavior-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))); if (desiredOrder is null) @@ -34,10 +19,10 @@ public sealed partial class ShipAiService return; } - var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); + var existing = ship.OrderQueue.FindById(desiredOrder.Id); if (existing is null) { - ship.OrderQueue.Add(desiredOrder); + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); return; } @@ -46,8 +31,7 @@ public sealed partial class ShipAiService return; } - ship.OrderQueue.Remove(existing); - ship.OrderQueue.Add(desiredOrder); + ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder); } private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship) @@ -76,7 +60,7 @@ public sealed partial class ShipAiService }; } - if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal)) + if (string.Equals(behaviorKind, DockAtStation, StringComparison.Ordinal)) { var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); if (station is null) @@ -88,38 +72,36 @@ public sealed partial class ShipAiService ship.LastAccessFailureReason = null; return new ShipOrderRuntime { - Id = $"behavior-{ship.Id}-dock-and-wait", - Kind = ShipOrderKinds.DockAndWait, + Id = $"behavior-{ship.Id}-dock-at-station", + Kind = ShipOrderKinds.DockAtStation, SourceKind = ShipOrderSourceKind.Behavior, SourceId = behaviorKind, Priority = 0, InterruptCurrentPlan = false, - Label = $"Dock and wait at {station.Label}", + Label = $"Dock at {station.Label}", TargetEntityId = station.Id, TargetSystemId = station.SystemId, DestinationStationId = station.Id, - WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), Radius = ship.DefaultBehavior.Radius, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, }; } - if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal)) + if (string.Equals(behaviorKind, Move, StringComparison.Ordinal)) { ship.LastAccessFailureReason = null; return new ShipOrderRuntime { - Id = $"behavior-{ship.Id}-fly-and-wait", - Kind = ShipOrderKinds.FlyAndWait, + Id = $"behavior-{ship.Id}-move", + Kind = ShipOrderKinds.Move, SourceKind = ShipOrderSourceKind.Behavior, SourceId = behaviorKind, Priority = 0, InterruptCurrentPlan = false, - Label = "Fly and wait", + Label = "Fly to position", TargetSystemId = systemId, TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position, - WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), Radius = ship.DefaultBehavior.Radius, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, @@ -306,13 +288,12 @@ public sealed partial class ShipAiService } ship.LastAccessFailureReason = null; - return CreateManagedFlyAndWaitOrder( + return CreateManagedMoveOrder( ship, behaviorKind, "Protect position", targetSystemId, targetPosition, - MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius)); } @@ -365,13 +346,12 @@ public sealed partial class ShipAiService } ship.LastAccessFailureReason = null; - return CreateManagedFlyAndWaitOrder( + return CreateManagedMoveOrder( ship, behaviorKind, $"Guard {station.Label}", station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), - MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius)); } @@ -410,7 +390,7 @@ public sealed partial class ShipAiService && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) { ship.LastAccessFailureReason = null; - return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); + return CreateManagedDockAtStationOrder(ship, behaviorKind, visitStation, $"Revisit {visitStation.Label}"); } ship.LastAccessFailureReason = "no-trade-route"; @@ -641,7 +621,7 @@ public sealed partial class ShipAiService } ship.LastAccessFailureReason = null; - return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait"); + return CreateManagedMoveOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-move"); } private static ShipOrderRuntime CreateManagedAttackOrder( @@ -687,11 +667,11 @@ public sealed partial class ShipAiService KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, }; - private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) => + private static ShipOrderRuntime CreateManagedDockAtStationOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, string label) => new() { - Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait", - Kind = ShipOrderKinds.DockAndWait, + Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station", + Kind = ShipOrderKinds.DockAtStation, SourceKind = ShipOrderSourceKind.Behavior, SourceId = behaviorKind, Priority = 0, @@ -700,25 +680,23 @@ public sealed partial class ShipAiService TargetEntityId = station.Id, TargetSystemId = station.SystemId, DestinationStationId = station.Id, - WaitSeconds = waitSeconds, Radius = ship.DefaultBehavior.Radius, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, }; - private static ShipOrderRuntime CreateManagedFlyAndWaitOrder( + private static ShipOrderRuntime CreateManagedMoveOrder( ShipRuntime ship, string behaviorKind, string label, string targetSystemId, Vector3 targetPosition, - float waitSeconds, float radius, string? orderIdSuffix = null) => new() { Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}", - Kind = ShipOrderKinds.FlyAndWait, + Kind = ShipOrderKinds.Move, SourceKind = ShipOrderSourceKind.Behavior, SourceId = behaviorKind, Priority = 0, @@ -726,7 +704,6 @@ public sealed partial class ShipAiService Label = label, TargetSystemId = targetSystemId, TargetPosition = targetPosition, - WaitSeconds = waitSeconds, Radius = radius, MaxSystemRange = ship.DefaultBehavior.MaxSystemRange, KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly, diff --git a/apps/backend/Ships/AI/ShipAiService.Execution.cs b/apps/backend/Ships/AI/ShipAiService.Execution.cs index 9bca4e9..9452dd0 100644 --- a/apps/backend/Ships/AI/ShipAiService.Execution.cs +++ b/apps/backend/Ships/AI/ShipAiService.Execution.cs @@ -6,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI; public sealed partial class ShipAiService { - private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) + private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) { return subTask.Kind switch { @@ -636,12 +636,13 @@ public sealed partial class ShipAiService ship.SpatialState.Transit = null; ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id; subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); + var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position); ship.SpatialState.SystemPosition = currentAnchor is null - ? ship.Position + ? localSystemOffset : new Vector3( - currentAnchor.Position.X + ship.Position.X, - currentAnchor.Position.Y + ship.Position.Y, - currentAnchor.Position.Z + ship.Position.Z); + currentAnchor.Position.X + localSystemOffset.X, + currentAnchor.Position.Y + localSystemOffset.Y, + currentAnchor.Position.Z + localSystemOffset.Z); if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold)) { @@ -650,12 +651,13 @@ public sealed partial class ShipAiService ship.SystemId = targetSystemId; ship.SpatialState.CurrentSystemId = targetSystemId; ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id; + var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition); ship.SpatialState.SystemPosition = targetAnchor is null - ? targetPosition + ? arrivalSystemOffset : new Vector3( - targetAnchor.Position.X + targetPosition.X, - targetAnchor.Position.Y + targetPosition.Y, - targetAnchor.Position.Z + targetPosition.Z); + targetAnchor.Position.X + arrivalSystemOffset.X, + targetAnchor.Position.Y + arrivalSystemOffset.Y, + targetAnchor.Position.Z + arrivalSystemOffset.Z); ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } @@ -663,12 +665,13 @@ public sealed partial class ShipAiService ship.State = ShipState.LocalFlight; ship.SpatialState.CurrentAnchorId = currentAnchor?.Id; ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position); ship.SpatialState.SystemPosition = currentAnchor is null - ? ship.Position + ? movedSystemOffset : new Vector3( - currentAnchor.Position.X + ship.Position.X, - currentAnchor.Position.Y + ship.Position.Y, - currentAnchor.Position.Z + ship.Position.Z); + currentAnchor.Position.X + movedSystemOffset.X, + currentAnchor.Position.Y + movedSystemOffset.Y, + currentAnchor.Position.Z + movedSystemOffset.Z); return SubTaskOutcome.Active; } @@ -822,12 +825,13 @@ public sealed partial class ShipAiService ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight; ship.SpatialState.CurrentAnchorId = targetAnchor?.Id; ship.SpatialState.DestinationAnchorId = targetAnchor?.Id; + var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition); ship.SpatialState.SystemPosition = targetAnchor is null - ? targetPosition + ? arrivalSystemOffset : new Vector3( - targetAnchor.Position.X + targetPosition.X, - targetAnchor.Position.Y + targetPosition.Y, - targetAnchor.Position.Z + targetPosition.Z); + targetAnchor.Position.X + arrivalSystemOffset.X, + targetAnchor.Position.Y + arrivalSystemOffset.Y, + targetAnchor.Position.Z + arrivalSystemOffset.Z); ship.State = ShipState.Arriving; return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; } diff --git a/apps/backend/Ships/AI/ShipAiService.Helpers.cs b/apps/backend/Ships/AI/ShipAiService.Helpers.cs index 9759f46..485187f 100644 --- a/apps/backend/Ships/AI/ShipAiService.Helpers.cs +++ b/apps/backend/Ships/AI/ShipAiService.Helpers.cs @@ -185,13 +185,14 @@ public sealed partial class ShipAiService { if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor) { + var localOffset = SimulationUnits.MetersToKilometers(station.Position); return new Vector3( - anchor.Position.X + station.Position.X, - anchor.Position.Y + station.Position.Y, - anchor.Position.Z + station.Position.Z); + anchor.Position.X + localOffset.X, + anchor.Position.Y + localOffset.Y, + anchor.Position.Z + localOffset.Z); } - return station.Position; + return SimulationUnits.MetersToKilometers(station.Position); } private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node) @@ -216,17 +217,18 @@ public sealed partial class ShipAiService if (ResolveCurrentAnchor(world, ship) is { } anchor) { + var localOffset = SimulationUnits.MetersToKilometers(ship.Position); return new Vector3( - anchor.Position.X + ship.Position.X, - anchor.Position.Y + ship.Position.Y, - anchor.Position.Z + ship.Position.Z); + anchor.Position.X + localOffset.X, + anchor.Position.Y + localOffset.Y, + anchor.Position.Z + localOffset.Z); } - return ship.Position; + return SimulationUnits.MetersToKilometers(ship.Position); } private static float GetLocalTravelSpeed(ShipRuntime ship) => - SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); + ship.Definition.Speed * GetSkillFactor(ship.Skills.Navigation); private static float GetWarpTravelSpeed(ShipRuntime ship) => SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); @@ -997,9 +999,6 @@ public sealed partial class ShipAiService ? null : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; - 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) @@ -1032,41 +1031,40 @@ public sealed partial class ShipAiService 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}"; + var orderId = ship.ActiveOrderId ?? "none"; + var subTask = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex]; + var signature = $"{ship.State.ToContractValue()}|{orderId}|{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.#}"); + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} order={orderId} task={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) + private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousOrderId, string? previousTaskId, ICollection events) { - var currentPlanId = ship.ActivePlan?.Id; - var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; + var currentOrderId = ship.ActiveOrderId; + var currentTaskId = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id; var occurredAtUtc = DateTimeOffset.UtcNow; if (previousState != ship.State) { events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); } - if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) + if (!string.Equals(previousOrderId, currentOrderId, StringComparison.Ordinal)) { - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc)); + events.Add(new SimulationEventRecord("ship", ship.Id, "order-changed", $"{ship.Definition.Name} switched active order.", occurredAtUtc)); } - if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) + if (!string.Equals(previousTaskId, currentTaskId, StringComparison.Ordinal)) { - events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc)); + events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Name} advanced active task.", occurredAtUtc)); } } diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs index a46e257..a8da21f 100644 --- a/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs @@ -1,323 +1,179 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; -using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; namespace SpaceGame.Api.Ships.AI; public sealed partial class ShipAiService { - private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship) - { - var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); - var failureReason = ship.LastAccessFailureReason; - if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal)) - { - return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"); - } - - if (IsBehaviorBlockingFailure(behaviorKind, failureReason)) - { - return CreateBlockedPlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason), - failureReason!); - } - - return CreateIdlePlan( - ship, - AiPlanSourceKind.DefaultBehavior, - sourceId, - DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason)); - } - private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch { "missing-item" => true, "no-suitable-buyer" => true, - "no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true, + "no-mineable-node" when string.Equals(behaviorKind, ShipBehaviorKinds.LocalAutoMine, StringComparison.Ordinal) => true, _ => false, }; - private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason) + private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) { var assignment = ResolveAssignment(world, ship); - var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; - var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource"; - - return failureReason switch - { - "missing-item" => "No mining ware configured", - "no-suitable-buyer" => $"No buyer for {itemId} in {systemId}", - "no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}", - "no-mineable-node" => "No mineable node", - "no-home-station" => "No home station", - "no-trade-route" => "No trade route", - "no-fleet-to-supply" => "No fleet to supply", - "station-missing" => "No station to dock", - "target-ship-missing" => "No ship to follow", - "target-missing" => "No object target", - "no-salvage-target" => "No salvage target", - "no-repeat-orders" => "No repeat orders", - "no-construction-site" => "No construction site", - "support-station-missing" => "No support station", - _ => "Idle", - }; + return assignment is null + ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) + : (assignment.BehaviorKind, assignment.ObjectiveId); } - private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) + private IReadOnlyList BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route) { - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.TradeRoute, - summary, - [ - CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", + return [ - 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.GetTotalCargoCapacity(), 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-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.GetTotalCargoCapacity(), 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), + 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.GetTotalCargoCapacity(), 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 IReadOnlyList BuildFleetSupplySubTasks(FleetSupplyPlan plan) + { + return [ - 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.GetTotalCargoCapacity(), 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) - ]) - ]); + 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), + CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", 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 BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) + private IReadOnlyList BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation) { - return CreatePlan( - ship, - sourceKind, - sourceId, - SupplyFleet, - plan.Summary, - [ - CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", + var targetPosition = supportStation.Position; + return [ - 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.Name}", - [ - CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", 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), - ]) - ]); + 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, supportStation.Position, site.Id, 12f, 0f), + CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f), + ]; } - private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) + private static IReadOnlyList BuildAttackSubTasks(string targetEntityId, string? targetSystemId, 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}", + return [ - 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, supportStation.Position, site.Id, 12f, 0f) - ]), - CreateStep("step-construction-build", "build-site", $"Build {site.Id}", + CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f), + ]; + } + + private static IReadOnlyList BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) + { + return [ - CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f) - ]) - ]); + CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), + ]; } - private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) + private static IReadOnlyList BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) => + BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); + + private static IReadOnlyList BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) { - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.AttackTarget, - summary, - [ - CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary, + return [ - CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) - ]) - ]); + CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), + ]; } - private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) + private static IReadOnlyList BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order) { - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.DockAndWait, - summary, - [ - CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", + return [ - 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), - ]) - ]); + CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f), + ]; } - private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) + private IReadOnlyList BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation) { - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.FlyAndWait, - summary, - [ - CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary, + var deposit = SelectMiningDeposit(node, ship.Id); + var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); + return [ - 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), - ]) - ]); + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id), + 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.GetTotalCargoCapacity()), + CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f), + ]; } - private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) + private IReadOnlyList BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node) { - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.FlyToObject, - summary, - [ - CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary, + var deposit = SelectMiningDeposit(node, ship.Id); + var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); + return [ - 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)), - ]) - ]); + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id), + ]; } - private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) + private IReadOnlyList BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId) { - 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, - ShipOrderKinds.FollowShip, - summary, - [ - CreateStep("step-follow", "follow-target", summary, + var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId)); + return [ - CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), - ]) - ]); + CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f), + CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f), + CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId), + CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f), + ]; } - private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) + private IReadOnlyList BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach) { - return CreatePlan( - ship, - sourceKind, - sourceId, - Idle, - summary, - [ - CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary, + return [ - CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) - ]) - ]); + CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f), + CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId), + 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.GetTotalCargoCapacity(), itemId: wreck.ItemId), + CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), + ]; } - private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason) - { - var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f); - subTask.Status = WorkStatus.Blocked; - subTask.BlockingReason = blockingReason; - - var step = CreateStep("step-blocked", "blocked", summary, [subTask]); - step.Status = AiPlanStepStatus.Blocked; - step.BlockingReason = blockingReason; - - var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]); - plan.Status = AiPlanStatus.Blocked; - plan.FailureReason = blockingReason; - return plan; - } - - 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 + 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? targetAnchorId = null, + string? targetResourceNodeId = null, + string? targetResourceDepositId = null) => + new() { Id = id, Kind = kind, Summary = summary, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + TargetEntityId = targetEntityId, + TargetAnchorId = targetAnchorId, + TargetResourceNodeId = targetResourceNodeId, + TargetResourceDepositId = targetResourceDepositId, + ItemId = itemId, + ModuleId = moduleId, + Threshold = threshold, + Amount = amount, }; - 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? targetAnchorId = null, - string? targetResourceNodeId = null, - string? targetResourceDepositId = null) => - new() - { - Id = id, - Kind = kind, - Summary = summary, - TargetSystemId = targetSystemId, - TargetPosition = targetPosition, - TargetEntityId = targetEntityId, - TargetAnchorId = targetAnchorId, - TargetResourceNodeId = targetResourceNodeId, - TargetResourceDepositId = targetResourceDepositId, - ItemId = itemId, - ModuleId = moduleId, - Threshold = threshold, - Amount = amount, - }; } diff --git a/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs index 59c1b5b..87a58f8 100644 --- a/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs +++ b/apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs @@ -1,5 +1,4 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; -using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService; @@ -7,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI; public sealed partial class ShipAiService { - private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) + private ShipOrderRuntime? BuildEmergencyOrder(SimulationWorld world, ShipRuntime ship) { var policy = ResolvePolicy(world, ship.PolicySetId); if (policy is null) @@ -37,86 +36,75 @@ public sealed partial class ShipAiService .ThenBy(station => station.Position.DistanceTo(ship.Position)) .FirstOrDefault(); - var plan = new ShipPlanRuntime + return new ShipOrderRuntime { - Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", - SourceKind = AiPlanSourceKind.Rule, + Id = $"rule-{ship.Id}-flee", + Kind = ShipOrderKinds.Flee, + SourceKind = ShipOrderSourceKind.Behavior, SourceId = ShipOrderKinds.Flee, - Kind = "safety-flee", - Summary = "Emergency retreat", + Priority = 1000, + InterruptCurrentPlan = true, + Label = "Emergency retreat", + TargetEntityId = safeStation?.Id, + TargetSystemId = safeStation?.SystemId ?? ship.SystemId, + TargetPosition = safeStation?.Position ?? ship.Position, + DestinationStationId = safeStation?.Id, + Radius = safeStation is null ? 0f : MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), }; - - if (safeStation is null) - { - plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "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(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) + private IReadOnlyList? BuildOrderSubTasks(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.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order), - var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(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), + var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderSubTasks(world, order), + var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderSubTasks(world, order), + var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderSubTasks(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderSubTasks(order), + var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldSubTasks(ship, order), _ => null, }; } - private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) + private IReadOnlyList BuildFleeSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { - var assignment = ResolveAssignment(world, ship); - return assignment is null - ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) - : (assignment.BehaviorKind, assignment.ObjectiveId); + var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); + if (safeStation is null) + { + return + [ + CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f), + ]; + } + + return + [ + CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f), + CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f), + ]; } - private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) + private static IReadOnlyList BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order) { var targetSystemId = order.TargetSystemId ?? ship.SystemId; var targetPosition = order.TargetPosition ?? ship.Position; - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - ShipOrderKinds.Move, - order.Label ?? "Move order", - [ - CreateStep("step-move", "travel", order.Label ?? "Travel", + return [ - CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) - ]) - ]); + CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f), + ]; } - private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildDockOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); if (station is null) @@ -125,25 +113,14 @@ public sealed partial class ShipAiService 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}", + return [ - CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(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) - ]) - ]); + CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f), + 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) + private IReadOnlyList? BuildTradeOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) { @@ -158,10 +135,10 @@ public sealed partial class ShipAiService return null; } - return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); + return BuildTradeSubTasks(ship, route); } - private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildMineOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var systemId = order.TargetSystemId ?? ship.SystemId; var itemId = order.ItemId; @@ -198,10 +175,10 @@ public sealed partial class ShipAiService return null; } - return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}"); + return BuildLocalMiningSubTasks(ship, node); } - private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildMineLocalOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId); var node = ResolveNode(world, order.TargetEntityId) @@ -212,10 +189,10 @@ public sealed partial class ShipAiService return null; } - return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}"); + return BuildLocalMiningSubTasks(ship, node); } - private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildMineAndDeliverRunOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId); var node = ResolveNode(world, order.TargetEntityId) @@ -229,10 +206,10 @@ public sealed partial class ShipAiService return null; } - return BuildMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}"); + return BuildMiningSubTasks(ship, node, buyer); } - private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildSellMinedCargoOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId); if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId)) @@ -241,10 +218,10 @@ public sealed partial class ShipAiService return null; } - return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}"); + return BuildLocalMiningDeliverySubTasks(ship, buyer, order.ItemId); } - private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildAutoSalvageOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId); var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f); @@ -255,29 +232,10 @@ public sealed partial class ShipAiService } var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f)); - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - AutoSalvage, - order.Label ?? $"Salvage {wreck.ItemId}", - [ - CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}", - [ - CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f), - CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: 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.GetTotalCargoCapacity(), itemId: wreck.ItemId), - CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), - ]) - ]); + return BuildSalvageSubTasks(ship, wreck, homeStation, approach); } - private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildSupplyFleetOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var sourceStation = ResolveStation(world, order.SourceStationId); var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); @@ -303,10 +261,10 @@ public sealed partial class ShipAiService amount, MathF.Max(16f, order.Radius), order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}"); - return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan); + return BuildFleetSupplySubTasks(plan); } - private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildBuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) { var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); if (site is null) @@ -322,10 +280,10 @@ public sealed partial class ShipAiService return null; } - return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); + return BuildConstructionSubTasks(site, supportStation); } - private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildAttackOrderSubTasks(ShipOrderRuntime order) { var targetId = order.TargetEntityId; if (targetId is null) @@ -334,45 +292,10 @@ public sealed partial class ShipAiService return null; } - return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); + return BuildAttackSubTasks(targetId, order.TargetSystemId, order.Label ?? "Attack target"); } - private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) - { - return CreatePlan( - ship, - AiPlanSourceKind.Order, - order.Id, - ShipOrderKinds.HoldPosition, - order.Label ?? "Hold position", - [ - CreateStep("step-hold", ShipOrderKinds.HoldPosition, 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) + private IReadOnlyList? BuildFlyToObjectOrderSubTasks(SimulationWorld world, ShipOrderRuntime order) { var targetEntityId = order.TargetEntityId; if (targetEntityId is null) @@ -388,10 +311,10 @@ public sealed partial class ShipAiService return null; } - return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); + return BuildFlyToObjectSubTasks(objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); } - private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + private IReadOnlyList? BuildFollowShipOrderSubTasks(SimulationWorld world, ShipOrderRuntime order) { var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); if (targetShip is null) @@ -400,71 +323,6 @@ public sealed partial class ShipAiService 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.Name}"); - } - - private ShipPlanRuntime BuildMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) - { - var deposit = SelectMiningDeposit(node, ship.Id); - var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.MineAndDeliver, - summary, - [ - CreateStep("step-mine", "mine", $"Mine {node.ItemId}", - [ - CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), - CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id) - ]), - 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.GetTotalCargoCapacity()), - CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) - ]) - ]); - } - - private ShipPlanRuntime BuildLocalMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary) - { - var deposit = SelectMiningDeposit(node, ship.Id); - var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f); - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.MineLocal, - summary, - [ - CreateStep("step-mine", "mine", $"Mine {node.ItemId}", - [ - CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId), - CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id) - ]) - ]); - } - - private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary) - { - var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId)); - return CreatePlan( - ship, - sourceKind, - sourceId, - ShipOrderKinds.SellMinedCargo, - summary, - [ - CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}", - [ - CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f), - CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f), - CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId), - CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f) - ]) - ]); + return BuildFollowShipSubTasks(targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}"); } } diff --git a/apps/backend/Ships/AI/ShipAiService.cs b/apps/backend/Ships/AI/ShipAiService.cs index 4df4d3e..71c2dfa 100644 --- a/apps/backend/Ships/AI/ShipAiService.cs +++ b/apps/backend/Ships/AI/ShipAiService.cs @@ -26,191 +26,195 @@ public sealed partial class ShipAiService } 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 previousOrderId = ship.ActiveOrderId; + var previousTaskId = GetCurrentSubTask(ship)?.Id; + SyncEmergencyOrders(world, ship); SyncBehaviorOrders(world, ship); - 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!) - : BuildBehaviorFallbackPlan(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); + EnsureOrderExecution(world, ship, events); + ExecuteOrder(world, ship, deltaSeconds, events); + TrackHistory(ship); + EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events); } - private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection events) { - var plan = ship.ActivePlan; - if (plan is null) + var currentOrder = ship.OrderQueue.GetCurrentOrder(); + if (currentOrder is null) { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; + ClearActiveOrder(ship); + ApplyIdleOrBlockedState(world, ship); return; } - if (plan.CurrentStepIndex >= plan.Steps.Count) + if (currentOrder.Status == OrderStatus.Queued) + { + currentOrder.Status = OrderStatus.Active; + } + + if (!ship.NeedsReplan + && string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal) + && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count) { - CompletePlan(ship, plan, events); return; } - plan.UpdatedAtUtc = DateTimeOffset.UtcNow; - - var step = plan.Steps[plan.CurrentStepIndex]; - if (step.Status == AiPlanStepStatus.Planned) + if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)) { - step.Status = AiPlanStepStatus.Running; - } - - if (step.CurrentSubTaskIndex >= step.SubTasks.Count) - { - CompleteStep(plan, step); return; } - var subTask = step.SubTasks[step.CurrentSubTaskIndex]; + var subTasks = BuildOrderSubTasks(world, ship, currentOrder); + if (subTasks is null || subTasks.Count == 0) + { + FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable"); + ClearActiveOrder(ship); + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.1f; + ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable"; + events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow)); + ApplyIdleOrBlockedState(world, ship); + return; + } + + BeginOrderExecution(ship, currentOrder, subTasks); + events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow)); + } + + private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + { + var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId); + if (order is null) + { + ClearActiveOrder(ship); + ApplyIdleOrBlockedState(world, ship); + return; + } + + if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count) + { + CompleteOrderExecution(ship, order, events); + return; + } + + var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex]; if (subTask.Status == WorkStatus.Pending) { subTask.Status = WorkStatus.Active; } else if (subTask.Status == WorkStatus.Blocked) { - step.Status = AiPlanStepStatus.Blocked; - step.BlockingReason = subTask.BlockingReason; - plan.Status = AiPlanStatus.Blocked; ship.State = ShipState.Blocked; ship.TargetPosition = subTask.TargetPosition ?? ship.Position; return; } - plan.Status = AiPlanStatus.Running; - - var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); + var outcome = UpdateSubTask(world, ship, 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) + ship.ActiveSubTaskIndex += 1; + if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count) { - CompleteStep(plan, step); + CompleteOrderExecution(ship, order, events); } 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; + FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events); return; } } - private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) + private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList subTasks) { - 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); - if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior - && string.Equals(completedOrder.SourceId, RepeatOrders, 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.Name} 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.ActiveOrderId = order.Id; + ship.ActiveSubTaskIndex = 0; + ship.ActiveSubTasks.Clear(); + ship.ActiveSubTasks.AddRange(subTasks); ship.NeedsReplan = false; ship.ReplanCooldownSeconds = 0f; - ship.LastReplanReason = reason; - events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); + ship.LastReplanReason = "order-execution-started"; + ship.LastDeltaSignature = string.Empty; + } + + private static void ClearActiveOrder(ShipRuntime ship) + { + ship.ActiveOrderId = null; + ship.ActiveSubTaskIndex = 0; + ship.ActiveSubTasks.Clear(); + } + + private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection events) + { + ship.OrderQueue.TryCompleteOrder(order.Id); + if (order.SourceKind == ShipOrderSourceKind.Behavior + && string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal) + && ship.DefaultBehavior.RepeatOrders.Count > 0) + { + ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; + } + + ClearActiveOrder(ship); + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.25f; + ship.LastReplanReason = "order-completed"; + ship.LastDeltaSignature = string.Empty; + events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow)); + } + + private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection events) + { + FailOrder(ship, order, failureReason); + ClearActiveOrder(ship); + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.5f; + ship.LastReplanReason = failureReason; + ship.LastDeltaSignature = string.Empty; + events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow)); + } + + private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason) + { + ship.OrderQueue.TryFailOrder(order.Id, failureReason); + ship.LastDeltaSignature = string.Empty; + } + + private static ShipSubTaskRuntime? GetCurrentSubTask(ShipRuntime ship) => + ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex]; + + private void ApplyIdleOrBlockedState(SimulationWorld world, ShipRuntime ship) + { + var (behaviorKind, _) = ResolveBehaviorSource(world, ship); + if (IsBehaviorBlockingFailure(behaviorKind, ship.LastAccessFailureReason)) + { + ship.State = ShipState.Blocked; + ship.TargetPosition = ship.Position; + return; + } + + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + } + + private void SyncEmergencyOrders(SimulationWorld world, ShipRuntime ship) + { + var desiredOrder = BuildEmergencyOrder(world, ship); + ship.OrderQueue.RemoveWhere(order => + order.SourceKind == ShipOrderSourceKind.Behavior + && string.Equals(order.SourceId, ShipOrderKinds.Flee, StringComparison.Ordinal) + && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))); + + if (desiredOrder is null) + { + return; + } + + ship.OrderQueue.AddOrReplaceManagedOrderAtFront(desiredOrder); } } diff --git a/apps/backend/Ships/Api/ReorderShipOrderHandler.cs b/apps/backend/Ships/Api/ReorderShipOrderHandler.cs new file mode 100644 index 0000000..ad01eac --- /dev/null +++ b/apps/backend/Ships/Api/ReorderShipOrderHandler.cs @@ -0,0 +1,31 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class ReorderShipOrderHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/ships/{shipId}/orders/{orderId}/position"); + } + + public override async Task HandleAsync(ShipOrderReorderRequest request, CancellationToken cancellationToken) + { + var shipId = Route("shipId"); + var orderId = Route("orderId"); + if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + var snapshot = worldService.ReorderShipOrder(shipId, orderId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/Ships/Api/UpdateShipOrderHandler.cs b/apps/backend/Ships/Api/UpdateShipOrderHandler.cs new file mode 100644 index 0000000..ebef269 --- /dev/null +++ b/apps/backend/Ships/Api/UpdateShipOrderHandler.cs @@ -0,0 +1,39 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class UpdateShipOrderHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/ships/{shipId}/orders/{orderId}"); + } + + public override async Task HandleAsync(ShipOrderUpdateCommandRequest request, CancellationToken cancellationToken) + { + var shipId = Route("shipId"); + var orderId = Route("orderId"); + if (string.IsNullOrWhiteSpace(shipId) || string.IsNullOrWhiteSpace(orderId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + try + { + var snapshot = worldService.UpdateShipOrder(shipId, orderId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Ships/Contracts/ShipCommands.cs b/apps/backend/Ships/Contracts/ShipCommands.cs index b8635ad..c0e73e4 100644 --- a/apps/backend/Ships/Contracts/ShipCommands.cs +++ b/apps/backend/Ships/Contracts/ShipCommands.cs @@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest( int? MaxSystemRange, bool? KnownStationsOnly); +public sealed record ShipOrderUpdateCommandRequest( + string Kind, + int Priority, + bool InterruptCurrentPlan, + string? Label, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? AnchorId, + string? ConstructionSiteId, + string? ModuleId, + float? WaitSeconds, + float? Radius, + int? MaxSystemRange, + bool? KnownStationsOnly); + +public sealed record ShipOrderReorderRequest( + int TargetIndex); + public sealed record ShipOrderTemplateCommandRequest( string Kind, string? Label, diff --git a/apps/backend/Ships/Contracts/Ships.cs b/apps/backend/Ships/Contracts/Ships.cs index 6ff7e75..6e15f37 100644 --- a/apps/backend/Ships/Contracts/Ships.cs +++ b/apps/backend/Ships/Contracts/Ships.cs @@ -108,29 +108,6 @@ public sealed record ShipSubTaskSnapshot( float TotalSeconds, string? BlockingReason); -public sealed record ShipPlanStepSnapshot( - string Id, - string Kind, - string Status, - string Summary, - string? BlockingReason, - int CurrentSubTaskIndex, - IReadOnlyList SubTasks); - -public sealed record ShipPlanSnapshot( - string Id, - string SourceKind, - string SourceId, - string Kind, - string Status, - string Summary, - int CurrentStepIndex, - DateTimeOffset CreatedAtUtc, - DateTimeOffset UpdatedAtUtc, - string? InterruptReason, - string? FailureReason, - IReadOnlyList Steps); - public sealed record ShipSnapshot( string Id, string Name, @@ -146,8 +123,6 @@ public sealed record ShipSnapshot( DefaultBehaviorSnapshot DefaultBehavior, ShipAssignmentSnapshot? Assignment, ShipSkillProfileSnapshot Skills, - ShipPlanSnapshot? ActivePlan, - string? CurrentStepId, IReadOnlyList ActiveSubTasks, string ControlSourceKind, string? ControlSourceId, @@ -182,8 +157,6 @@ public sealed record ShipDelta( DefaultBehaviorSnapshot DefaultBehavior, ShipAssignmentSnapshot? Assignment, ShipSkillProfileSnapshot Skills, - ShipPlanSnapshot? ActivePlan, - string? CurrentStepId, IReadOnlyList ActiveSubTasks, string ControlSourceKind, string? ControlSourceId, diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index f097a06..aa4e882 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -12,8 +12,7 @@ public sealed class ShipRuntime public Vector3 Velocity { get; set; } = Vector3.Zero; public ShipState State { get; set; } = ShipState.Idle; public required DefaultBehaviorRuntime DefaultBehavior { get; set; } - public List OrderQueue { get; } = []; - public ShipPlanRuntime? ActivePlan { get; set; } + public ShipOrderQueue OrderQueue { get; } = new(); public required ShipSkillProfileRuntime Skills { get; set; } public bool NeedsReplan { get; set; } = true; public float ReplanCooldownSeconds { get; set; } @@ -30,10 +29,190 @@ public sealed class ShipRuntime public float Health { get; set; } public HashSet KnownStationIds { get; } = new(StringComparer.Ordinal); public List History { get; } = []; + public string? ActiveOrderId { get; set; } + public int ActiveSubTaskIndex { get; set; } + public List ActiveSubTasks { get; } = []; public string LastSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class ShipOrderQueue : IReadOnlyList +{ + public const int MaxOrders = 8; + + private readonly List _orders = []; + + public int Count => _orders.Count; + + public ShipOrderRuntime this[int index] => _orders[index]; + + public IEnumerator GetEnumerator() => _orders.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Enqueue(ShipOrderRuntime order) + { + if (_orders.Count >= MaxOrders) + { + throw new InvalidOperationException("Order queue is full."); + } + + _orders.Add(order); + } + + public void EnqueuePlayerOrder(ShipOrderRuntime order) + { + if (order.SourceKind != ShipOrderSourceKind.Player) + { + throw new InvalidOperationException("Player segment only accepts player orders."); + } + + EnsureCapacityForNewOrder(order.Id); + _orders.Insert(GetManagedInsertionIndex(), order); + } + + public void EnqueueManagedOrder(ShipOrderRuntime order) + { + EnsureCapacityForNewOrder(order.Id); + _orders.Add(order); + } + + public void AddOrReplaceManagedOrder(ShipOrderRuntime order) + => AddOrReplaceManagedOrder(order, insertAtFront: false); + + public void AddOrReplaceManagedOrderAtFront(ShipOrderRuntime order) + => AddOrReplaceManagedOrder(order, insertAtFront: true); + + private void AddOrReplaceManagedOrder(ShipOrderRuntime order, bool insertAtFront) + { + var existingIndex = _orders.FindIndex(candidate => string.Equals(candidate.Id, order.Id, StringComparison.Ordinal)); + if (existingIndex >= 0) + { + _orders[existingIndex] = order; + return; + } + + EnsureCapacityForNewOrder(order.Id); + if (insertAtFront) + { + _orders.Insert(GetManagedInsertionIndex(), order); + return; + } + + _orders.Add(order); + } + + public bool Remove(ShipOrderRuntime order) => RemoveById(order.Id); + + public bool RemoveById(string orderId) => _orders.RemoveAll(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)) > 0; + + public int RemoveWhere(Predicate predicate) => _orders.RemoveAll(predicate); + + public ShipOrderRuntime? FindById(string orderId) => _orders.FirstOrDefault(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)); + + public ShipOrderRuntime? FindLeadingOrderForSource(ShipOrderSourceKind sourceKind) => + _orders.FirstOrDefault(order => order.SourceKind == sourceKind); + + public string? GetLeadingOrderLabelForSource(ShipOrderSourceKind sourceKind) => + FindLeadingOrderForSource(sourceKind) is { } order + ? order.Label ?? order.Kind + : null; + + public bool HasOrdersFromSource(ShipOrderSourceKind sourceKind) => _orders.Any(order => order.SourceKind == sourceKind); + + public ShipOrderRuntime? GetCurrentOrder() => + _orders.FirstOrDefault(order => order.Status is OrderStatus.Queued or OrderStatus.Active); + + public bool TryMovePlayerOrder(string orderId, int targetIndex) + { + var currentIndex = _orders.FindIndex(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)); + if (currentIndex < 0) + { + return false; + } + + var order = _orders[currentIndex]; + if (order.SourceKind != ShipOrderSourceKind.Player) + { + return false; + } + + var playerOrderIds = _orders + .Select((candidate, index) => (candidate, index)) + .Where(entry => entry.candidate.SourceKind == ShipOrderSourceKind.Player) + .Select(entry => entry.index) + .ToList(); + if (playerOrderIds.Count <= 1) + { + return true; + } + + var clampedPlayerIndex = Math.Clamp(targetIndex, 0, playerOrderIds.Count - 1); + var destinationIndex = playerOrderIds[clampedPlayerIndex]; + if (currentIndex == destinationIndex) + { + return true; + } + + _orders.RemoveAt(currentIndex); + if (currentIndex < destinationIndex) + { + destinationIndex -= 1; + } + + _orders.Insert(destinationIndex, order); + return true; + } + + public bool TryCompleteOrder(string orderId) => TryTransitionOrder(orderId, OrderStatus.Completed); + + public bool TryFailOrder(string orderId, string? failureReason = null) + { + var order = FindById(orderId); + if (order is null) + { + return false; + } + + order.FailureReason = failureReason ?? order.FailureReason; + if (order.SourceKind == ShipOrderSourceKind.Player) + { + order.Status = OrderStatus.Failed; + return true; + } + + return TryTransitionOrder(orderId, OrderStatus.Failed); + } + + public bool TryTransitionOrder(string orderId, OrderStatus terminalStatus) + { + var order = FindById(orderId); + if (order is null) + { + return false; + } + + order.Status = terminalStatus; + return RemoveById(orderId); + } + + private int GetManagedInsertionIndex() => + _orders.TakeWhile(order => order.SourceKind == ShipOrderSourceKind.Player).Count(); + + private void EnsureCapacityForNewOrder(string orderId) + { + if (FindById(orderId) is not null) + { + return; + } + + if (_orders.Count >= MaxOrders) + { + throw new InvalidOperationException("Order queue is full."); + } + } +} + public sealed class ShipSkillProfileRuntime { public int Navigation { get; set; } @@ -111,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime public bool KnownStationsOnly { get; set; } } -public sealed class ShipPlanRuntime -{ - public required string Id { get; init; } - public required AiPlanSourceKind SourceKind { get; init; } - public required string SourceId { get; init; } - public required string Kind { get; init; } - public required string Summary { get; set; } - public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned; - public int CurrentStepIndex { get; set; } - public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - public string? InterruptReason { get; set; } - public string? FailureReason { get; set; } - public List Steps { get; } = []; -} - -public sealed class ShipPlanStepRuntime -{ - public required string Id { get; init; } - public required string Kind { get; init; } - public required string Summary { get; set; } - public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned; - public int CurrentSubTaskIndex { get; set; } - public string? BlockingReason { get; set; } - public List SubTasks { get; } = []; -} - public sealed class ShipSubTaskRuntime { public required string Id { get; init; } diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index 6d6def3..5384104 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -201,8 +201,6 @@ internal sealed class SimulationProjectionService ship.DefaultBehavior, ship.Assignment, ship.Skills, - ship.ActivePlan, - ship.CurrentStepId, ship.ActiveSubTasks, ship.ControlSourceKind, ship.ControlSourceId, @@ -569,9 +567,6 @@ internal sealed class SimulationProjectionService ship.TargetPosition.Z.ToString("0.###"), ship.State.ToContractValue(), string.Join(",", ship.OrderQueue - .OrderByDescending(GetOrderSourcePriority) - .ThenByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) .Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), ship.DefaultBehavior.Kind, ship.DefaultBehavior.TargetEntityId ?? "none", @@ -595,9 +590,6 @@ internal sealed class SimulationProjectionService ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment ? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}" : "no-assignment", - ship.ActivePlan?.Kind ?? "none", - ship.ActivePlan?.Status.ToContractValue() ?? "none", - ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1", string.Join(",", ToActiveSubTaskSnapshots(ship).Select(subTask => $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), @@ -620,7 +612,9 @@ internal sealed class SimulationProjectionService ship.Skills.Combat.ToString(CultureInfo.InvariantCulture), ship.Skills.Construction.ToString(CultureInfo.InvariantCulture), ship.Health.ToString("0.###"), - GetCurrentShipStep(ship)?.Id ?? "none"); + ship.ActiveSubTaskIndex >= 0 && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count + ? ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id + : "none"); private static string BuildInventorySignature(IReadOnlyDictionary inventory) => string.Join(",", @@ -889,8 +883,6 @@ internal sealed class SimulationProjectionService ToDefaultBehaviorSnapshot(ship.DefaultBehavior), ToShipAssignmentSnapshot(commander), new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction), - ToShipPlanSnapshot(ship.ActivePlan), - GetCurrentShipStep(ship)?.Id, ToActiveSubTaskSnapshots(ship), ship.ControlSourceKind, ship.ControlSourceId, @@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService { MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"), MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"), - _ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"), + _ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())), "m/s"), }; } @@ -936,9 +928,6 @@ internal sealed class SimulationProjectionService private static IReadOnlyList ToShipOrderSnapshots(ShipRuntime ship) => ship.OrderQueue - .OrderByDescending(GetOrderSourcePriority) - .ThenByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) .Select(order => new ShipOrderSnapshot( order.Id, order.Kind, @@ -965,14 +954,6 @@ internal sealed class SimulationProjectionService order.FailureReason)) .ToList(); - private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch - { - ShipOrderSourceKind.Player => 300, - ShipOrderSourceKind.Commander => 200, - ShipOrderSourceKind.Behavior => 100, - _ => 0, - }; - private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) => new( behavior.Kind, @@ -1039,38 +1020,6 @@ internal sealed class SimulationProjectionService assignment.UpdatedAtUtc); } - private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan) - { - if (plan is null) - { - return null; - } - - return new ShipPlanSnapshot( - plan.Id, - plan.SourceKind.ToContractValue(), - plan.SourceId, - plan.Kind, - plan.Status.ToContractValue(), - plan.Summary, - plan.CurrentStepIndex, - plan.CreatedAtUtc, - plan.UpdatedAtUtc, - plan.InterruptReason, - plan.FailureReason, - plan.Steps.Select(ToShipPlanStepSnapshot).ToList()); - } - - private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) => - new( - step.Id, - step.Kind, - step.Status.ToContractValue(), - step.Summary, - step.BlockingReason, - step.CurrentSubTaskIndex, - step.SubTasks.Select(ToShipSubTaskSnapshot).ToList()); - private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) => new( subTask.Id, @@ -1094,23 +1043,12 @@ internal sealed class SimulationProjectionService private static IReadOnlyList ToActiveSubTaskSnapshots(ShipRuntime ship) { - var step = GetCurrentShipStep(ship); - if (step is null) - { - return []; - } - - return step.SubTasks + return ship.ActiveSubTasks .Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked) .Select(ToShipSubTaskSnapshot) .ToList(); } - private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) => - ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count - ? null - : ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex]; - private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander) { var assignment = commander.Assignment; diff --git a/apps/backend/Universe/Scenario/SpatialBuilder.cs b/apps/backend/Universe/Scenario/SpatialBuilder.cs index f9589cb..ac488e1 100644 --- a/apps/backend/Universe/Scenario/SpatialBuilder.cs +++ b/apps/backend/Universe/Scenario/SpatialBuilder.cs @@ -314,24 +314,28 @@ public sealed class SpatialBuilder ResourceNodeDefinition definition, float oreAmount) { - var depositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 12); + var derivedDepositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 18); + var depositCount = Math.Clamp(definition.ShardCount > 0 ? definition.ShardCount : derivedDepositCount, 4, 48); var deposits = new List(depositCount); var weightTotal = 0f; var weights = new float[depositCount]; + var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits")); for (var index = 0; index < depositCount; index += 1) { - var weight = 0.8f + (Hash01(systemId, nodeId, $"weight-{index}") * 1.6f); + var weight = 0.8f + (NextFloat01(random) * 1.6f); weights[index] = weight; weightTotal += weight; } - var scatterRadius = MathF.Max(140f, LocalSpaceRadius * 0.58f); + // Resource node localspace should read as a compact mineable field around the node core, + // not as sparse debris spread across the entire anchor volume. + var scatterRadius = MathF.Max(120f, MathF.Min(LocalSpaceRadius * 0.2f, 900f)); for (var index = 0; index < depositCount; index += 1) { - var angle = Hash01(systemId, nodeId, $"angle-{index}") * MathF.PI * 2f; - var radiusFactor = 0.22f + (Hash01(systemId, nodeId, $"radius-{index}") * 0.74f); + var angle = NextFloat01(random) * MathF.PI * 2f; + var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f); var radius = scatterRadius * MathF.Sqrt(radiusFactor); - var vertical = (Hash01(systemId, nodeId, $"vertical-{index}") - 0.5f) * MathF.Max(60f, scatterRadius * 0.14f); + var vertical = (NextFloat01(random) - 0.5f) * MathF.Max(40f, scatterRadius * 0.18f); var localPosition = new Vector3( MathF.Cos(angle) * radius, vertical, @@ -351,6 +355,32 @@ public sealed class SpatialBuilder return deposits; } + private static int ComputeDeterministicSeed(string systemId, string nodeId, string salt) + { + unchecked + { + var hash = 17; + foreach (var character in systemId) + { + hash = (hash * 31) + character; + } + + foreach (var character in nodeId) + { + hash = (hash * 31) + character; + } + + foreach (var character in salt) + { + hash = (hash * 31) + character; + } + + return hash; + } + } + + private static float NextFloat01(Random random) => (float)random.NextDouble(); + private static float Hash01(string systemId, string nodeId, string salt) { unchecked @@ -391,13 +421,15 @@ public sealed class SpatialBuilder internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection anchors) { + var systemPosition = SimulationUnits.MetersToKilometers(position); var nearestAnchor = anchors .Where(anchor => anchor.SystemId == systemId) - .OrderBy(anchor => anchor.Position.DistanceTo(position)) + .OrderBy(anchor => anchor.Position.DistanceTo(systemPosition)) .FirstOrDefault(); - var localPosition = nearestAnchor is null - ? position - : position.Subtract(nearestAnchor.Position); + var localPosition = position; + var resolvedSystemPosition = nearestAnchor is null + ? systemPosition + : Add(nearestAnchor.Position, SimulationUnits.MetersToKilometers(localPosition)); return new ShipSpatialStateRuntime { @@ -405,7 +437,7 @@ public sealed class SpatialBuilder SpaceLayer = SpaceLayerKind.LocalSpace, CurrentAnchorId = nearestAnchor?.Id, LocalPosition = localPosition, - SystemPosition = position, + SystemPosition = resolvedSystemPosition, MovementRegime = MovementRegimeKind.LocalFlight, }; } diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 9d58bc2..08a0687 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -16,7 +16,11 @@ public sealed class WorldBuilder( WorldGenerationOptions worldGenerationOptions, ScenarioDefinition? scenarioDefinition) { - var topology = topologyBuilder.Build(worldGenerationOptions); + // Temporary QA override: allow a scenario to provide an exact system list + // instead of going through procedural topology generation. + var topology = scenarioDefinition?.Systems is { Count: > 0 } scenarioSystems + ? topologyBuilder.Build(scenarioSystems) + : topologyBuilder.Build(worldGenerationOptions); var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems); scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal)); diff --git a/apps/backend/Universe/Scenario/WorldTopologyBuilder.cs b/apps/backend/Universe/Scenario/WorldTopologyBuilder.cs index 662bebb..256286b 100644 --- a/apps/backend/Universe/Scenario/WorldTopologyBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldTopologyBuilder.cs @@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder( generationService.PrepareKnownSystems(staticData.KnownSystems), worldGenerationOptions); + return BuildFromDefinitions(systems); + } + + public WorldBuildTopology Build(IReadOnlyList systems) + { + if (systems.Count == 0) + { + throw new InvalidOperationException("Scenario-defined systems cannot be empty."); + } + + // Temporary QA-only path for fixed-topology scenarios such as "minimal". + return BuildFromDefinitions(systems); + } + + private WorldBuildTopology BuildFromDefinitions(IReadOnlyList systems) + { var systemRuntimes = systems .Select(definition => new SystemRuntime { diff --git a/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs b/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs index e0eb0b8..a47a2d4 100644 --- a/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs +++ b/apps/backend/Universe/Simulation/OrbitalStateUpdater.cs @@ -308,9 +308,10 @@ internal sealed class OrbitalStateUpdater } ship.SpatialState.CurrentAnchorId = currentAnchor?.Id; + var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position); ship.SpatialState.SystemPosition = currentAnchor is null - ? ship.Position - : Add(currentAnchor.Position, ship.Position); + ? localSystemOffset + : Add(currentAnchor.Position, localSystemOffset); if (ship.DockedStationId is null) { diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 96985ee..e08b7d9 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -129,6 +129,39 @@ public sealed class WorldService } } + public ShipSnapshot? UpdateShipOrder(string shipId, string orderId, ShipOrderUpdateCommandRequest request) + { + lock (_sync) + { + ValidateShipOrderRequestUnsafe(shipId, ToCommandRequest(request)); + var ship = CanCurrentActorAccessGm() + ? UpdateGmShipOrderUnsafe(shipId, orderId, request) + : _playerFaction.UpdateDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public ShipSnapshot? ReorderShipOrder(string shipId, string orderId, ShipOrderReorderRequest request) + { + lock (_sync) + { + var ship = CanCurrentActorAccessGm() + ? ReorderGmShipOrderUnsafe(shipId, orderId, request.TargetIndex) + : _playerFaction.ReorderDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request.TargetIndex); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request) { lock (_sync) @@ -694,6 +727,30 @@ public sealed class WorldService } } + private static void ApplyShipOrderRequest(ShipOrderRuntime order, ShipOrderUpdateCommandRequest request) + { + order.Priority = request.Priority; + order.InterruptCurrentPlan = request.InterruptCurrentPlan; + order.Label = request.Label; + order.TargetEntityId = request.TargetEntityId; + order.TargetSystemId = request.TargetSystemId; + order.TargetPosition = request.TargetPosition is null + ? null + : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + order.SourceStationId = request.SourceStationId; + order.DestinationStationId = request.DestinationStationId; + order.ItemId = request.ItemId; + order.AnchorId = request.AnchorId; + order.ConstructionSiteId = request.ConstructionSiteId; + order.ModuleId = request.ModuleId; + order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f); + order.Radius = MathF.Max(0f, request.Radius ?? 0f); + order.MaxSystemRange = request.MaxSystemRange; + order.KnownStationsOnly = request.KnownStationsOnly ?? false; + order.Status = OrderStatus.Queued; + order.FailureReason = null; + } + private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); @@ -702,12 +759,7 @@ public sealed class WorldService return null; } - if (ship.OrderQueue.Count >= 8) - { - throw new InvalidOperationException("Order queue is full."); - } - - ship.OrderQueue.Add(new ShipOrderRuntime + ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime { Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Kind = request.Kind, @@ -732,12 +784,7 @@ public sealed class WorldService }); ship.ControlSourceKind = "gm-order"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = request.Label ?? request.Kind; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-enqueued"; @@ -753,22 +800,12 @@ public sealed class WorldService return null; } - ship.OrderQueue.RemoveAll(order => order.Id == orderId); - ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player) + ship.OrderQueue.RemoveById(orderId); + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "gm-order" : "gm-manual"; - ship.ControlSourceId = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Id) - .FirstOrDefault(); - ship.ControlReason = ship.OrderQueue - .Where(order => order.SourceKind == ShipOrderSourceKind.Player) - .OrderByDescending(order => order.Priority) - .ThenBy(order => order.CreatedAtUtc) - .Select(order => order.Label ?? order.Kind) - .FirstOrDefault() + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? "manual-gm-control"; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-removed"; @@ -776,6 +813,59 @@ public sealed class WorldService return ship; } + private ShipRuntime? UpdateGmShipOrderUnsafe(string shipId, string orderId, ShipOrderUpdateCommandRequest request) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var order = ship.OrderQueue.FindById(orderId); + if (order is null || order.SourceKind != ShipOrderSourceKind.Player) + { + return null; + } + + ApplyShipOrderRequest(order, request); + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) + ? "gm-order" + : "gm-manual"; + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) + ?? request.Label + ?? request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "gm-order-updated"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private ShipRuntime? ReorderGmShipOrderUnsafe(string shipId, string orderId, int targetIndex) + { + var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex)) + { + return ship; + } + + ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) + ? "gm-order" + : "gm-manual"; + ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; + ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) + ?? "manual-gm-control"; + ship.NeedsReplan = true; + ship.LastReplanReason = "gm-order-reordered"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); @@ -837,6 +927,26 @@ public sealed class WorldService return ship; } + private static ShipOrderCommandRequest ToCommandRequest(ShipOrderUpdateCommandRequest request) => + new( + request.Kind, + request.Priority, + request.InterruptCurrentPlan, + request.Label, + request.TargetEntityId, + request.TargetSystemId, + request.TargetPosition, + request.SourceStationId, + request.DestinationStationId, + request.ItemId, + request.AnchorId, + request.ConstructionSiteId, + request.ModuleId, + request.WaitSeconds, + request.Radius, + request.MaxSystemRange, + request.KnownStationsOnly); + private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new() { Id = $"commander-faction-{faction.Id}", @@ -915,12 +1025,15 @@ public sealed class WorldService return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius); } - private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) => - _world.Anchors - .Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)) - .Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind)) - .OrderBy(candidate => candidate.Position.DistanceTo(position)) - .FirstOrDefault(); + private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) + { + var systemPosition = SimulationUnits.MetersToKilometers(position); + return _world.Anchors + .Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)) + .Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind)) + .OrderBy(candidate => candidate.Position.DistanceTo(systemPosition)) + .FirstOrDefault(); + } private string? ResolveNearestAnchorId(string systemId, Vector3 position) => ResolveNearestConstructibleAnchor(systemId, position)?.Id; diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index 184ec9a..589e11c 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -1,7 +1,11 @@ import * as THREE from "three"; import { + LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM, + LOCAL_CAMERA_DISTANCE_AT_TRANSITION, + LOCAL_SYSTEM_BACKDROP_DISTANCE, MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, + MIN_LOCAL_CAMERA_DISTANCE, NAV_DISTANCE, } from "./viewerConstants"; import { updatePanFromKeyboard } from "./viewerCamera"; @@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer"; import { LocalLayer } from "./viewerLocalLayer"; import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState"; import { describeSelectable } from "./viewerSelection"; +import { resolveLocalAnchorOffset } from "./viewerWorldPresentation"; import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection"; import { useViewerSceneStore } from "./ui/stores/viewerScene"; import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu"; @@ -88,6 +93,7 @@ export class ViewerAppController { private selectedItems: Selectable[] = []; private worldSignature = ""; private povLevel: PovLevel = "system"; + private previousPovLevel: PovLevel = "system"; private currentDistance = NAV_DISTANCE.system; private desiredDistance = NAV_DISTANCE.system; private orbitYaw = -2.3; @@ -100,6 +106,7 @@ export class ViewerAppController { private marqueeActive = false; private suppressClickSelection = false; private activeSystemId?: string; + private cameraFocusedAnchorId?: string; private cameraTargetShipId?: string; private readonly followCameraPosition = new THREE.Vector3(); private readonly followCameraFocus = new THREE.Vector3(); @@ -262,15 +269,34 @@ export class ViewerAppController { }); } - private computeOrbitOffset(): THREE.Vector3 { - const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); + private computeOrbitOffset(cameraDistance: number): THREE.Vector3 { + const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch); return new THREE.Vector3( Math.cos(this.orbitYaw) * horizontalDistance, - this.currentDistance * Math.sin(this.orbitPitch), + cameraDistance * Math.sin(this.orbitPitch), Math.sin(this.orbitYaw) * horizontalDistance, ); } + private resolveLocalOrbitCameraDistance() { + const clamped = THREE.MathUtils.clamp(this.currentDistance, MIN_LOCAL_CAMERA_DISTANCE, 650); + return THREE.MathUtils.mapLinear( + clamped, + MIN_LOCAL_CAMERA_DISTANCE, + 650, + LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM, + LOCAL_CAMERA_DISTANCE_AT_TRANSITION, + ); + } + + private resolveSystemOrbitCameraDistance() { + if (this.povLevel !== "local") { + return this.currentDistance; + } + + return LOCAL_SYSTEM_BACKDROP_DISTANCE; + } + private updateCamera(delta: number) { const nextState = stepCamera({ currentDistance: this.currentDistance, @@ -279,6 +305,7 @@ export class ViewerAppController { delta, }); this.currentDistance = nextState.currentDistance; + this.previousPovLevel = this.povLevel; this.povLevel = nextState.povLevel; this.orbitPitch = nextState.orbitPitch; if (this.sceneStore.povLevel !== this.povLevel) { @@ -286,27 +313,29 @@ export class ViewerAppController { } this.navigationController.updateActiveSystem(); this.navigationController.syncGalaxyAnchorToActiveSystem(); + this.updateCameraFocusedAnchor(); if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) { // Follow camera directly controls systemLayer.camera in updateFollowCamera. // Still update galaxy camera independently. - const orbitOffset = this.computeOrbitOffset(); - this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset); + const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance()); + this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset); return; } this.updatePanFromKeyboard(delta); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32); - const orbitOffset = this.computeOrbitOffset(); + const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance()); + const localOrbitOffset = this.computeOrbitOffset(this.resolveLocalOrbitCameraDistance()); - this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset); + this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset); if (this.activeSystemId) { - this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset); + this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), systemOrbitOffset); } - this.localLayer.updateCamera(orbitOffset); + this.localLayer.updateCamera(this.systemAnchor, localOrbitOffset, resolveLocalAnchorOffset(this.world, this.resolveFocusedAnchorId())); // Update star dot scales in galaxy scene updateSystemStarPresentation( @@ -353,7 +382,48 @@ export class ViewerAppController { } private resolveFocusedAnchorId() { - return resolveFocusedAnchorId(this.world, this.selectedItems); + return this.cameraFocusedAnchorId; + } + + private updateCameraFocusedAnchor() { + if (!this.world || !this.activeSystemId || this.povLevel === "galaxy") { + this.cameraFocusedAnchorId = undefined; + return; + } + + if (this.povLevel === "system") { + this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus(); + return; + } + + if (this.previousPovLevel !== "local" || !this.cameraFocusedAnchorId) { + this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus() ?? this.cameraFocusedAnchorId; + } + } + + private resolveNearestAnchorToSystemFocus() { + if (!this.world || !this.activeSystemId) { + return undefined; + } + + let bestAnchorId: string | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const anchor of this.world.anchors.values()) { + if (anchor.systemId !== this.activeSystemId) { + continue; + } + + const dx = anchor.systemPosition.x - this.systemAnchor.x; + const dy = anchor.systemPosition.y - this.systemAnchor.y; + const dz = anchor.systemPosition.z - this.systemAnchor.z; + const distanceSquared = (dx * dx) + (dy * dy) + (dz * dz); + if (distanceSquared < bestDistance) { + bestDistance = distanceSquared; + bestAnchorId = anchor.id; + } + } + + return bestAnchorId; } private onResize(width: number, height: number) { diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index df27e1a..50bfa12 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -23,6 +23,7 @@ import type { import type { ShipDefaultBehaviorCommandRequest, ShipOrderCommandRequest, + ShipOrderUpdateCommandRequest, } from "./shipCommands"; export interface WorldStreamScope { @@ -318,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) { method: "DELETE", }); } + +export async function updateShipOrder(shipId: string, orderId: string, request: ShipOrderUpdateCommandRequest) { + return fetchJson(`/api/ships/${shipId}/orders/${orderId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} diff --git a/apps/viewer/src/components/ViewerEntityBrowserPanel.vue b/apps/viewer/src/components/ViewerEntityBrowserPanel.vue index 1a9a10e..77a3af5 100644 --- a/apps/viewer/src/components/ViewerEntityBrowserPanel.vue +++ b/apps/viewer/src/components/ViewerEntityBrowserPanel.vue @@ -160,11 +160,11 @@ function shipAiStates(ship: ShipSnapshot) { const travelToken = ship.spatialState.transit ? "TRV" : ""; const dockToken = ship.dockedStationId ? "DCK" : ""; const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO"); - const planToken = ship.activePlan?.steps.length ? "PLAN" : ""; + const taskToken = ship.activeSubTasks.length > 0 ? "TSK" : ""; const orderToken = ship.orderQueue.length > 0 ? "ORD" : ""; const commandToken = ship.commanderId ? "CMD" : ""; - return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5); + return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5); } function stationAiStates(station: StationSnapshot) { diff --git a/apps/viewer/src/components/ViewerEntityInspectorPanel.vue b/apps/viewer/src/components/ViewerEntityInspectorPanel.vue index 31bae9e..e4ce944 100644 --- a/apps/viewer/src/components/ViewerEntityInspectorPanel.vue +++ b/apps/viewer/src/components/ViewerEntityInspectorPanel.vue @@ -2,7 +2,8 @@ import { computed, reactive, ref, watch } from "vue"; import { storeToRefs } from "pinia"; import modulesData from "../../../../shared/data/modules.json"; -import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api"; +import { removeShipOrder, updateShipDefaultBehavior, updateShipOrder } from "../api"; +import type { ShipOrderSnapshot } from "../contractsShips"; import { formatShipAutomationSupportStatus, getShipBehaviorLabel, @@ -43,16 +44,23 @@ const behaviorForm = reactive({ areaSystemId: "", itemId: "ore", }); - -const mineOrderForm = reactive({ - systemId: "", - itemId: "ore", -}); - -const moveOrderSystemId = ref(""); const actionBusy = ref(false); const actionStatus = ref(""); const actionError = ref(""); +const expandedDirectOrderId = ref(null); + +const orderEditForm = reactive({ + label: "", + priority: "100", + interruptCurrentPlan: true, + targetSystemId: "", + targetEntityId: "", + itemId: "", + waitSeconds: "0", + radius: "0", + maxSystemRange: "", + knownStationsOnly: false, +}); const moduleNameById = new Map( (modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]), @@ -92,6 +100,34 @@ function joinDetail(parts: Array) { return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · "); } +function describeOrderFailure(order: { + failureReason?: string | null; + kind: string; + itemId?: string | null; +}) { + switch (order.failureReason) { + case "mine-order-node-missing": + return `Cannot find ${order.itemId ?? "resource"} to mine`; + case "mine-order-item-missing": + return "No mining ware selected"; + case "mine-order-node-system-mismatch": + return "Selected mining target is in the wrong system"; + case "mine-order-node-item-mismatch": + return `Selected mining target does not provide ${order.itemId ?? "the requested ware"}`; + case "mine-order-incomplete": + case "mine-and-deliver-order-incomplete": + return `Cannot complete ${getShipOrderLabel(order.kind).toLowerCase()}`; + case "target-ship-missing": + return "Target ship no longer exists"; + case "target-missing": + return "Target no longer exists"; + case "station-missing": + return "Station no longer exists"; + default: + return order.failureReason ? titleCase(order.failureReason) : null; + } +} + function describeOrderTarget(order: { itemId?: string | null; targetEntityId?: string | null; @@ -161,11 +197,7 @@ const canDirectControlSelectedShip = computed(() => ); const directOrders = computed(() => - selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [], -); - -const behaviorOrders = computed(() => - selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [], + selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [], ); const editableBehaviorDefinitions = computed(() => @@ -189,6 +221,10 @@ const formBehaviorNotes = computed(() => getShipBehaviorNotes(behaviorForm.kind), ); +const behaviorGeneratedOrderCount = computed(() => + selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior").length ?? 0, +); + const shipStatusRows = computed(() => { if (!selectedShip.value) { return []; @@ -206,9 +242,9 @@ const shipStatusRows = computed(() => { { label: "Control", value: titleCase(selectedShip.value.controlSourceKind) }, { label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" }, { - label: "Plan", - value: selectedShip.value.activePlan - ? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}` + label: "Activity", + value: selectedShip.value.activeSubTasks[0] + ? `${selectedShip.value.activeSubTasks[0].summary || titleCase(selectedShip.value.activeSubTasks[0].kind)} · ${titleCase(selectedShip.value.activeSubTasks[0].status)}` : "none", }, { label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" }, @@ -260,67 +296,33 @@ const shipBehaviorRows = computed(() => { const directOrderRows = computed(() => directOrders.value.map((order) => ({ id: order.id, + kind: order.kind, label: getShipOrderLabel(order.kind), status: titleCase(order.status), target: describeOrderTarget(order), detail: joinDetail([ `P${order.priority}`, titleCase(order.sourceKind), - order.failureReason ?? undefined, + describeOrderFailure(order) ?? undefined, ]), })), ); -const behaviorOrderRows = computed(() => - behaviorOrders.value.map((order) => ({ - id: order.id, - label: getShipOrderLabel(order.kind), - status: titleCase(order.status), - target: describeOrderTarget(order), +const shipPlanRows = computed(() => + (selectedShip.value?.activeSubTasks ?? []).map((subTask) => ({ + id: subTask.id, + scope: "Task", + activity: subTask.summary || titleCase(subTask.kind), + status: titleCase(subTask.status), detail: joinDetail([ - `P${order.priority}`, - getShipOrderSupportStatusLabel(order.kind) ?? undefined, - getShipOrderNotes(order.kind) ?? undefined, - order.failureReason ?? undefined, + describeSubTaskTarget(subTask), + subTask.blockingReason ?? undefined, + `${Math.round(subTask.progress * 100)}%`, ]), + isSubTask: false, })), ); -const shipPlanRows = computed(() => { - if (!selectedShip.value?.activePlan) { - return []; - } - - return selectedShip.value.activePlan.steps.flatMap((step) => { - const stepRow = { - id: step.id, - scope: "Step", - activity: step.summary || titleCase(step.kind), - status: titleCase(step.status), - detail: joinDetail([ - step.blockingReason ?? undefined, - `${step.subTasks.length} subtasks`, - ]), - isSubTask: false, - }; - - const subTaskRows = step.subTasks.map((subTask) => ({ - id: subTask.id, - scope: "Subtask", - activity: subTask.summary || titleCase(subTask.kind), - status: titleCase(subTask.status), - detail: joinDetail([ - describeSubTaskTarget(subTask), - subTask.blockingReason ?? undefined, - `${Math.round(subTask.progress * 100)}%`, - ]), - isSubTask: true, - })); - - return [stepRow, ...subTaskRows]; - }); -}); - const stationStatusRows = computed(() => { if (!selectedStation.value) { return []; @@ -397,15 +399,116 @@ watch( behaviorForm.kind = ship.defaultBehavior.kind; behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? ""; behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore"; - mineOrderForm.systemId = ship.systemId ?? ""; - mineOrderForm.itemId = "ore"; - moveOrderSystemId.value = ship.systemId ?? ""; actionStatus.value = ""; actionError.value = ""; + expandedDirectOrderId.value = null; }, { immediate: true }, ); +function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") { + switch (field) { + case "targetSystemId": + return kind === "move" || kind === "mine-and-deliver"; + case "targetEntityId": + return kind === "follow-ship" || kind === "attack-target"; + case "itemId": + return kind === "mine-and-deliver"; + case "waitSeconds": + return kind === "hold-position" || kind === "follow-ship"; + case "radius": + return kind === "move" || kind === "follow-ship"; + case "maxSystemRange": + return kind === "mine-and-deliver"; + case "knownStationsOnly": + return kind === "mine-and-deliver"; + default: + return false; + } +} + +function loadOrderEditor(order: ShipOrderSnapshot) { + orderEditForm.label = order.label ?? ""; + orderEditForm.priority = String(order.priority); + orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan; + orderEditForm.targetSystemId = order.targetSystemId ?? ""; + orderEditForm.targetEntityId = order.targetEntityId ?? ""; + orderEditForm.itemId = order.itemId ?? "ore"; + orderEditForm.waitSeconds = String(order.waitSeconds ?? 0); + orderEditForm.radius = String(order.radius ?? 0); + orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange); + orderEditForm.knownStationsOnly = order.knownStationsOnly; +} + +function toggleOrderEditor(order: ShipOrderSnapshot) { + if (expandedDirectOrderId.value === order.id) { + expandedDirectOrderId.value = null; + return; + } + + loadOrderEditor(order); + expandedDirectOrderId.value = order.id; +} + +function parseNumber(value: string, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseOptionalInt(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +async function saveOrder(order: ShipOrderSnapshot) { + if (!selectedShip.value || !canDirectControlSelectedShip.value) { + return; + } + + await runShipAction(async () => { + const ship = await updateShipOrder(selectedShip.value!.id, order.id, { + kind: order.kind, + priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))), + interruptCurrentPlan: orderEditForm.interruptCurrentPlan, + label: orderEditForm.label.trim() || null, + targetEntityId: supportsOrderField(order.kind, "targetEntityId") + ? (orderEditForm.targetEntityId.trim() || null) + : order.targetEntityId ?? null, + targetSystemId: supportsOrderField(order.kind, "targetSystemId") + ? (orderEditForm.targetSystemId.trim() || null) + : order.targetSystemId ?? null, + targetPosition: order.targetPosition ?? null, + sourceStationId: order.sourceStationId ?? null, + destinationStationId: order.destinationStationId ?? null, + itemId: supportsOrderField(order.kind, "itemId") + ? (orderEditForm.itemId.trim() || null) + : order.itemId ?? null, + anchorId: order.anchorId ?? null, + constructionSiteId: order.constructionSiteId ?? null, + moduleId: order.moduleId ?? null, + waitSeconds: supportsOrderField(order.kind, "waitSeconds") + ? parseNumber(orderEditForm.waitSeconds, order.waitSeconds) + : order.waitSeconds, + radius: supportsOrderField(order.kind, "radius") + ? parseNumber(orderEditForm.radius, order.radius) + : order.radius, + maxSystemRange: supportsOrderField(order.kind, "maxSystemRange") + ? parseOptionalInt(orderEditForm.maxSystemRange) + : order.maxSystemRange ?? null, + knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly") + ? orderEditForm.knownStationsOnly + : order.knownStationsOnly, + }); + gmStore.upsertShip(ship); + expandedDirectOrderId.value = null; + }, "Order updated."); +} + function focusShip(cameraMode?: "follow" | "tactical") { if (!selectedShip.value) { return; @@ -468,114 +571,6 @@ async function saveBehavior() { }, "Default behavior updated."); } -async function queueHoldPositionOrder() { - if (!selectedShip.value || !canDirectControlSelectedShip.value) { - return; - } - - await runShipAction(async () => { - const ship = await enqueueShipOrder(selectedShip.value!.id, { - kind: "hold-position", - priority: 100, - interruptCurrentPlan: true, - label: "Hold position", - targetEntityId: null, - targetSystemId: null, - targetPosition: null, - sourceStationId: null, - destinationStationId: null, - itemId: null, - anchorId: null, - constructionSiteId: null, - moduleId: null, - waitSeconds: 0, - radius: 0, - maxSystemRange: 0, - knownStationsOnly: false, - }); - gmStore.upsertShip(ship); - }, "Hold position order queued."); -} - -async function queueMoveOrder() { - if (!selectedShip.value || !canDirectControlSelectedShip.value) { - return; - } - - const targetSystemId = moveOrderSystemId.value.trim(); - if (!targetSystemId) { - actionError.value = "Select a target system."; - actionStatus.value = ""; - return; - } - - await runShipAction(async () => { - const ship = await enqueueShipOrder(selectedShip.value!.id, { - kind: "move", - priority: 90, - interruptCurrentPlan: true, - label: `Move to ${targetSystemId}`, - targetEntityId: null, - targetSystemId, - targetPosition: null, - sourceStationId: null, - destinationStationId: null, - itemId: null, - anchorId: null, - constructionSiteId: null, - moduleId: null, - waitSeconds: 0, - radius: 0, - maxSystemRange: 0, - knownStationsOnly: false, - }); - gmStore.upsertShip(ship); - }, "Move order queued."); -} - -async function queueMineResourceOrder() { - if (!selectedShip.value || !canDirectControlSelectedShip.value) { - return; - } - - const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId; - const itemId = mineOrderForm.itemId.trim(); - if (!targetSystemId) { - actionError.value = "Select a mining system."; - actionStatus.value = ""; - return; - } - - if (!itemId) { - actionError.value = "Select a ware to mine."; - actionStatus.value = ""; - return; - } - - await runShipAction(async () => { - const ship = await enqueueShipOrder(selectedShip.value!.id, { - kind: "mine-and-deliver", - priority: 95, - interruptCurrentPlan: true, - label: `Mine ${itemId} in ${targetSystemId}`, - targetEntityId: null, - targetSystemId, - targetPosition: null, - sourceStationId: null, - destinationStationId: null, - itemId, - anchorId: null, - constructionSiteId: null, - moduleId: null, - waitSeconds: 0, - radius: 0, - maxSystemRange: 0, - knownStationsOnly: false, - }); - gmStore.upsertShip(ship); - }, "Mine Resource order queued."); -} - async function removeOrder(orderId: string) { if (!selectedShip.value || !canDirectControlSelectedShip.value) { return; @@ -632,43 +627,114 @@ async function clearOrders() {
-

Cargo

-
-
-
- {{ row.label }} - {{ row.valueLabel }} / {{ row.maxLabel }} -
-
- 0 -
-
+

Order Queue

+
+ +
+
{{ actionStatus }}
+
{{ actionError }}
+
+
+
+ +
+ {{ titleCase(order.status) }} +
- {{ row.maxLabel }} +
+
+ {{ describeOrderTarget(order) }} + {{ joinDetail([`P${order.priority}`, titleCase(order.sourceKind), describeOrderFailure(order) ?? undefined]) }}
-
+
+
+ {{ [getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }} +
+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+
+
-
- - - - - - - - - - - - - -
WareAmount
{{ row.ware }}{{ row.amount }}
+
No direct orders queued.
+
+ Behavior-generated queue entries are managed from Default Behavior. + Active generated orders: {{ behaviorGeneratedOrderCount }}.
-
No wares loaded.
-

Behavior

+

Default Behavior

{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
@@ -715,125 +781,6 @@ async function clearOrders() { Direct behavior editing is only available for player-owned ships or GM users.
- -
-

Orders

-
-
- - -
-
- - -
-
- - - -
-
-
{{ actionStatus }}
-
{{ actionError }}
-
- - - - - - - - - - - - - - - - - - - -
OrderStatusTargetDetailAction
{{ order.label }}{{ order.status }}{{ order.target }}{{ order.detail }} - -
-
-
No direct orders queued.
-
- Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }} -
-
- - - - - - - - - - - - - - - - - -
OrderStatusTargetDetail
{{ order.label }}{{ order.status }}{{ order.target }}{{ order.detail }}
-
-
No behavior orders queued.
-
- -
-

Plan Steps

-
- - - - - - - - - - - - - - - - - -
ScopeActivityStatusDetail
{{ row.scope }}{{ row.activity }}{{ row.status }}{{ row.detail }}
-
-
No active plan.
-