Refine ship orders and viewer controls

This commit is contained in:
2026-04-09 12:42:52 -04:00
parent 6c92ab50c8
commit 8503855a4c
64 changed files with 2939 additions and 2037 deletions

View File

@@ -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<SolarSystemDefinition>? Systems { get; set; }
public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]

View File

@@ -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<ShipBehaviorDefinition> 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<ShipOrderDefinition> 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."),

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<SimulationEventRecord> events)
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousOrderId, string? previousTaskId, ICollection<SimulationEventRecord> 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));
}
}

View File

@@ -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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
private static IReadOnlyList<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipPlanStepRuntime> 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<ShipSubTaskRuntime> 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,
};
}

View File

@@ -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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime> 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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<ShipSubTaskRuntime>? 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}");
}
}

View File

@@ -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<SimulationEventRecord> 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<SimulationEventRecord> events)
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> 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<SimulationEventRecord> 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<ShipSubTaskRuntime> 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<SimulationEventRecord> 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<SimulationEventRecord> 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<SimulationEventRecord> 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<SimulationEventRecord> 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);
}
}

View File

@@ -0,0 +1,31 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class ReorderShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderReorderRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}/position");
}
public override async Task HandleAsync(ShipOrderReorderRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("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);
}
}

View File

@@ -0,0 +1,39 @@
using FastEndpoints;
namespace SpaceGame.Api.Ships.Api;
public sealed class UpdateShipOrderHandler(WorldService worldService) : Endpoint<ShipOrderUpdateCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Put("/api/ships/{shipId}/orders/{orderId}");
}
public override async Task HandleAsync(ShipOrderUpdateCommandRequest request, CancellationToken cancellationToken)
{
var shipId = Route<string>("shipId");
var orderId = Route<string>("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);
}
}
}

View File

@@ -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,

View File

@@ -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<ShipSubTaskSnapshot> 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<ShipPlanStepSnapshot> 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<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,
@@ -182,8 +157,6 @@ public sealed record ShipDelta(
DefaultBehaviorSnapshot DefaultBehavior,
ShipAssignmentSnapshot? Assignment,
ShipSkillProfileSnapshot Skills,
ShipPlanSnapshot? ActivePlan,
string? CurrentStepId,
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
string ControlSourceKind,
string? ControlSourceId,

View File

@@ -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<ShipOrderRuntime> 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<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
public List<string> History { get; } = [];
public string? ActiveOrderId { get; set; }
public int ActiveSubTaskIndex { get; set; }
public List<ShipSubTaskRuntime> ActiveSubTasks { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderQueue : IReadOnlyList<ShipOrderRuntime>
{
public const int MaxOrders = 8;
private readonly List<ShipOrderRuntime> _orders = [];
public int Count => _orders.Count;
public ShipOrderRuntime this[int index] => _orders[index];
public IEnumerator<ShipOrderRuntime> 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<ShipOrderRuntime> 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<ShipPlanStepRuntime> 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<ShipSubTaskRuntime> SubTasks { get; } = [];
}
public sealed class ShipSubTaskRuntime
{
public required string Id { get; init; }

View File

@@ -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<string, float> 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<ShipOrderSnapshot> 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<ShipSubTaskSnapshot> 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;

View File

@@ -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<ResourceDepositRuntime>(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<AnchorRuntime> 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,
};
}

View File

@@ -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));

View File

@@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder(
generationService.PrepareKnownSystems(staticData.KnownSystems),
worldGenerationOptions);
return BuildFromDefinitions(systems);
}
public WorldBuildTopology Build(IReadOnlyList<SolarSystemDefinition> 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<SolarSystemDefinition> systems)
{
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{

View File

@@ -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)
{

View File

@@ -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;