Refine ship orders and viewer controls
This commit is contained in:
@@ -3,4 +3,8 @@
|
|||||||
<Folder Name="/apps/backend/">
|
<Folder Name="/apps/backend/">
|
||||||
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
<Project Path="apps/backend/SpaceGame.Api.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/" />
|
||||||
|
<Folder Name="/tests/backend/">
|
||||||
|
<Project Path="tests/backend/SpaceGame.Api.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -559,6 +559,9 @@ public sealed class ShipCargoDefinition
|
|||||||
public sealed class ScenarioDefinition
|
public sealed class ScenarioDefinition
|
||||||
{
|
{
|
||||||
public required WorldGenerationOptions WorldGeneration { get; set; }
|
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<InitialStationDefinition> InitialStations { get; set; }
|
||||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||||
|
|||||||
@@ -2834,7 +2834,7 @@ internal sealed class CommanderPlanningService
|
|||||||
TargetEntityId = objective.TargetEntityId,
|
TargetEntityId = objective.TargetEntityId,
|
||||||
TargetSystemId = targetSystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = targetPosition,
|
||||||
DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null,
|
DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null,
|
||||||
ItemId = objective.ItemId,
|
ItemId = objective.ItemId,
|
||||||
WaitSeconds = 0f,
|
WaitSeconds = 0f,
|
||||||
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
||||||
@@ -2874,13 +2874,13 @@ internal sealed class CommanderPlanningService
|
|||||||
|
|
||||||
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
|
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)
|
if (desiredOrder is null)
|
||||||
{
|
{
|
||||||
return changed;
|
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 (existing is not null)
|
||||||
{
|
{
|
||||||
if (ShipOrdersEqual(existing, desiredOrder))
|
if (ShipOrdersEqual(existing, desiredOrder))
|
||||||
@@ -2888,18 +2888,18 @@ internal sealed class CommanderPlanningService
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.OrderQueue.Remove(existing);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
changed = true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -672,12 +672,7 @@ internal sealed class PlayerFactionService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.OrderQueue.Count >= 8)
|
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Order queue is full.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
|
||||||
{
|
{
|
||||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||||
Kind = request.Kind,
|
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);
|
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
ship.ControlSourceKind = "player-order";
|
ship.ControlSourceKind = "player-order";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
ship.ControlReason = request.Label ?? request.Kind;
|
ship.ControlReason = request.Label ?? request.Kind;
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-order-enqueued";
|
ship.LastReplanReason = "player-order-enqueued";
|
||||||
@@ -731,28 +721,18 @@ internal sealed class PlayerFactionService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
var removed = ship.OrderQueue.RemoveById(orderId);
|
||||||
if (removed > 0)
|
if (removed)
|
||||||
{
|
{
|
||||||
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
|
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
|
||||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||||
? "player-order"
|
? "player-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(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()
|
|
||||||
?? "manual-player-control";
|
?? "manual-player-control";
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "player-order-removed";
|
ship.LastReplanReason = "player-order-removed";
|
||||||
@@ -760,6 +740,93 @@ internal sealed class PlayerFactionService
|
|||||||
return ship;
|
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)
|
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||||
{
|
{
|
||||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||||
@@ -1321,25 +1388,15 @@ internal sealed class PlayerFactionService
|
|||||||
? "player-directive"
|
? "player-directive"
|
||||||
: automation is not null
|
: automation is not null
|
||||||
? "player-automation"
|
? "player-automation"
|
||||||
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
: ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||||
? "player-order"
|
? "player-order"
|
||||||
: "player-manual";
|
: "player-manual";
|
||||||
var desiredControlSourceId = directive?.Id
|
var desiredControlSourceId = directive?.Id
|
||||||
?? automation?.Id
|
?? automation?.Id
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
var desiredControlReason = directive?.Label
|
var desiredControlReason = directive?.Label
|
||||||
?? automation?.Label
|
?? automation?.Label
|
||||||
?? ship.OrderQueue
|
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Label ?? order.Kind)
|
|
||||||
.FirstOrDefault()
|
|
||||||
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
||||||
|
|
||||||
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
||||||
@@ -1438,7 +1495,7 @@ internal sealed class PlayerFactionService
|
|||||||
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
||||||
{
|
{
|
||||||
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
|
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;
|
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
|
||||||
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
||||||
@@ -1470,17 +1527,16 @@ internal sealed class PlayerFactionService
|
|||||||
KnownStationsOnly = directive.KnownStationsOnly,
|
KnownStationsOnly = directive.KnownStationsOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
|
var existing = ship.OrderQueue.FindById(aiOrderId!);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ShipOrdersEqual(existing, desiredOrder))
|
if (!ShipOrdersEqual(existing, desiredOrder))
|
||||||
{
|
{
|
||||||
ship.OrderQueue.Remove(existing);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using Npgsql;
|
using Npgsql;
|
||||||
using SpaceGame.Api.Universe.Bootstrap;
|
using SpaceGame.Api.Universe.Bootstrap;
|
||||||
|
|
||||||
const string StartupScenarioPath = "scenarios/empty.json";
|
const string StartupScenarioPath = "scenarios/minimal.json";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|||||||
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
3
apps/backend/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("SpaceGame.Api.Tests")]
|
||||||
@@ -34,8 +34,8 @@ public static class ShipBehaviorKinds
|
|||||||
public const string AdvancedAutoMine = "advanced-auto-mine";
|
public const string AdvancedAutoMine = "advanced-auto-mine";
|
||||||
public const string ExpertAutoMine = "expert-auto-mine";
|
public const string ExpertAutoMine = "expert-auto-mine";
|
||||||
|
|
||||||
public const string DockAndWait = "dock-and-wait";
|
public const string DockAtStation = "dock-at-station";
|
||||||
public const string FlyAndWait = "fly-and-wait";
|
public const string Move = "move";
|
||||||
public const string FlyToObject = "fly-to-object";
|
public const string FlyToObject = "fly-to-object";
|
||||||
public const string FollowShip = "follow-ship";
|
public const string FollowShip = "follow-ship";
|
||||||
public const string HoldPosition = "hold-position";
|
public const string HoldPosition = "hold-position";
|
||||||
@@ -60,29 +60,29 @@ public static class ShipAutomationCatalog
|
|||||||
{
|
{
|
||||||
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors =
|
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.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.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.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.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.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.DockAtStation, "Dock At Station", "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.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.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.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.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.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.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.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.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.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."),
|
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 =
|
public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
|
||||||
[
|
[
|
||||||
new(ShipOrderKinds.DockAndWait, "Dock 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.FlyAndWait, "Fly To And Wait", "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.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.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.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."),
|
new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||||
|
|
||||||
|
|||||||
@@ -46,16 +46,6 @@ public enum AiPlanStatus
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AiPlanStepStatus
|
|
||||||
{
|
|
||||||
Planned,
|
|
||||||
Running,
|
|
||||||
Blocked,
|
|
||||||
Completed,
|
|
||||||
Failed,
|
|
||||||
Interrupted,
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum AiPlanSourceKind
|
public enum AiPlanSourceKind
|
||||||
{
|
{
|
||||||
Rule,
|
Rule,
|
||||||
@@ -165,8 +155,6 @@ public static class ShipOrderKinds
|
|||||||
{
|
{
|
||||||
public const string Move = "move";
|
public const string Move = "move";
|
||||||
public const string DockAtStation = "dock-at-station";
|
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 FlyToObject = "fly-to-object";
|
||||||
public const string FollowShip = "follow-ship";
|
public const string FollowShip = "follow-ship";
|
||||||
public const string TradeRoute = "trade-route";
|
public const string TradeRoute = "trade-route";
|
||||||
@@ -324,17 +312,6 @@ public static class SimulationEnumMappings
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
_ => 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
|
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AiPlanSourceKind.Rule => "rule",
|
AiPlanSourceKind.Rule => "rule",
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ public static class SimulationUnits
|
|||||||
|
|
||||||
public static float AuToKilometers(float au) => au * KilometersPerAu;
|
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) =>
|
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
|
||||||
auPerSecond * KilometersPerAu;
|
auPerSecond * KilometersPerAu;
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,12 @@ namespace SpaceGame.Api.Ships.AI;
|
|||||||
|
|
||||||
public sealed partial class ShipAiService
|
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)
|
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
|
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
|
||||||
ship.OrderQueue.RemoveAll(order =>
|
ship.OrderQueue.RemoveWhere(order =>
|
||||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||||
|
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
|
||||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||||
|
|
||||||
if (desiredOrder is null)
|
if (desiredOrder is null)
|
||||||
@@ -34,10 +19,10 @@ public sealed partial class ShipAiService
|
|||||||
return;
|
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)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +31,7 @@ public sealed partial class ShipAiService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.OrderQueue.Remove(existing);
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||||
ship.OrderQueue.Add(desiredOrder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
|
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);
|
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
|
||||||
if (station is null)
|
if (station is null)
|
||||||
@@ -88,38 +72,36 @@ public sealed partial class ShipAiService
|
|||||||
ship.LastAccessFailureReason = null;
|
ship.LastAccessFailureReason = null;
|
||||||
return new ShipOrderRuntime
|
return new ShipOrderRuntime
|
||||||
{
|
{
|
||||||
Id = $"behavior-{ship.Id}-dock-and-wait",
|
Id = $"behavior-{ship.Id}-dock-at-station",
|
||||||
Kind = ShipOrderKinds.DockAndWait,
|
Kind = ShipOrderKinds.DockAtStation,
|
||||||
SourceKind = ShipOrderSourceKind.Behavior,
|
SourceKind = ShipOrderSourceKind.Behavior,
|
||||||
SourceId = behaviorKind,
|
SourceId = behaviorKind,
|
||||||
Priority = 0,
|
Priority = 0,
|
||||||
InterruptCurrentPlan = false,
|
InterruptCurrentPlan = false,
|
||||||
Label = $"Dock and wait at {station.Label}",
|
Label = $"Dock at {station.Label}",
|
||||||
TargetEntityId = station.Id,
|
TargetEntityId = station.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = station.SystemId,
|
||||||
DestinationStationId = station.Id,
|
DestinationStationId = station.Id,
|
||||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
|
||||||
Radius = ship.DefaultBehavior.Radius,
|
Radius = ship.DefaultBehavior.Radius,
|
||||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal))
|
if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
ship.LastAccessFailureReason = null;
|
ship.LastAccessFailureReason = null;
|
||||||
return new ShipOrderRuntime
|
return new ShipOrderRuntime
|
||||||
{
|
{
|
||||||
Id = $"behavior-{ship.Id}-fly-and-wait",
|
Id = $"behavior-{ship.Id}-move",
|
||||||
Kind = ShipOrderKinds.FlyAndWait,
|
Kind = ShipOrderKinds.Move,
|
||||||
SourceKind = ShipOrderSourceKind.Behavior,
|
SourceKind = ShipOrderSourceKind.Behavior,
|
||||||
SourceId = behaviorKind,
|
SourceId = behaviorKind,
|
||||||
Priority = 0,
|
Priority = 0,
|
||||||
InterruptCurrentPlan = false,
|
InterruptCurrentPlan = false,
|
||||||
Label = "Fly and wait",
|
Label = "Fly to position",
|
||||||
TargetSystemId = systemId,
|
TargetSystemId = systemId,
|
||||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
|
||||||
Radius = ship.DefaultBehavior.Radius,
|
Radius = ship.DefaultBehavior.Radius,
|
||||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||||
@@ -306,13 +288,12 @@ public sealed partial class ShipAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
ship.LastAccessFailureReason = null;
|
ship.LastAccessFailureReason = null;
|
||||||
return CreateManagedFlyAndWaitOrder(
|
return CreateManagedMoveOrder(
|
||||||
ship,
|
ship,
|
||||||
behaviorKind,
|
behaviorKind,
|
||||||
"Protect position",
|
"Protect position",
|
||||||
targetSystemId,
|
targetSystemId,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
|
||||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,13 +346,12 @@ public sealed partial class ShipAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
ship.LastAccessFailureReason = null;
|
ship.LastAccessFailureReason = null;
|
||||||
return CreateManagedFlyAndWaitOrder(
|
return CreateManagedMoveOrder(
|
||||||
ship,
|
ship,
|
||||||
behaviorKind,
|
behaviorKind,
|
||||||
$"Guard {station.Label}",
|
$"Guard {station.Label}",
|
||||||
station.SystemId,
|
station.SystemId,
|
||||||
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
|
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));
|
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +390,7 @@ public sealed partial class ShipAiService
|
|||||||
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
|
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
|
||||||
{
|
{
|
||||||
ship.LastAccessFailureReason = null;
|
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";
|
ship.LastAccessFailureReason = "no-trade-route";
|
||||||
@@ -641,7 +621,7 @@ public sealed partial class ShipAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
ship.LastAccessFailureReason = null;
|
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(
|
private static ShipOrderRuntime CreateManagedAttackOrder(
|
||||||
@@ -687,11 +667,11 @@ public sealed partial class ShipAiService
|
|||||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait",
|
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
|
||||||
Kind = ShipOrderKinds.DockAndWait,
|
Kind = ShipOrderKinds.DockAtStation,
|
||||||
SourceKind = ShipOrderSourceKind.Behavior,
|
SourceKind = ShipOrderSourceKind.Behavior,
|
||||||
SourceId = behaviorKind,
|
SourceId = behaviorKind,
|
||||||
Priority = 0,
|
Priority = 0,
|
||||||
@@ -700,25 +680,23 @@ public sealed partial class ShipAiService
|
|||||||
TargetEntityId = station.Id,
|
TargetEntityId = station.Id,
|
||||||
TargetSystemId = station.SystemId,
|
TargetSystemId = station.SystemId,
|
||||||
DestinationStationId = station.Id,
|
DestinationStationId = station.Id,
|
||||||
WaitSeconds = waitSeconds,
|
|
||||||
Radius = ship.DefaultBehavior.Radius,
|
Radius = ship.DefaultBehavior.Radius,
|
||||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder(
|
private static ShipOrderRuntime CreateManagedMoveOrder(
|
||||||
ShipRuntime ship,
|
ShipRuntime ship,
|
||||||
string behaviorKind,
|
string behaviorKind,
|
||||||
string label,
|
string label,
|
||||||
string targetSystemId,
|
string targetSystemId,
|
||||||
Vector3 targetPosition,
|
Vector3 targetPosition,
|
||||||
float waitSeconds,
|
|
||||||
float radius,
|
float radius,
|
||||||
string? orderIdSuffix = null) =>
|
string? orderIdSuffix = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||||
Kind = ShipOrderKinds.FlyAndWait,
|
Kind = ShipOrderKinds.Move,
|
||||||
SourceKind = ShipOrderSourceKind.Behavior,
|
SourceKind = ShipOrderSourceKind.Behavior,
|
||||||
SourceId = behaviorKind,
|
SourceId = behaviorKind,
|
||||||
Priority = 0,
|
Priority = 0,
|
||||||
@@ -726,7 +704,6 @@ public sealed partial class ShipAiService
|
|||||||
Label = label,
|
Label = label,
|
||||||
TargetSystemId = targetSystemId,
|
TargetSystemId = targetSystemId,
|
||||||
TargetPosition = targetPosition,
|
TargetPosition = targetPosition,
|
||||||
WaitSeconds = waitSeconds,
|
|
||||||
Radius = radius,
|
Radius = radius,
|
||||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
|
|||||||
|
|
||||||
public sealed partial class ShipAiService
|
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
|
return subTask.Kind switch
|
||||||
{
|
{
|
||||||
@@ -636,12 +636,13 @@ public sealed partial class ShipAiService
|
|||||||
ship.SpatialState.Transit = null;
|
ship.SpatialState.Transit = null;
|
||||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||||
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
|
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.SpatialState.SystemPosition = currentAnchor is null
|
||||||
? ship.Position
|
? localSystemOffset
|
||||||
: new Vector3(
|
: new Vector3(
|
||||||
currentAnchor.Position.X + ship.Position.X,
|
currentAnchor.Position.X + localSystemOffset.X,
|
||||||
currentAnchor.Position.Y + ship.Position.Y,
|
currentAnchor.Position.Y + localSystemOffset.Y,
|
||||||
currentAnchor.Position.Z + ship.Position.Z);
|
currentAnchor.Position.Z + localSystemOffset.Z);
|
||||||
|
|
||||||
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
|
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
|
||||||
{
|
{
|
||||||
@@ -650,12 +651,13 @@ public sealed partial class ShipAiService
|
|||||||
ship.SystemId = targetSystemId;
|
ship.SystemId = targetSystemId;
|
||||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||||
|
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||||
? targetPosition
|
? arrivalSystemOffset
|
||||||
: new Vector3(
|
: new Vector3(
|
||||||
targetAnchor.Position.X + targetPosition.X,
|
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||||
targetAnchor.Position.Y + targetPosition.Y,
|
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||||
targetAnchor.Position.Z + targetPosition.Z);
|
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||||
ship.State = ShipState.Arriving;
|
ship.State = ShipState.Arriving;
|
||||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||||
}
|
}
|
||||||
@@ -663,12 +665,13 @@ public sealed partial class ShipAiService
|
|||||||
ship.State = ShipState.LocalFlight;
|
ship.State = ShipState.LocalFlight;
|
||||||
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||||
|
var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||||
? ship.Position
|
? movedSystemOffset
|
||||||
: new Vector3(
|
: new Vector3(
|
||||||
currentAnchor.Position.X + ship.Position.X,
|
currentAnchor.Position.X + movedSystemOffset.X,
|
||||||
currentAnchor.Position.Y + ship.Position.Y,
|
currentAnchor.Position.Y + movedSystemOffset.Y,
|
||||||
currentAnchor.Position.Z + ship.Position.Z);
|
currentAnchor.Position.Z + movedSystemOffset.Z);
|
||||||
return SubTaskOutcome.Active;
|
return SubTaskOutcome.Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,12 +825,13 @@ public sealed partial class ShipAiService
|
|||||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id;
|
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id;
|
||||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id;
|
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id;
|
||||||
|
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||||
? targetPosition
|
? arrivalSystemOffset
|
||||||
: new Vector3(
|
: new Vector3(
|
||||||
targetAnchor.Position.X + targetPosition.X,
|
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||||
targetAnchor.Position.Y + targetPosition.Y,
|
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||||
targetAnchor.Position.Z + targetPosition.Z);
|
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||||
ship.State = ShipState.Arriving;
|
ship.State = ShipState.Arriving;
|
||||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,13 +185,14 @@ public sealed partial class ShipAiService
|
|||||||
{
|
{
|
||||||
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
|
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
|
||||||
{
|
{
|
||||||
|
var localOffset = SimulationUnits.MetersToKilometers(station.Position);
|
||||||
return new Vector3(
|
return new Vector3(
|
||||||
anchor.Position.X + station.Position.X,
|
anchor.Position.X + localOffset.X,
|
||||||
anchor.Position.Y + station.Position.Y,
|
anchor.Position.Y + localOffset.Y,
|
||||||
anchor.Position.Z + station.Position.Z);
|
anchor.Position.Z + localOffset.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
return station.Position;
|
return SimulationUnits.MetersToKilometers(station.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node)
|
private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node)
|
||||||
@@ -216,17 +217,18 @@ public sealed partial class ShipAiService
|
|||||||
|
|
||||||
if (ResolveCurrentAnchor(world, ship) is { } anchor)
|
if (ResolveCurrentAnchor(world, ship) is { } anchor)
|
||||||
{
|
{
|
||||||
|
var localOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||||
return new Vector3(
|
return new Vector3(
|
||||||
anchor.Position.X + ship.Position.X,
|
anchor.Position.X + localOffset.X,
|
||||||
anchor.Position.Y + ship.Position.Y,
|
anchor.Position.Y + localOffset.Y,
|
||||||
anchor.Position.Z + ship.Position.Z);
|
anchor.Position.Z + localOffset.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ship.Position;
|
return SimulationUnits.MetersToKilometers(ship.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
|
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) =>
|
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
|
||||||
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
|
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
|
||||||
@@ -997,9 +999,6 @@ public sealed partial class ShipAiService
|
|||||||
? null
|
? null
|
||||||
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment;
|
: 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)
|
private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
|
||||||
{
|
{
|
||||||
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId)
|
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)
|
private static void TrackHistory(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var plan = ship.ActivePlan;
|
var orderId = ship.ActiveOrderId ?? "none";
|
||||||
var step = GetCurrentStep(plan);
|
var subTask = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||||
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex];
|
var signature = $"{ship.State.ToContractValue()}|{orderId}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
|
||||||
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
|
|
||||||
if (ship.LastSignature == signature)
|
if (ship.LastSignature == signature)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.LastSignature = signature;
|
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)
|
if (ship.History.Count > 24)
|
||||||
{
|
{
|
||||||
ship.History.RemoveAt(0);
|
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 currentOrderId = ship.ActiveOrderId;
|
||||||
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
var currentTaskId = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id;
|
||||||
var occurredAtUtc = DateTimeOffset.UtcNow;
|
var occurredAtUtc = DateTimeOffset.UtcNow;
|
||||||
if (previousState != ship.State)
|
if (previousState != ship.State)
|
||||||
{
|
{
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,293 +1,149 @@
|
|||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
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;
|
namespace SpaceGame.Api.Ships.AI;
|
||||||
|
|
||||||
public sealed partial class ShipAiService
|
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
|
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
|
||||||
{
|
{
|
||||||
"missing-item" => true,
|
"missing-item" => true,
|
||||||
"no-suitable-buyer" => 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,
|
_ => 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 assignment = ResolveAssignment(world, ship);
|
||||||
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
return assignment is null
|
||||||
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource";
|
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
||||||
|
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.TradeRoute,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}",
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
|
CreateSubTask("sub-acquire-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-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-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-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f),
|
||||||
]),
|
|
||||||
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
CreateSubTask("sub-route-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-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-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-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f),
|
||||||
])
|
];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
SupplyFleet,
|
|
||||||
plan.Summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
CreateSubTask("sub-fleet-source-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-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-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-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-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-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 BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
|
||||||
{
|
{
|
||||||
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
|
var targetPosition = supportStation.Position;
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
"construction-support",
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
CreateSubTask("sub-construction-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-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),
|
||||||
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
|
];
|
||||||
[
|
|
||||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.AttackTarget,
|
|
||||||
summary,
|
|
||||||
[
|
[
|
||||||
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
|
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
|
||||||
[
|
];
|
||||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.DockAndWait,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f),
|
|
||||||
CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
|
||||||
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
|
|
||||||
{
|
|
||||||
return CreatePlan(
|
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.FlyAndWait,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
|
|
||||||
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
|
||||||
{
|
|
||||||
return CreatePlan(
|
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.FlyToObject,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||||
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
|
];
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
|
||||||
{
|
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||||
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
ShipOrderKinds.FollowShip,
|
|
||||||
summary,
|
|
||||||
[
|
|
||||||
CreateStep("step-follow", "follow-target", summary,
|
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||||
])
|
];
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
|
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||||
{
|
{
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
sourceKind,
|
|
||||||
sourceId,
|
|
||||||
Idle,
|
|
||||||
summary,
|
|
||||||
[
|
[
|
||||||
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
|
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
|
||||||
|
{
|
||||||
|
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||||
|
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||||
|
return
|
||||||
[
|
[
|
||||||
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
|
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 CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
|
||||||
{
|
{
|
||||||
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
|
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||||
subTask.Status = WorkStatus.Blocked;
|
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||||
subTask.BlockingReason = blockingReason;
|
return
|
||||||
|
[
|
||||||
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
|
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||||
step.Status = AiPlanStepStatus.Blocked;
|
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),
|
||||||
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(
|
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
|
||||||
ShipRuntime ship,
|
|
||||||
AiPlanSourceKind sourceKind,
|
|
||||||
string sourceId,
|
|
||||||
string kind,
|
|
||||||
string summary,
|
|
||||||
IReadOnlyList<ShipPlanStepRuntime> steps)
|
|
||||||
{
|
{
|
||||||
var plan = new ShipPlanRuntime
|
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||||
{
|
return
|
||||||
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
|
[
|
||||||
SourceKind = sourceKind,
|
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||||
SourceId = sourceId,
|
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||||
Kind = kind,
|
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||||
Summary = summary,
|
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f),
|
||||||
};
|
];
|
||||||
plan.Steps.AddRange(steps);
|
|
||||||
return plan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
|
||||||
{
|
{
|
||||||
var step = new ShipPlanStepRuntime
|
return
|
||||||
{
|
[
|
||||||
Id = id,
|
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||||
Kind = kind,
|
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||||
Summary = summary,
|
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),
|
||||||
step.SubTasks.AddRange(subTasks);
|
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),
|
||||||
return step;
|
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShipSubTaskRuntime CreateSubTask(
|
private static ShipSubTaskRuntime CreateSubTask(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
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.InfrastructureSimulationService;
|
||||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||||
|
|
||||||
@@ -7,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
|
|||||||
|
|
||||||
public sealed partial class ShipAiService
|
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);
|
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||||
if (policy is null)
|
if (policy is null)
|
||||||
@@ -37,86 +36,75 @@ public sealed partial class ShipAiService
|
|||||||
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
var plan = new ShipPlanRuntime
|
return new ShipOrderRuntime
|
||||||
{
|
{
|
||||||
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}",
|
Id = $"rule-{ship.Id}-flee",
|
||||||
SourceKind = AiPlanSourceKind.Rule,
|
Kind = ShipOrderKinds.Flee,
|
||||||
|
SourceKind = ShipOrderSourceKind.Behavior,
|
||||||
SourceId = ShipOrderKinds.Flee,
|
SourceId = ShipOrderKinds.Flee,
|
||||||
Kind = "safety-flee",
|
Priority = 1000,
|
||||||
Summary = "Emergency retreat",
|
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",
|
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||||
[
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
return order.Kind switch
|
return order.Kind switch
|
||||||
{
|
{
|
||||||
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order),
|
var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
|
||||||
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order),
|
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
|
||||||
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order),
|
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(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) => BuildFlyToObjectOrderSubTasks(world, 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) => BuildFollowShipOrderSubTasks(world, 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) => BuildTradeOrderSubTasks(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) => BuildMineOrderSubTasks(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) => BuildMineLocalOrderSubTasks(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) => BuildMineAndDeliverRunOrderSubTasks(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) => BuildSellMinedCargoOrderSubTasks(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) => BuildSupplyFleetOrderSubTasks(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) => BuildAutoSalvageOrderSubTasks(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) => BuildBuildOrderSubTasks(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) => BuildAttackOrderSubTasks(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) => BuildHoldSubTasks(ship, order),
|
||||||
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
|
|
||||||
_ => null,
|
_ => 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);
|
var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||||
return assignment is null
|
if (safeStation is null)
|
||||||
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
{
|
||||||
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
return
|
||||||
|
[
|
||||||
|
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
|
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 static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||||
{
|
{
|
||||||
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
||||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
AiPlanSourceKind.Order,
|
|
||||||
order.Id,
|
|
||||||
ShipOrderKinds.Move,
|
|
||||||
order.Label ?? "Move order",
|
|
||||||
[
|
[
|
||||||
CreateStep("step-move", "travel", order.Label ?? "Travel",
|
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f),
|
||||||
[
|
];
|
||||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 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);
|
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||||
if (station is null)
|
if (station is null)
|
||||||
@@ -125,25 +113,14 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreatePlan(
|
return
|
||||||
ship,
|
|
||||||
AiPlanSourceKind.Order,
|
|
||||||
order.Id,
|
|
||||||
"dock-at-station",
|
|
||||||
order.Label ?? $"Dock at {station.Label}",
|
|
||||||
[
|
[
|
||||||
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
|
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f),
|
||||||
[
|
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)
|
];
|
||||||
]),
|
|
||||||
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
|
|
||||||
[
|
|
||||||
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
private IReadOnlyList<ShipSubTaskRuntime>? BuildTradeOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||||
{
|
{
|
||||||
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
|
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 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 systemId = order.TargetSystemId ?? ship.SystemId;
|
||||||
var itemId = order.ItemId;
|
var itemId = order.ItemId;
|
||||||
@@ -198,10 +175,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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 anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||||
var node = ResolveNode(world, order.TargetEntityId)
|
var node = ResolveNode(world, order.TargetEntityId)
|
||||||
@@ -212,10 +189,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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 anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||||
var node = ResolveNode(world, order.TargetEntityId)
|
var node = ResolveNode(world, order.TargetEntityId)
|
||||||
@@ -229,10 +206,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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);
|
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
|
||||||
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
|
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||||
@@ -241,10 +218,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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 homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
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));
|
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
|
||||||
return CreatePlan(
|
return BuildSalvageSubTasks(ship, wreck, homeStation, approach);
|
||||||
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),
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 sourceStation = ResolveStation(world, order.SourceStationId);
|
||||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||||
@@ -303,10 +261,10 @@ public sealed partial class ShipAiService
|
|||||||
amount,
|
amount,
|
||||||
MathF.Max(16f, order.Radius),
|
MathF.Max(16f, order.Radius),
|
||||||
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
|
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));
|
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
|
||||||
if (site is null)
|
if (site is null)
|
||||||
@@ -322,10 +280,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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;
|
var targetId = order.TargetEntityId;
|
||||||
if (targetId is null)
|
if (targetId is null)
|
||||||
@@ -334,45 +292,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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)
|
private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, 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)
|
|
||||||
{
|
{
|
||||||
var targetEntityId = order.TargetEntityId;
|
var targetEntityId = order.TargetEntityId;
|
||||||
if (targetEntityId is null)
|
if (targetEntityId is null)
|
||||||
@@ -388,10 +311,10 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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);
|
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||||
if (targetShip is null)
|
if (targetShip is null)
|
||||||
@@ -400,71 +323,6 @@ public sealed partial class ShipAiService
|
|||||||
return null;
|
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}");
|
return BuildFollowShipSubTasks(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)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,191 +26,195 @@ public sealed partial class ShipAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var previousState = ship.State;
|
var previousState = ship.State;
|
||||||
var previousPlanId = ship.ActivePlan?.Id;
|
var previousOrderId = ship.ActiveOrderId;
|
||||||
var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
var previousTaskId = GetCurrentSubTask(ship)?.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
SyncEmergencyOrders(world, ship);
|
||||||
SyncBehaviorOrders(world, ship);
|
SyncBehaviorOrders(world, ship);
|
||||||
var topOrder = GetTopOrder(ship);
|
EnsureOrderExecution(world, ship, events);
|
||||||
if (topOrder is not null && topOrder.Status == OrderStatus.Queued)
|
ExecuteOrder(world, ship, deltaSeconds, events);
|
||||||
{
|
TrackHistory(ship);
|
||||||
topOrder.Status = OrderStatus.Active;
|
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
|
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||||
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
|
{
|
||||||
var currentPlan = ship.ActivePlan;
|
var currentOrder = ship.OrderQueue.GetCurrentOrder();
|
||||||
|
if (currentOrder is null)
|
||||||
|
{
|
||||||
|
ClearActiveOrder(ship);
|
||||||
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentPlan is not null
|
if (currentOrder.Status == OrderStatus.Queued)
|
||||||
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
|
{
|
||||||
&& currentPlan.SourceKind == desiredSourceKind
|
currentOrder.Status = OrderStatus.Active;
|
||||||
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
|
}
|
||||||
&& !ship.NeedsReplan)
|
|
||||||
|
if (!ship.NeedsReplan
|
||||||
|
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
|
||||||
|
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
|
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
|
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
|
||||||
? BuildOrderPlan(world, ship, topOrder!)
|
if (subTasks is null || subTasks.Count == 0)
|
||||||
: BuildBehaviorFallbackPlan(world, ship);
|
|
||||||
|
|
||||||
if (nextPlan is null)
|
|
||||||
{
|
{
|
||||||
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
|
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
|
||||||
}
|
ClearActiveOrder(ship);
|
||||||
|
ship.NeedsReplan = true;
|
||||||
if (nextPlan.Kind != Idle)
|
ship.ReplanCooldownSeconds = 0.1f;
|
||||||
{
|
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
|
||||||
ship.LastAccessFailureReason = null;
|
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||||
}
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
|
|
||||||
ReplacePlan(ship, nextPlan, "replanned", events);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
|
||||||
{
|
|
||||||
var plan = ship.ActivePlan;
|
|
||||||
if (plan is null)
|
|
||||||
{
|
|
||||||
ship.State = ShipState.Idle;
|
|
||||||
ship.TargetPosition = ship.Position;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
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)
|
||||||
{
|
{
|
||||||
CompletePlan(ship, plan, events);
|
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
|
||||||
|
if (order is null)
|
||||||
|
{
|
||||||
|
ClearActiveOrder(ship);
|
||||||
|
ApplyIdleOrBlockedState(world, ship);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||||
|
|
||||||
var step = plan.Steps[plan.CurrentStepIndex];
|
|
||||||
if (step.Status == AiPlanStepStatus.Planned)
|
|
||||||
{
|
{
|
||||||
step.Status = AiPlanStepStatus.Running;
|
CompleteOrderExecution(ship, order, events);
|
||||||
}
|
|
||||||
|
|
||||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
|
||||||
{
|
|
||||||
CompleteStep(plan, step);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
|
var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||||
if (subTask.Status == WorkStatus.Pending)
|
if (subTask.Status == WorkStatus.Pending)
|
||||||
{
|
{
|
||||||
subTask.Status = WorkStatus.Active;
|
subTask.Status = WorkStatus.Active;
|
||||||
}
|
}
|
||||||
else if (subTask.Status == WorkStatus.Blocked)
|
else if (subTask.Status == WorkStatus.Blocked)
|
||||||
{
|
{
|
||||||
step.Status = AiPlanStepStatus.Blocked;
|
|
||||||
step.BlockingReason = subTask.BlockingReason;
|
|
||||||
plan.Status = AiPlanStatus.Blocked;
|
|
||||||
ship.State = ShipState.Blocked;
|
ship.State = ShipState.Blocked;
|
||||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.Status = AiPlanStatus.Running;
|
var outcome = UpdateSubTask(world, ship, subTask, deltaSeconds);
|
||||||
|
|
||||||
var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds);
|
|
||||||
switch (outcome)
|
switch (outcome)
|
||||||
{
|
{
|
||||||
case SubTaskOutcome.Active:
|
case SubTaskOutcome.Active:
|
||||||
step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running;
|
|
||||||
plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running;
|
|
||||||
return;
|
return;
|
||||||
case SubTaskOutcome.Completed:
|
case SubTaskOutcome.Completed:
|
||||||
subTask.Status = WorkStatus.Completed;
|
subTask.Status = WorkStatus.Completed;
|
||||||
subTask.Progress = 1f;
|
subTask.Progress = 1f;
|
||||||
step.CurrentSubTaskIndex += 1;
|
ship.ActiveSubTaskIndex += 1;
|
||||||
step.BlockingReason = null;
|
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
|
||||||
{
|
{
|
||||||
CompleteStep(plan, step);
|
CompleteOrderExecution(ship, order, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
case SubTaskOutcome.Failed:
|
case SubTaskOutcome.Failed:
|
||||||
subTask.Status = WorkStatus.Failed;
|
subTask.Status = WorkStatus.Failed;
|
||||||
step.Status = AiPlanStepStatus.Failed;
|
FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events);
|
||||||
plan.Status = AiPlanStatus.Failed;
|
|
||||||
plan.FailureReason = subTask.BlockingReason ?? "subtask-failed";
|
|
||||||
ship.NeedsReplan = true;
|
|
||||||
ship.ReplanCooldownSeconds = 0.5f;
|
|
||||||
ship.LastReplanReason = plan.FailureReason;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step)
|
private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
||||||
{
|
{
|
||||||
step.Status = AiPlanStepStatus.Completed;
|
ship.ActiveOrderId = order.Id;
|
||||||
step.BlockingReason = null;
|
ship.ActiveSubTaskIndex = 0;
|
||||||
plan.CurrentStepIndex += 1;
|
ship.ActiveSubTasks.Clear();
|
||||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
ship.ActiveSubTasks.AddRange(subTasks);
|
||||||
{
|
ship.NeedsReplan = false;
|
||||||
plan.Status = AiPlanStatus.Completed;
|
ship.ReplanCooldownSeconds = 0f;
|
||||||
}
|
ship.LastReplanReason = "order-execution-started";
|
||||||
|
ship.LastDeltaSignature = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
|
private static void ClearActiveOrder(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
plan.Status = AiPlanStatus.Completed;
|
ship.ActiveOrderId = null;
|
||||||
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
|
ship.ActiveSubTaskIndex = 0;
|
||||||
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
|
ship.ActiveSubTasks.Clear();
|
||||||
: null;
|
}
|
||||||
if (completedOrder is not null)
|
|
||||||
|
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
completedOrder.Status = OrderStatus.Completed;
|
ship.OrderQueue.TryCompleteOrder(order.Id);
|
||||||
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
|
if (order.SourceKind == ShipOrderSourceKind.Behavior
|
||||||
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
|
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||||
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
|
|
||||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||||
{
|
{
|
||||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ship.ActivePlan = null;
|
ClearActiveOrder(ship);
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.ReplanCooldownSeconds = 0.25f;
|
ship.ReplanCooldownSeconds = 0.25f;
|
||||||
ship.LastReplanReason = "plan-completed";
|
ship.LastReplanReason = "order-completed";
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
|
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 ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
|
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
|
FailOrder(ship, order, failureReason);
|
||||||
{
|
ClearActiveOrder(ship);
|
||||||
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
|
ship.NeedsReplan = true;
|
||||||
ship.ActivePlan.InterruptReason = reason;
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.ActivePlan = nextPlan;
|
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
|
||||||
ship.NeedsReplan = false;
|
{
|
||||||
ship.ReplanCooldownSeconds = 0f;
|
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
|
||||||
ship.LastReplanReason = reason;
|
ship.LastDeltaSignature = string.Empty;
|
||||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal file
31
apps/backend/Ships/Api/ReorderShipOrderHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal file
39
apps/backend/Ships/Api/UpdateShipOrderHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,28 @@ public sealed record ShipOrderCommandRequest(
|
|||||||
int? MaxSystemRange,
|
int? MaxSystemRange,
|
||||||
bool? KnownStationsOnly);
|
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(
|
public sealed record ShipOrderTemplateCommandRequest(
|
||||||
string Kind,
|
string Kind,
|
||||||
string? Label,
|
string? Label,
|
||||||
|
|||||||
@@ -108,29 +108,6 @@ public sealed record ShipSubTaskSnapshot(
|
|||||||
float TotalSeconds,
|
float TotalSeconds,
|
||||||
string? BlockingReason);
|
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(
|
public sealed record ShipSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -146,8 +123,6 @@ public sealed record ShipSnapshot(
|
|||||||
DefaultBehaviorSnapshot DefaultBehavior,
|
DefaultBehaviorSnapshot DefaultBehavior,
|
||||||
ShipAssignmentSnapshot? Assignment,
|
ShipAssignmentSnapshot? Assignment,
|
||||||
ShipSkillProfileSnapshot Skills,
|
ShipSkillProfileSnapshot Skills,
|
||||||
ShipPlanSnapshot? ActivePlan,
|
|
||||||
string? CurrentStepId,
|
|
||||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||||
string ControlSourceKind,
|
string ControlSourceKind,
|
||||||
string? ControlSourceId,
|
string? ControlSourceId,
|
||||||
@@ -182,8 +157,6 @@ public sealed record ShipDelta(
|
|||||||
DefaultBehaviorSnapshot DefaultBehavior,
|
DefaultBehaviorSnapshot DefaultBehavior,
|
||||||
ShipAssignmentSnapshot? Assignment,
|
ShipAssignmentSnapshot? Assignment,
|
||||||
ShipSkillProfileSnapshot Skills,
|
ShipSkillProfileSnapshot Skills,
|
||||||
ShipPlanSnapshot? ActivePlan,
|
|
||||||
string? CurrentStepId,
|
|
||||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||||
string ControlSourceKind,
|
string ControlSourceKind,
|
||||||
string? ControlSourceId,
|
string? ControlSourceId,
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ public sealed class ShipRuntime
|
|||||||
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||||
public ShipState State { get; set; } = ShipState.Idle;
|
public ShipState State { get; set; } = ShipState.Idle;
|
||||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||||
public List<ShipOrderRuntime> OrderQueue { get; } = [];
|
public ShipOrderQueue OrderQueue { get; } = new();
|
||||||
public ShipPlanRuntime? ActivePlan { get; set; }
|
|
||||||
public required ShipSkillProfileRuntime Skills { get; set; }
|
public required ShipSkillProfileRuntime Skills { get; set; }
|
||||||
public bool NeedsReplan { get; set; } = true;
|
public bool NeedsReplan { get; set; } = true;
|
||||||
public float ReplanCooldownSeconds { get; set; }
|
public float ReplanCooldownSeconds { get; set; }
|
||||||
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
|
|||||||
public float Health { get; set; }
|
public float Health { get; set; }
|
||||||
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
||||||
public List<string> History { get; } = [];
|
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 LastSignature { get; set; } = string.Empty;
|
||||||
public string LastDeltaSignature { 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 sealed class ShipSkillProfileRuntime
|
||||||
{
|
{
|
||||||
public int Navigation { get; set; }
|
public int Navigation { get; set; }
|
||||||
@@ -111,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
|
|||||||
public bool KnownStationsOnly { get; set; }
|
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 sealed class ShipSubTaskRuntime
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
|
|||||||
@@ -201,8 +201,6 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.DefaultBehavior,
|
ship.DefaultBehavior,
|
||||||
ship.Assignment,
|
ship.Assignment,
|
||||||
ship.Skills,
|
ship.Skills,
|
||||||
ship.ActivePlan,
|
|
||||||
ship.CurrentStepId,
|
|
||||||
ship.ActiveSubTasks,
|
ship.ActiveSubTasks,
|
||||||
ship.ControlSourceKind,
|
ship.ControlSourceKind,
|
||||||
ship.ControlSourceId,
|
ship.ControlSourceId,
|
||||||
@@ -569,9 +567,6 @@ internal sealed class SimulationProjectionService
|
|||||||
ship.TargetPosition.Z.ToString("0.###"),
|
ship.TargetPosition.Z.ToString("0.###"),
|
||||||
ship.State.ToContractValue(),
|
ship.State.ToContractValue(),
|
||||||
string.Join(",", ship.OrderQueue
|
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}")),
|
.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.Kind,
|
||||||
ship.DefaultBehavior.TargetEntityId ?? "none",
|
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
|
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}"
|
? $"{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",
|
: "no-assignment",
|
||||||
ship.ActivePlan?.Kind ?? "none",
|
|
||||||
ship.ActivePlan?.Status.ToContractValue() ?? "none",
|
|
||||||
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
|
|
||||||
string.Join(",",
|
string.Join(",",
|
||||||
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
||||||
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
|
$"{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.Combat.ToString(CultureInfo.InvariantCulture),
|
||||||
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
||||||
ship.Health.ToString("0.###"),
|
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) =>
|
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||||
string.Join(",",
|
string.Join(",",
|
||||||
@@ -889,8 +883,6 @@ internal sealed class SimulationProjectionService
|
|||||||
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
||||||
ToShipAssignmentSnapshot(commander),
|
ToShipAssignmentSnapshot(commander),
|
||||||
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
|
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),
|
ToActiveSubTaskSnapshots(ship),
|
||||||
ship.ControlSourceKind,
|
ship.ControlSourceKind,
|
||||||
ship.ControlSourceId,
|
ship.ControlSourceId,
|
||||||
@@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService
|
|||||||
{
|
{
|
||||||
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
||||||
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/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) =>
|
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
||||||
ship.OrderQueue
|
ship.OrderQueue
|
||||||
.OrderByDescending(GetOrderSourcePriority)
|
|
||||||
.ThenByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => new ShipOrderSnapshot(
|
.Select(order => new ShipOrderSnapshot(
|
||||||
order.Id,
|
order.Id,
|
||||||
order.Kind,
|
order.Kind,
|
||||||
@@ -965,14 +954,6 @@ internal sealed class SimulationProjectionService
|
|||||||
order.FailureReason))
|
order.FailureReason))
|
||||||
.ToList();
|
.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) =>
|
private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
|
||||||
new(
|
new(
|
||||||
behavior.Kind,
|
behavior.Kind,
|
||||||
@@ -1039,38 +1020,6 @@ internal sealed class SimulationProjectionService
|
|||||||
assignment.UpdatedAtUtc);
|
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) =>
|
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
|
||||||
new(
|
new(
|
||||||
subTask.Id,
|
subTask.Id,
|
||||||
@@ -1094,23 +1043,12 @@ internal sealed class SimulationProjectionService
|
|||||||
|
|
||||||
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var step = GetCurrentShipStep(ship);
|
return ship.ActiveSubTasks
|
||||||
if (step is null)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return step.SubTasks
|
|
||||||
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
||||||
.Select(ToShipSubTaskSnapshot)
|
.Select(ToShipSubTaskSnapshot)
|
||||||
.ToList();
|
.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)
|
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
|
||||||
{
|
{
|
||||||
var assignment = commander.Assignment;
|
var assignment = commander.Assignment;
|
||||||
|
|||||||
@@ -314,24 +314,28 @@ public sealed class SpatialBuilder
|
|||||||
ResourceNodeDefinition definition,
|
ResourceNodeDefinition definition,
|
||||||
float oreAmount)
|
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 deposits = new List<ResourceDepositRuntime>(depositCount);
|
||||||
var weightTotal = 0f;
|
var weightTotal = 0f;
|
||||||
var weights = new float[depositCount];
|
var weights = new float[depositCount];
|
||||||
|
var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits"));
|
||||||
for (var index = 0; index < depositCount; index += 1)
|
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;
|
weights[index] = weight;
|
||||||
weightTotal += 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)
|
for (var index = 0; index < depositCount; index += 1)
|
||||||
{
|
{
|
||||||
var angle = Hash01(systemId, nodeId, $"angle-{index}") * MathF.PI * 2f;
|
var angle = NextFloat01(random) * MathF.PI * 2f;
|
||||||
var radiusFactor = 0.22f + (Hash01(systemId, nodeId, $"radius-{index}") * 0.74f);
|
var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f);
|
||||||
var radius = scatterRadius * MathF.Sqrt(radiusFactor);
|
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(
|
var localPosition = new Vector3(
|
||||||
MathF.Cos(angle) * radius,
|
MathF.Cos(angle) * radius,
|
||||||
vertical,
|
vertical,
|
||||||
@@ -351,6 +355,32 @@ public sealed class SpatialBuilder
|
|||||||
return deposits;
|
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)
|
private static float Hash01(string systemId, string nodeId, string salt)
|
||||||
{
|
{
|
||||||
unchecked
|
unchecked
|
||||||
@@ -391,13 +421,15 @@ public sealed class SpatialBuilder
|
|||||||
|
|
||||||
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<AnchorRuntime> anchors)
|
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<AnchorRuntime> anchors)
|
||||||
{
|
{
|
||||||
|
var systemPosition = SimulationUnits.MetersToKilometers(position);
|
||||||
var nearestAnchor = anchors
|
var nearestAnchor = anchors
|
||||||
.Where(anchor => anchor.SystemId == systemId)
|
.Where(anchor => anchor.SystemId == systemId)
|
||||||
.OrderBy(anchor => anchor.Position.DistanceTo(position))
|
.OrderBy(anchor => anchor.Position.DistanceTo(systemPosition))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
var localPosition = nearestAnchor is null
|
var localPosition = position;
|
||||||
? position
|
var resolvedSystemPosition = nearestAnchor is null
|
||||||
: position.Subtract(nearestAnchor.Position);
|
? systemPosition
|
||||||
|
: Add(nearestAnchor.Position, SimulationUnits.MetersToKilometers(localPosition));
|
||||||
|
|
||||||
return new ShipSpatialStateRuntime
|
return new ShipSpatialStateRuntime
|
||||||
{
|
{
|
||||||
@@ -405,7 +437,7 @@ public sealed class SpatialBuilder
|
|||||||
SpaceLayer = SpaceLayerKind.LocalSpace,
|
SpaceLayer = SpaceLayerKind.LocalSpace,
|
||||||
CurrentAnchorId = nearestAnchor?.Id,
|
CurrentAnchorId = nearestAnchor?.Id,
|
||||||
LocalPosition = localPosition,
|
LocalPosition = localPosition,
|
||||||
SystemPosition = position,
|
SystemPosition = resolvedSystemPosition,
|
||||||
MovementRegime = MovementRegimeKind.LocalFlight,
|
MovementRegime = MovementRegimeKind.LocalFlight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ public sealed class WorldBuilder(
|
|||||||
WorldGenerationOptions worldGenerationOptions,
|
WorldGenerationOptions worldGenerationOptions,
|
||||||
ScenarioDefinition? scenarioDefinition)
|
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);
|
var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems);
|
||||||
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal));
|
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder(
|
|||||||
generationService.PrepareKnownSystems(staticData.KnownSystems),
|
generationService.PrepareKnownSystems(staticData.KnownSystems),
|
||||||
worldGenerationOptions);
|
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
|
var systemRuntimes = systems
|
||||||
.Select(definition => new SystemRuntime
|
.Select(definition => new SystemRuntime
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -308,9 +308,10 @@ internal sealed class OrbitalStateUpdater
|
|||||||
}
|
}
|
||||||
|
|
||||||
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||||
|
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||||
? ship.Position
|
? localSystemOffset
|
||||||
: Add(currentAnchor.Position, ship.Position);
|
: Add(currentAnchor.Position, localSystemOffset);
|
||||||
|
|
||||||
if (ship.DockedStationId is null)
|
if (ship.DockedStationId is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
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)
|
private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
|
||||||
{
|
{
|
||||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||||
@@ -702,12 +759,7 @@ public sealed class WorldService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.OrderQueue.Count >= 8)
|
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Order queue is full.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
|
||||||
{
|
{
|
||||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||||
Kind = request.Kind,
|
Kind = request.Kind,
|
||||||
@@ -732,12 +784,7 @@ public sealed class WorldService
|
|||||||
});
|
});
|
||||||
|
|
||||||
ship.ControlSourceKind = "gm-order";
|
ship.ControlSourceKind = "gm-order";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
||||||
.OrderByDescending(order => order.Priority)
|
|
||||||
.ThenBy(order => order.CreatedAtUtc)
|
|
||||||
.Select(order => order.Id)
|
|
||||||
.FirstOrDefault();
|
|
||||||
ship.ControlReason = request.Label ?? request.Kind;
|
ship.ControlReason = request.Label ?? request.Kind;
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "gm-order-enqueued";
|
ship.LastReplanReason = "gm-order-enqueued";
|
||||||
@@ -753,22 +800,12 @@ public sealed class WorldService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
ship.OrderQueue.RemoveById(orderId);
|
||||||
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||||
? "gm-order"
|
? "gm-order"
|
||||||
: "gm-manual";
|
: "gm-manual";
|
||||||
ship.ControlSourceId = ship.OrderQueue
|
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(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()
|
|
||||||
?? "manual-gm-control";
|
?? "manual-gm-control";
|
||||||
ship.NeedsReplan = true;
|
ship.NeedsReplan = true;
|
||||||
ship.LastReplanReason = "gm-order-removed";
|
ship.LastReplanReason = "gm-order-removed";
|
||||||
@@ -776,6 +813,59 @@ public sealed class WorldService
|
|||||||
return ship;
|
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)
|
private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||||
{
|
{
|
||||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||||
@@ -837,6 +927,26 @@ public sealed class WorldService
|
|||||||
return ship;
|
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()
|
private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
|
||||||
{
|
{
|
||||||
Id = $"commander-faction-{faction.Id}",
|
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);
|
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) =>
|
private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position)
|
||||||
_world.Anchors
|
{
|
||||||
|
var systemPosition = SimulationUnits.MetersToKilometers(position);
|
||||||
|
return _world.Anchors
|
||||||
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
|
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
|
||||||
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
|
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
|
||||||
.OrderBy(candidate => candidate.Position.DistanceTo(position))
|
.OrderBy(candidate => candidate.Position.DistanceTo(systemPosition))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
private string? ResolveNearestAnchorId(string systemId, Vector3 position) =>
|
private string? ResolveNearestAnchorId(string systemId, Vector3 position) =>
|
||||||
ResolveNearestConstructibleAnchor(systemId, position)?.Id;
|
ResolveNearestConstructibleAnchor(systemId, position)?.Id;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import {
|
import {
|
||||||
|
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
|
||||||
|
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
|
||||||
|
LOCAL_SYSTEM_BACKDROP_DISTANCE,
|
||||||
MAX_CAMERA_DISTANCE,
|
MAX_CAMERA_DISTANCE,
|
||||||
MIN_CAMERA_DISTANCE,
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MIN_LOCAL_CAMERA_DISTANCE,
|
||||||
NAV_DISTANCE,
|
NAV_DISTANCE,
|
||||||
} from "./viewerConstants";
|
} from "./viewerConstants";
|
||||||
import { updatePanFromKeyboard } from "./viewerCamera";
|
import { updatePanFromKeyboard } from "./viewerCamera";
|
||||||
@@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer";
|
|||||||
import { LocalLayer } from "./viewerLocalLayer";
|
import { LocalLayer } from "./viewerLocalLayer";
|
||||||
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
||||||
import { describeSelectable } from "./viewerSelection";
|
import { describeSelectable } from "./viewerSelection";
|
||||||
|
import { resolveLocalAnchorOffset } from "./viewerWorldPresentation";
|
||||||
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||||
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
||||||
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
||||||
@@ -88,6 +93,7 @@ export class ViewerAppController {
|
|||||||
private selectedItems: Selectable[] = [];
|
private selectedItems: Selectable[] = [];
|
||||||
private worldSignature = "";
|
private worldSignature = "";
|
||||||
private povLevel: PovLevel = "system";
|
private povLevel: PovLevel = "system";
|
||||||
|
private previousPovLevel: PovLevel = "system";
|
||||||
private currentDistance = NAV_DISTANCE.system;
|
private currentDistance = NAV_DISTANCE.system;
|
||||||
private desiredDistance = NAV_DISTANCE.system;
|
private desiredDistance = NAV_DISTANCE.system;
|
||||||
private orbitYaw = -2.3;
|
private orbitYaw = -2.3;
|
||||||
@@ -100,6 +106,7 @@ export class ViewerAppController {
|
|||||||
private marqueeActive = false;
|
private marqueeActive = false;
|
||||||
private suppressClickSelection = false;
|
private suppressClickSelection = false;
|
||||||
private activeSystemId?: string;
|
private activeSystemId?: string;
|
||||||
|
private cameraFocusedAnchorId?: string;
|
||||||
private cameraTargetShipId?: string;
|
private cameraTargetShipId?: string;
|
||||||
private readonly followCameraPosition = new THREE.Vector3();
|
private readonly followCameraPosition = new THREE.Vector3();
|
||||||
private readonly followCameraFocus = new THREE.Vector3();
|
private readonly followCameraFocus = new THREE.Vector3();
|
||||||
@@ -262,15 +269,34 @@ export class ViewerAppController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeOrbitOffset(): THREE.Vector3 {
|
private computeOrbitOffset(cameraDistance: number): THREE.Vector3 {
|
||||||
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
|
const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch);
|
||||||
return new THREE.Vector3(
|
return new THREE.Vector3(
|
||||||
Math.cos(this.orbitYaw) * horizontalDistance,
|
Math.cos(this.orbitYaw) * horizontalDistance,
|
||||||
this.currentDistance * Math.sin(this.orbitPitch),
|
cameraDistance * Math.sin(this.orbitPitch),
|
||||||
Math.sin(this.orbitYaw) * horizontalDistance,
|
Math.sin(this.orbitYaw) * horizontalDistance,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLocalOrbitCameraDistance() {
|
||||||
|
const clamped = THREE.MathUtils.clamp(this.currentDistance, MIN_LOCAL_CAMERA_DISTANCE, 650);
|
||||||
|
return THREE.MathUtils.mapLinear(
|
||||||
|
clamped,
|
||||||
|
MIN_LOCAL_CAMERA_DISTANCE,
|
||||||
|
650,
|
||||||
|
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
|
||||||
|
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSystemOrbitCameraDistance() {
|
||||||
|
if (this.povLevel !== "local") {
|
||||||
|
return this.currentDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LOCAL_SYSTEM_BACKDROP_DISTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
private updateCamera(delta: number) {
|
private updateCamera(delta: number) {
|
||||||
const nextState = stepCamera({
|
const nextState = stepCamera({
|
||||||
currentDistance: this.currentDistance,
|
currentDistance: this.currentDistance,
|
||||||
@@ -279,6 +305,7 @@ export class ViewerAppController {
|
|||||||
delta,
|
delta,
|
||||||
});
|
});
|
||||||
this.currentDistance = nextState.currentDistance;
|
this.currentDistance = nextState.currentDistance;
|
||||||
|
this.previousPovLevel = this.povLevel;
|
||||||
this.povLevel = nextState.povLevel;
|
this.povLevel = nextState.povLevel;
|
||||||
this.orbitPitch = nextState.orbitPitch;
|
this.orbitPitch = nextState.orbitPitch;
|
||||||
if (this.sceneStore.povLevel !== this.povLevel) {
|
if (this.sceneStore.povLevel !== this.povLevel) {
|
||||||
@@ -286,27 +313,29 @@ export class ViewerAppController {
|
|||||||
}
|
}
|
||||||
this.navigationController.updateActiveSystem();
|
this.navigationController.updateActiveSystem();
|
||||||
this.navigationController.syncGalaxyAnchorToActiveSystem();
|
this.navigationController.syncGalaxyAnchorToActiveSystem();
|
||||||
|
this.updateCameraFocusedAnchor();
|
||||||
|
|
||||||
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
||||||
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
|
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
|
||||||
// Still update galaxy camera independently.
|
// Still update galaxy camera independently.
|
||||||
const orbitOffset = this.computeOrbitOffset();
|
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
|
||||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updatePanFromKeyboard(delta);
|
this.updatePanFromKeyboard(delta);
|
||||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
||||||
|
|
||||||
const orbitOffset = this.computeOrbitOffset();
|
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
|
||||||
|
const localOrbitOffset = this.computeOrbitOffset(this.resolveLocalOrbitCameraDistance());
|
||||||
|
|
||||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
|
||||||
|
|
||||||
if (this.activeSystemId) {
|
if (this.activeSystemId) {
|
||||||
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
|
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), systemOrbitOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.localLayer.updateCamera(orbitOffset);
|
this.localLayer.updateCamera(this.systemAnchor, localOrbitOffset, resolveLocalAnchorOffset(this.world, this.resolveFocusedAnchorId()));
|
||||||
|
|
||||||
// Update star dot scales in galaxy scene
|
// Update star dot scales in galaxy scene
|
||||||
updateSystemStarPresentation(
|
updateSystemStarPresentation(
|
||||||
@@ -353,7 +382,48 @@ export class ViewerAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveFocusedAnchorId() {
|
private resolveFocusedAnchorId() {
|
||||||
return resolveFocusedAnchorId(this.world, this.selectedItems);
|
return this.cameraFocusedAnchorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCameraFocusedAnchor() {
|
||||||
|
if (!this.world || !this.activeSystemId || this.povLevel === "galaxy") {
|
||||||
|
this.cameraFocusedAnchorId = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.povLevel === "system") {
|
||||||
|
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previousPovLevel !== "local" || !this.cameraFocusedAnchorId) {
|
||||||
|
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus() ?? this.cameraFocusedAnchorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveNearestAnchorToSystemFocus() {
|
||||||
|
if (!this.world || !this.activeSystemId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestAnchorId: string | undefined;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (const anchor of this.world.anchors.values()) {
|
||||||
|
if (anchor.systemId !== this.activeSystemId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = anchor.systemPosition.x - this.systemAnchor.x;
|
||||||
|
const dy = anchor.systemPosition.y - this.systemAnchor.y;
|
||||||
|
const dz = anchor.systemPosition.z - this.systemAnchor.z;
|
||||||
|
const distanceSquared = (dx * dx) + (dy * dy) + (dz * dz);
|
||||||
|
if (distanceSquared < bestDistance) {
|
||||||
|
bestDistance = distanceSquared;
|
||||||
|
bestAnchorId = anchor.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestAnchorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onResize(width: number, height: number) {
|
private onResize(width: number, height: number) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ShipDefaultBehaviorCommandRequest,
|
ShipDefaultBehaviorCommandRequest,
|
||||||
ShipOrderCommandRequest,
|
ShipOrderCommandRequest,
|
||||||
|
ShipOrderUpdateCommandRequest,
|
||||||
} from "./shipCommands";
|
} from "./shipCommands";
|
||||||
|
|
||||||
export interface WorldStreamScope {
|
export interface WorldStreamScope {
|
||||||
@@ -318,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateShipOrder(shipId: string, orderId: string, request: ShipOrderUpdateCommandRequest) {
|
||||||
|
return fetchJson<ShipSnapshot>(`/api/ships/${shipId}/orders/${orderId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,11 +160,11 @@ function shipAiStates(ship: ShipSnapshot) {
|
|||||||
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
||||||
const dockToken = ship.dockedStationId ? "DCK" : "";
|
const dockToken = ship.dockedStationId ? "DCK" : "";
|
||||||
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||||
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
const taskToken = ship.activeSubTasks.length > 0 ? "TSK" : "";
|
||||||
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||||
const commandToken = ship.commanderId ? "CMD" : "";
|
const commandToken = ship.commanderId ? "CMD" : "";
|
||||||
|
|
||||||
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stationAiStates(station: StationSnapshot) {
|
function stationAiStates(station: StationSnapshot) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { computed, reactive, ref, watch } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import modulesData from "../../../../shared/data/modules.json";
|
import modulesData from "../../../../shared/data/modules.json";
|
||||||
import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api";
|
import { removeShipOrder, updateShipDefaultBehavior, updateShipOrder } from "../api";
|
||||||
|
import type { ShipOrderSnapshot } from "../contractsShips";
|
||||||
import {
|
import {
|
||||||
formatShipAutomationSupportStatus,
|
formatShipAutomationSupportStatus,
|
||||||
getShipBehaviorLabel,
|
getShipBehaviorLabel,
|
||||||
@@ -43,16 +44,23 @@ const behaviorForm = reactive({
|
|||||||
areaSystemId: "",
|
areaSystemId: "",
|
||||||
itemId: "ore",
|
itemId: "ore",
|
||||||
});
|
});
|
||||||
|
|
||||||
const mineOrderForm = reactive({
|
|
||||||
systemId: "",
|
|
||||||
itemId: "ore",
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveOrderSystemId = ref("");
|
|
||||||
const actionBusy = ref(false);
|
const actionBusy = ref(false);
|
||||||
const actionStatus = ref("");
|
const actionStatus = ref("");
|
||||||
const actionError = ref("");
|
const actionError = ref("");
|
||||||
|
const expandedDirectOrderId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const orderEditForm = reactive({
|
||||||
|
label: "",
|
||||||
|
priority: "100",
|
||||||
|
interruptCurrentPlan: true,
|
||||||
|
targetSystemId: "",
|
||||||
|
targetEntityId: "",
|
||||||
|
itemId: "",
|
||||||
|
waitSeconds: "0",
|
||||||
|
radius: "0",
|
||||||
|
maxSystemRange: "",
|
||||||
|
knownStationsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
const moduleNameById = new Map<string, string>(
|
const moduleNameById = new Map<string, string>(
|
||||||
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]),
|
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]),
|
||||||
@@ -92,6 +100,34 @@ function joinDetail(parts: Array<string | null | undefined>) {
|
|||||||
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeOrderFailure(order: {
|
||||||
|
failureReason?: string | null;
|
||||||
|
kind: string;
|
||||||
|
itemId?: string | null;
|
||||||
|
}) {
|
||||||
|
switch (order.failureReason) {
|
||||||
|
case "mine-order-node-missing":
|
||||||
|
return `Cannot find ${order.itemId ?? "resource"} to mine`;
|
||||||
|
case "mine-order-item-missing":
|
||||||
|
return "No mining ware selected";
|
||||||
|
case "mine-order-node-system-mismatch":
|
||||||
|
return "Selected mining target is in the wrong system";
|
||||||
|
case "mine-order-node-item-mismatch":
|
||||||
|
return `Selected mining target does not provide ${order.itemId ?? "the requested ware"}`;
|
||||||
|
case "mine-order-incomplete":
|
||||||
|
case "mine-and-deliver-order-incomplete":
|
||||||
|
return `Cannot complete ${getShipOrderLabel(order.kind).toLowerCase()}`;
|
||||||
|
case "target-ship-missing":
|
||||||
|
return "Target ship no longer exists";
|
||||||
|
case "target-missing":
|
||||||
|
return "Target no longer exists";
|
||||||
|
case "station-missing":
|
||||||
|
return "Station no longer exists";
|
||||||
|
default:
|
||||||
|
return order.failureReason ? titleCase(order.failureReason) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function describeOrderTarget(order: {
|
function describeOrderTarget(order: {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
targetEntityId?: string | null;
|
targetEntityId?: string | null;
|
||||||
@@ -161,11 +197,7 @@ const canDirectControlSelectedShip = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const directOrders = computed(() =>
|
const directOrders = computed(() =>
|
||||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [],
|
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [],
|
||||||
);
|
|
||||||
|
|
||||||
const behaviorOrders = computed(() =>
|
|
||||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const editableBehaviorDefinitions = computed(() =>
|
const editableBehaviorDefinitions = computed(() =>
|
||||||
@@ -189,6 +221,10 @@ const formBehaviorNotes = computed(() =>
|
|||||||
getShipBehaviorNotes(behaviorForm.kind),
|
getShipBehaviorNotes(behaviorForm.kind),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const behaviorGeneratedOrderCount = computed(() =>
|
||||||
|
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior").length ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const shipStatusRows = computed(() => {
|
const shipStatusRows = computed(() => {
|
||||||
if (!selectedShip.value) {
|
if (!selectedShip.value) {
|
||||||
return [];
|
return [];
|
||||||
@@ -206,9 +242,9 @@ const shipStatusRows = computed(() => {
|
|||||||
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||||
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||||
{
|
{
|
||||||
label: "Plan",
|
label: "Activity",
|
||||||
value: selectedShip.value.activePlan
|
value: selectedShip.value.activeSubTasks[0]
|
||||||
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
? `${selectedShip.value.activeSubTasks[0].summary || titleCase(selectedShip.value.activeSubTasks[0].kind)} · ${titleCase(selectedShip.value.activeSubTasks[0].status)}`
|
||||||
: "none",
|
: "none",
|
||||||
},
|
},
|
||||||
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||||
@@ -260,53 +296,22 @@ const shipBehaviorRows = computed(() => {
|
|||||||
const directOrderRows = computed(() =>
|
const directOrderRows = computed(() =>
|
||||||
directOrders.value.map((order) => ({
|
directOrders.value.map((order) => ({
|
||||||
id: order.id,
|
id: order.id,
|
||||||
|
kind: order.kind,
|
||||||
label: getShipOrderLabel(order.kind),
|
label: getShipOrderLabel(order.kind),
|
||||||
status: titleCase(order.status),
|
status: titleCase(order.status),
|
||||||
target: describeOrderTarget(order),
|
target: describeOrderTarget(order),
|
||||||
detail: joinDetail([
|
detail: joinDetail([
|
||||||
`P${order.priority}`,
|
`P${order.priority}`,
|
||||||
titleCase(order.sourceKind),
|
titleCase(order.sourceKind),
|
||||||
order.failureReason ?? undefined,
|
describeOrderFailure(order) ?? undefined,
|
||||||
]),
|
]),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const behaviorOrderRows = computed(() =>
|
const shipPlanRows = computed(() =>
|
||||||
behaviorOrders.value.map((order) => ({
|
(selectedShip.value?.activeSubTasks ?? []).map((subTask) => ({
|
||||||
id: order.id,
|
|
||||||
label: getShipOrderLabel(order.kind),
|
|
||||||
status: titleCase(order.status),
|
|
||||||
target: describeOrderTarget(order),
|
|
||||||
detail: joinDetail([
|
|
||||||
`P${order.priority}`,
|
|
||||||
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
|
||||||
getShipOrderNotes(order.kind) ?? undefined,
|
|
||||||
order.failureReason ?? undefined,
|
|
||||||
]),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const shipPlanRows = computed(() => {
|
|
||||||
if (!selectedShip.value?.activePlan) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
|
||||||
const stepRow = {
|
|
||||||
id: step.id,
|
|
||||||
scope: "Step",
|
|
||||||
activity: step.summary || titleCase(step.kind),
|
|
||||||
status: titleCase(step.status),
|
|
||||||
detail: joinDetail([
|
|
||||||
step.blockingReason ?? undefined,
|
|
||||||
`${step.subTasks.length} subtasks`,
|
|
||||||
]),
|
|
||||||
isSubTask: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const subTaskRows = step.subTasks.map((subTask) => ({
|
|
||||||
id: subTask.id,
|
id: subTask.id,
|
||||||
scope: "Subtask",
|
scope: "Task",
|
||||||
activity: subTask.summary || titleCase(subTask.kind),
|
activity: subTask.summary || titleCase(subTask.kind),
|
||||||
status: titleCase(subTask.status),
|
status: titleCase(subTask.status),
|
||||||
detail: joinDetail([
|
detail: joinDetail([
|
||||||
@@ -314,12 +319,9 @@ const shipPlanRows = computed(() => {
|
|||||||
subTask.blockingReason ?? undefined,
|
subTask.blockingReason ?? undefined,
|
||||||
`${Math.round(subTask.progress * 100)}%`,
|
`${Math.round(subTask.progress * 100)}%`,
|
||||||
]),
|
]),
|
||||||
isSubTask: true,
|
isSubTask: false,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
return [stepRow, ...subTaskRows];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const stationStatusRows = computed(() => {
|
const stationStatusRows = computed(() => {
|
||||||
if (!selectedStation.value) {
|
if (!selectedStation.value) {
|
||||||
@@ -397,15 +399,116 @@ watch(
|
|||||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||||
mineOrderForm.systemId = ship.systemId ?? "";
|
|
||||||
mineOrderForm.itemId = "ore";
|
|
||||||
moveOrderSystemId.value = ship.systemId ?? "";
|
|
||||||
actionStatus.value = "";
|
actionStatus.value = "";
|
||||||
actionError.value = "";
|
actionError.value = "";
|
||||||
|
expandedDirectOrderId.value = null;
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
|
||||||
|
switch (field) {
|
||||||
|
case "targetSystemId":
|
||||||
|
return kind === "move" || kind === "mine-and-deliver";
|
||||||
|
case "targetEntityId":
|
||||||
|
return kind === "follow-ship" || kind === "attack-target";
|
||||||
|
case "itemId":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
case "waitSeconds":
|
||||||
|
return kind === "hold-position" || kind === "follow-ship";
|
||||||
|
case "radius":
|
||||||
|
return kind === "move" || kind === "follow-ship";
|
||||||
|
case "maxSystemRange":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
case "knownStationsOnly":
|
||||||
|
return kind === "mine-and-deliver";
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrderEditor(order: ShipOrderSnapshot) {
|
||||||
|
orderEditForm.label = order.label ?? "";
|
||||||
|
orderEditForm.priority = String(order.priority);
|
||||||
|
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
|
||||||
|
orderEditForm.targetSystemId = order.targetSystemId ?? "";
|
||||||
|
orderEditForm.targetEntityId = order.targetEntityId ?? "";
|
||||||
|
orderEditForm.itemId = order.itemId ?? "ore";
|
||||||
|
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
|
||||||
|
orderEditForm.radius = String(order.radius ?? 0);
|
||||||
|
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
|
||||||
|
orderEditForm.knownStationsOnly = order.knownStationsOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOrderEditor(order: ShipOrderSnapshot) {
|
||||||
|
if (expandedDirectOrderId.value === order.id) {
|
||||||
|
expandedDirectOrderId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOrderEditor(order);
|
||||||
|
expandedDirectOrderId.value = order.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInt(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOrder(order: ShipOrderSnapshot) {
|
||||||
|
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runShipAction(async () => {
|
||||||
|
const ship = await updateShipOrder(selectedShip.value!.id, order.id, {
|
||||||
|
kind: order.kind,
|
||||||
|
priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))),
|
||||||
|
interruptCurrentPlan: orderEditForm.interruptCurrentPlan,
|
||||||
|
label: orderEditForm.label.trim() || null,
|
||||||
|
targetEntityId: supportsOrderField(order.kind, "targetEntityId")
|
||||||
|
? (orderEditForm.targetEntityId.trim() || null)
|
||||||
|
: order.targetEntityId ?? null,
|
||||||
|
targetSystemId: supportsOrderField(order.kind, "targetSystemId")
|
||||||
|
? (orderEditForm.targetSystemId.trim() || null)
|
||||||
|
: order.targetSystemId ?? null,
|
||||||
|
targetPosition: order.targetPosition ?? null,
|
||||||
|
sourceStationId: order.sourceStationId ?? null,
|
||||||
|
destinationStationId: order.destinationStationId ?? null,
|
||||||
|
itemId: supportsOrderField(order.kind, "itemId")
|
||||||
|
? (orderEditForm.itemId.trim() || null)
|
||||||
|
: order.itemId ?? null,
|
||||||
|
anchorId: order.anchorId ?? null,
|
||||||
|
constructionSiteId: order.constructionSiteId ?? null,
|
||||||
|
moduleId: order.moduleId ?? null,
|
||||||
|
waitSeconds: supportsOrderField(order.kind, "waitSeconds")
|
||||||
|
? parseNumber(orderEditForm.waitSeconds, order.waitSeconds)
|
||||||
|
: order.waitSeconds,
|
||||||
|
radius: supportsOrderField(order.kind, "radius")
|
||||||
|
? parseNumber(orderEditForm.radius, order.radius)
|
||||||
|
: order.radius,
|
||||||
|
maxSystemRange: supportsOrderField(order.kind, "maxSystemRange")
|
||||||
|
? parseOptionalInt(orderEditForm.maxSystemRange)
|
||||||
|
: order.maxSystemRange ?? null,
|
||||||
|
knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly")
|
||||||
|
? orderEditForm.knownStationsOnly
|
||||||
|
: order.knownStationsOnly,
|
||||||
|
});
|
||||||
|
gmStore.upsertShip(ship);
|
||||||
|
expandedDirectOrderId.value = null;
|
||||||
|
}, "Order updated.");
|
||||||
|
}
|
||||||
|
|
||||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||||
if (!selectedShip.value) {
|
if (!selectedShip.value) {
|
||||||
return;
|
return;
|
||||||
@@ -468,114 +571,6 @@ async function saveBehavior() {
|
|||||||
}, "Default behavior updated.");
|
}, "Default behavior updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueHoldPositionOrder() {
|
|
||||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runShipAction(async () => {
|
|
||||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
|
||||||
kind: "hold-position",
|
|
||||||
priority: 100,
|
|
||||||
interruptCurrentPlan: true,
|
|
||||||
label: "Hold position",
|
|
||||||
targetEntityId: null,
|
|
||||||
targetSystemId: null,
|
|
||||||
targetPosition: null,
|
|
||||||
sourceStationId: null,
|
|
||||||
destinationStationId: null,
|
|
||||||
itemId: null,
|
|
||||||
anchorId: null,
|
|
||||||
constructionSiteId: null,
|
|
||||||
moduleId: null,
|
|
||||||
waitSeconds: 0,
|
|
||||||
radius: 0,
|
|
||||||
maxSystemRange: 0,
|
|
||||||
knownStationsOnly: false,
|
|
||||||
});
|
|
||||||
gmStore.upsertShip(ship);
|
|
||||||
}, "Hold position order queued.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueMoveOrder() {
|
|
||||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetSystemId = moveOrderSystemId.value.trim();
|
|
||||||
if (!targetSystemId) {
|
|
||||||
actionError.value = "Select a target system.";
|
|
||||||
actionStatus.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runShipAction(async () => {
|
|
||||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
|
||||||
kind: "move",
|
|
||||||
priority: 90,
|
|
||||||
interruptCurrentPlan: true,
|
|
||||||
label: `Move to ${targetSystemId}`,
|
|
||||||
targetEntityId: null,
|
|
||||||
targetSystemId,
|
|
||||||
targetPosition: null,
|
|
||||||
sourceStationId: null,
|
|
||||||
destinationStationId: null,
|
|
||||||
itemId: null,
|
|
||||||
anchorId: null,
|
|
||||||
constructionSiteId: null,
|
|
||||||
moduleId: null,
|
|
||||||
waitSeconds: 0,
|
|
||||||
radius: 0,
|
|
||||||
maxSystemRange: 0,
|
|
||||||
knownStationsOnly: false,
|
|
||||||
});
|
|
||||||
gmStore.upsertShip(ship);
|
|
||||||
}, "Move order queued.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueMineResourceOrder() {
|
|
||||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId;
|
|
||||||
const itemId = mineOrderForm.itemId.trim();
|
|
||||||
if (!targetSystemId) {
|
|
||||||
actionError.value = "Select a mining system.";
|
|
||||||
actionStatus.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
actionError.value = "Select a ware to mine.";
|
|
||||||
actionStatus.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runShipAction(async () => {
|
|
||||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
|
||||||
kind: "mine-and-deliver",
|
|
||||||
priority: 95,
|
|
||||||
interruptCurrentPlan: true,
|
|
||||||
label: `Mine ${itemId} in ${targetSystemId}`,
|
|
||||||
targetEntityId: null,
|
|
||||||
targetSystemId,
|
|
||||||
targetPosition: null,
|
|
||||||
sourceStationId: null,
|
|
||||||
destinationStationId: null,
|
|
||||||
itemId,
|
|
||||||
anchorId: null,
|
|
||||||
constructionSiteId: null,
|
|
||||||
moduleId: null,
|
|
||||||
waitSeconds: 0,
|
|
||||||
radius: 0,
|
|
||||||
maxSystemRange: 0,
|
|
||||||
knownStationsOnly: false,
|
|
||||||
});
|
|
||||||
gmStore.upsertShip(ship);
|
|
||||||
}, "Mine Resource order queued.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeOrder(orderId: string) {
|
async function removeOrder(orderId: string) {
|
||||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||||
return;
|
return;
|
||||||
@@ -632,43 +627,114 @@ async function clearOrders() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Cargo</h4>
|
<h4>Order Queue</h4>
|
||||||
<div class="entity-inspector-capacity-list">
|
<div v-if="canDirectControlSelectedShip && directOrders.length > 0" class="entity-inspector-actions-row">
|
||||||
<div v-for="row in shipCargoBarRows" :key="row.key" class="entity-inspector-capacity">
|
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="clearOrders">Clear Orders</button>
|
||||||
<div class="entity-inspector-capacity__header">
|
|
||||||
<span class="entity-inspector-capacity__label">{{ row.label }}</span>
|
|
||||||
<span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="entity-inspector-capacity__scale">
|
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
||||||
<span>0</span>
|
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
||||||
<div class="entity-inspector-capacity__track">
|
<div v-if="directOrders.length > 0" class="entity-inspector-order-list">
|
||||||
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
|
<article v-for="order in directOrders" :key="order.id" class="entity-inspector-order-card">
|
||||||
|
<header class="entity-inspector-order-card__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="entity-inspector-order-card__toggle"
|
||||||
|
:aria-expanded="expandedDirectOrderId === order.id"
|
||||||
|
@click="toggleOrderEditor(order)"
|
||||||
|
>
|
||||||
|
<span>{{ expandedDirectOrderId === order.id ? "▾" : "▸" }}</span>
|
||||||
|
<span>{{ getShipOrderLabel(order.kind) }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="entity-inspector-order-card__actions">
|
||||||
|
<span class="entity-inspector-order-card__status">{{ titleCase(order.status) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canDirectControlSelectedShip"
|
||||||
|
type="button"
|
||||||
|
class="entity-inspector-order-remove"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="removeOrder(order.id)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ row.maxLabel }}</span>
|
</header>
|
||||||
|
<div class="entity-inspector-order-card__summary">
|
||||||
|
<span>{{ describeOrderTarget(order) }}</span>
|
||||||
|
<span>{{ joinDetail([`P${order.priority}`, titleCase(order.sourceKind), describeOrderFailure(order) ?? undefined]) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedDirectOrderId === order.id" class="entity-inspector-order-editor">
|
||||||
|
<div class="entity-inspector-note">
|
||||||
|
{{ [getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}
|
||||||
|
</div>
|
||||||
|
<div class="entity-inspector-form">
|
||||||
|
<label class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Label</span>
|
||||||
|
<input v-model="orderEditForm.label" type="text" />
|
||||||
|
</label>
|
||||||
|
<div class="entity-inspector-inline-form">
|
||||||
|
<label class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Priority</span>
|
||||||
|
<input v-model="orderEditForm.priority" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
<label class="entity-inspector-field entity-inspector-field--checkbox">
|
||||||
|
<span>Interrupt current plan</span>
|
||||||
|
<input v-model="orderEditForm.interruptCurrentPlan" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'targetSystemId')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Target System</span>
|
||||||
|
<select v-model="orderEditForm.targetSystemId">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'targetEntityId')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Target Entity Id</span>
|
||||||
|
<input v-model="orderEditForm.targetEntityId" type="text" />
|
||||||
|
</label>
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'itemId')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Ware</span>
|
||||||
|
<select v-model="orderEditForm.itemId">
|
||||||
|
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div v-if="supportsOrderField(order.kind, 'waitSeconds') || supportsOrderField(order.kind, 'radius')" class="entity-inspector-inline-form">
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'waitSeconds')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Wait Seconds</span>
|
||||||
|
<input v-model="orderEditForm.waitSeconds" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'radius')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Radius</span>
|
||||||
|
<input v-model="orderEditForm.radius" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="supportsOrderField(order.kind, 'maxSystemRange') || supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-inline-form">
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'maxSystemRange')" class="entity-inspector-field entity-inspector-field--grow">
|
||||||
|
<span>Max System Range</span>
|
||||||
|
<input v-model="orderEditForm.maxSystemRange" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
<label v-if="supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-field entity-inspector-field--checkbox">
|
||||||
|
<span>Known Stations Only</span>
|
||||||
|
<input v-model="orderEditForm.knownStationsOnly" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="entity-inspector-order-actions">
|
||||||
|
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="saveOrder(order)">Save</button>
|
||||||
|
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="toggleOrderEditor(order)">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
</article>
|
||||||
<table class="entity-inspector-table">
|
</div>
|
||||||
<thead>
|
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||||
<tr>
|
<div class="entity-inspector-note">
|
||||||
<th scope="col">Ware</th>
|
Behavior-generated queue entries are managed from Default Behavior.
|
||||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
<span v-if="behaviorGeneratedOrderCount > 0"> Active generated orders: {{ behaviorGeneratedOrderCount }}.</span>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in shipCargoRows" :key="row.key">
|
|
||||||
<td>{{ row.ware }}</td>
|
|
||||||
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-inspector-empty">No wares loaded.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
<div class="entity-inspector-section">
|
||||||
<h4>Behavior</h4>
|
<h4>Default Behavior</h4>
|
||||||
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
||||||
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
||||||
</div>
|
</div>
|
||||||
@@ -715,125 +781,6 @@ async function clearOrders() {
|
|||||||
Direct behavior editing is only available for player-owned ships or GM users.
|
Direct behavior editing is only available for player-owned ships or GM users.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
|
||||||
<h4>Orders</h4>
|
|
||||||
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
|
|
||||||
<div class="entity-inspector-actions-row">
|
|
||||||
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueHoldPositionOrder">Hold Position</button>
|
|
||||||
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy || directOrders.length === 0" @click="clearOrders">Clear Orders</button>
|
|
||||||
</div>
|
|
||||||
<div class="entity-inspector-inline-form">
|
|
||||||
<label class="entity-inspector-field entity-inspector-field--grow">
|
|
||||||
<span>Move To System</span>
|
|
||||||
<select v-model="moveOrderSystemId">
|
|
||||||
<option value="">Select system</option>
|
|
||||||
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMoveOrder">Queue Move</button>
|
|
||||||
</div>
|
|
||||||
<div class="entity-inspector-inline-form">
|
|
||||||
<label class="entity-inspector-field entity-inspector-field--grow">
|
|
||||||
<span>Mine Resource System</span>
|
|
||||||
<select v-model="mineOrderForm.systemId">
|
|
||||||
<option value="">Current system</option>
|
|
||||||
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="entity-inspector-field entity-inspector-field--grow">
|
|
||||||
<span>Ware</span>
|
|
||||||
<select v-model="mineOrderForm.itemId">
|
|
||||||
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMineResourceOrder">Queue Mine</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
|
||||||
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
|
||||||
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
|
|
||||||
<table class="entity-inspector-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Order</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Target</th>
|
|
||||||
<th scope="col">Detail</th>
|
|
||||||
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="order in directOrderRows" :key="order.id">
|
|
||||||
<td>{{ order.label }}</td>
|
|
||||||
<td>{{ order.status }}</td>
|
|
||||||
<td>{{ order.target }}</td>
|
|
||||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
|
||||||
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="entity-inspector-order-remove"
|
|
||||||
:disabled="actionBusy"
|
|
||||||
@click="removeOrder(order.id)"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
|
||||||
<div class="entity-inspector-divider">
|
|
||||||
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
|
|
||||||
<table class="entity-inspector-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Order</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Target</th>
|
|
||||||
<th scope="col">Detail</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="order in behaviorOrderRows" :key="order.id">
|
|
||||||
<td>{{ order.label }}</td>
|
|
||||||
<td>{{ order.status }}</td>
|
|
||||||
<td>{{ order.target }}</td>
|
|
||||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="entity-inspector-section">
|
|
||||||
<h4>Plan Steps</h4>
|
|
||||||
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
|
|
||||||
<table class="entity-inspector-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Scope</th>
|
|
||||||
<th scope="col">Activity</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Detail</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
|
|
||||||
<td>{{ row.scope }}</td>
|
|
||||||
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else class="entity-inspector-empty">No active plan.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="selectedStation">
|
<template v-else-if="selectedStation">
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import type { OpsStripState } from "../viewerHudState";
|
|
||||||
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
|
|
||||||
import type { Selectable } from "../viewerTypes";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
state: OpsStripState;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
history: [selection: Selectable];
|
|
||||||
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const selectionStore = useViewerSelectionStore();
|
|
||||||
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
|
||||||
|
|
||||||
function isSelected(kind: Selectable["kind"], id: string) {
|
|
||||||
return selectedEntityKind.value === kind && selectedEntityId.value === id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStationClick(id: string, label: string) {
|
|
||||||
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStationDoubleClick(id: string, label: string) {
|
|
||||||
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
|
|
||||||
emit("focus", { kind: "station", id }, "tactical");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onShipClick(id: string, label: string) {
|
|
||||||
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onShipDoubleClick(id: string, label: string) {
|
|
||||||
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
|
|
||||||
emit("focus", { kind: "ship", id }, "follow");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="ops-strip">
|
|
||||||
<article
|
|
||||||
v-for="faction in state.factions"
|
|
||||||
:key="faction.id"
|
|
||||||
class="ship-card faction-card"
|
|
||||||
:data-faction-id="faction.id"
|
|
||||||
>
|
|
||||||
<div class="ship-card-header">
|
|
||||||
<h3>{{ faction.label }}</h3>
|
|
||||||
<span class="ship-card-badge">faction</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="faction.stateLines.length > 0"
|
|
||||||
class="ship-card-ai"
|
|
||||||
>
|
|
||||||
<p class="ship-card-section-title">GOAP State</p>
|
|
||||||
<p
|
|
||||||
v-for="line in faction.stateLines"
|
|
||||||
:key="line"
|
|
||||||
>
|
|
||||||
{{ line }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="faction.priorities.length > 0"
|
|
||||||
class="ship-card-ai"
|
|
||||||
>
|
|
||||||
<p class="ship-card-section-title">Priorities</p>
|
|
||||||
<p
|
|
||||||
v-for="priority in faction.priorities"
|
|
||||||
:key="`${faction.id}-${priority.label}`"
|
|
||||||
class="ship-card-split-line"
|
|
||||||
>
|
|
||||||
<span>{{ priority.label }}</span>
|
|
||||||
<span>{{ priority.value }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
v-for="station in state.stations"
|
|
||||||
:key="station.id"
|
|
||||||
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
|
|
||||||
:data-station-id="station.id"
|
|
||||||
@click="onStationClick(station.id, station.label)"
|
|
||||||
@dblclick="onStationDoubleClick(station.id, station.label)"
|
|
||||||
>
|
|
||||||
<div class="ship-card-header">
|
|
||||||
<h3>{{ station.label }}</h3>
|
|
||||||
<span class="ship-card-badge">{{ station.badge }}</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-for="line in station.lines"
|
|
||||||
:key="`${station.id}-${line}`"
|
|
||||||
>
|
|
||||||
{{ line }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="station.processes.length > 0"
|
|
||||||
class="ship-card-ai"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="process in station.processes"
|
|
||||||
:key="`${station.id}-${process.label}`"
|
|
||||||
class="ship-action-progress"
|
|
||||||
>
|
|
||||||
<div class="ship-action-progress-label">
|
|
||||||
<span>{{ process.label }}</span>
|
|
||||||
<span>{{ process.valueLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ship-action-progress-track">
|
|
||||||
<div
|
|
||||||
class="ship-action-progress-fill"
|
|
||||||
:style="{ width: `${process.progress}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
v-for="ship in state.ships"
|
|
||||||
:key="ship.id"
|
|
||||||
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
|
|
||||||
:data-ship-id="ship.id"
|
|
||||||
@click="onShipClick(ship.id, ship.label)"
|
|
||||||
@dblclick="onShipDoubleClick(ship.id, ship.label)"
|
|
||||||
>
|
|
||||||
<div class="ship-card-header">
|
|
||||||
<h3>{{ ship.label }}</h3>
|
|
||||||
<div class="ship-card-meta">
|
|
||||||
<span class="ship-card-badge">{{ ship.badge }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ship-card-history-button"
|
|
||||||
:data-history-ship-id="ship.id"
|
|
||||||
:aria-label="`Open history for ${ship.label}`"
|
|
||||||
title="Open history"
|
|
||||||
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
|
|
||||||
>
|
|
||||||
🕔
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-for="line in ship.locationLines"
|
|
||||||
:key="`${ship.id}-${line}`"
|
|
||||||
>
|
|
||||||
{{ line }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-for="line in ship.lines"
|
|
||||||
:key="`${ship.id}-${line}`"
|
|
||||||
>
|
|
||||||
{{ line }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="ship.action"
|
|
||||||
class="ship-action-progress"
|
|
||||||
>
|
|
||||||
<div class="ship-action-progress-label">
|
|
||||||
<span>{{ ship.action.label }}</span>
|
|
||||||
<span>{{ ship.action.valueLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ship-action-progress-track">
|
|
||||||
<div
|
|
||||||
class="ship-action-progress-fill"
|
|
||||||
:style="{ width: `${ship.action.progress}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ship-card-ai">
|
|
||||||
<p
|
|
||||||
v-for="line in ship.aiLines"
|
|
||||||
:key="`${ship.id}-${line}`"
|
|
||||||
>
|
|
||||||
{{ line }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -12,7 +12,7 @@ import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
|
|||||||
|
|
||||||
type MenuAction =
|
type MenuAction =
|
||||||
| "mine-resource"
|
| "mine-resource"
|
||||||
| "fly-to-and-wait"
|
| "fly-to"
|
||||||
| "follow"
|
| "follow"
|
||||||
| "attack";
|
| "attack";
|
||||||
|
|
||||||
@@ -105,13 +105,14 @@ const actions = computed<OrderMenuActionEntry[]>(() => {
|
|||||||
case "station":
|
case "station":
|
||||||
case "celestial":
|
case "celestial":
|
||||||
case "construction-site":
|
case "construction-site":
|
||||||
|
case "point":
|
||||||
return [{
|
return [{
|
||||||
key: "fly-to-and-wait",
|
key: "fly-to",
|
||||||
orderKind: "fly-and-wait",
|
orderKind: "move",
|
||||||
label: getShipOrderLabel("fly-and-wait"),
|
label: getShipOrderLabel("move"),
|
||||||
detail: target.value.label,
|
detail: target.value.label,
|
||||||
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
|
supportStatus: getShipOrderSupportStatusLabel("move"),
|
||||||
notes: getShipOrderNotes("fly-and-wait"),
|
notes: getShipOrderNotes("move"),
|
||||||
}];
|
}];
|
||||||
case "system":
|
case "system":
|
||||||
return emptyActions();
|
return emptyActions();
|
||||||
@@ -170,9 +171,9 @@ async function runAction(action: MenuAction) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "fly-to-and-wait") {
|
if (action === "fly-to") {
|
||||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||||
kind: "fly-and-wait",
|
kind: "move",
|
||||||
priority: 100,
|
priority: 100,
|
||||||
interruptCurrentPlan: true,
|
interruptCurrentPlan: true,
|
||||||
label: `Fly to ${target.value.label}`,
|
label: `Fly to ${target.value.label}`,
|
||||||
@@ -185,7 +186,7 @@ async function runAction(action: MenuAction) {
|
|||||||
anchorId: null,
|
anchorId: null,
|
||||||
constructionSiteId: null,
|
constructionSiteId: null,
|
||||||
moduleId: null,
|
moduleId: null,
|
||||||
waitSeconds: 8,
|
waitSeconds: 0,
|
||||||
radius: 0,
|
radius: 0,
|
||||||
maxSystemRange: 0,
|
maxSystemRange: 0,
|
||||||
knownStationsOnly: false,
|
knownStationsOnly: false,
|
||||||
|
|||||||
@@ -278,9 +278,7 @@ type ShipRow = {
|
|||||||
|
|
||||||
const shipRows = computed<ShipRow[]>(() =>
|
const shipRows = computed<ShipRow[]>(() =>
|
||||||
gmStore.ships.map((s) => {
|
gmStore.ships.map((s) => {
|
||||||
const topOrder = [...s.orderQueue]
|
const topOrder = s.orderQueue[0];
|
||||||
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
|
||||||
const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex];
|
|
||||||
const currentSubTask = s.activeSubTasks[0];
|
const currentSubTask = s.activeSubTasks[0];
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -293,8 +291,8 @@ const shipRows = computed<ShipRow[]>(() =>
|
|||||||
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
|
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
|
||||||
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
|
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
|
||||||
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
|
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
|
||||||
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
|
plan: currentSubTask ? "Task execution" : "—",
|
||||||
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
|
step: currentSubTask ? titleCaseToken(currentSubTask.kind) : "—",
|
||||||
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
|
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ async function submitDirectOrder() {
|
|||||||
<div v-if="selectedShip" class="player-card">
|
<div v-if="selectedShip" class="player-card">
|
||||||
<strong>Behavior</strong>
|
<strong>Behavior</strong>
|
||||||
<span>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
<span>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
||||||
<span>Orders {{ selectedShip.orderQueue.length }} · Plan {{ selectedShip.activePlan?.kind ?? "none" }}</span>
|
<span>Orders {{ selectedShip.orderQueue.length }} · Tasks {{ selectedShip.activeSubTasks.length }}</span>
|
||||||
<span>Command {{ titleCase(selectedShip.controlSourceKind) }}<template v-if="selectedShip.controlReason"> · {{ selectedShip.controlReason }}</template></span>
|
<span>Command {{ titleCase(selectedShip.controlSourceKind) }}<template v-if="selectedShip.controlReason"> · {{ selectedShip.controlReason }}</template></span>
|
||||||
<span v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
|
<span v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
|
||||||
<span v-if="selectedShip.lastAccessFailureReason">Access {{ selectedShip.lastAccessFailureReason }}</span>
|
<span v-if="selectedShip.lastAccessFailureReason">Access {{ selectedShip.lastAccessFailureReason }}</span>
|
||||||
|
|||||||
@@ -114,31 +114,6 @@ export interface ShipSubTaskSnapshot {
|
|||||||
blockingReason?: string | null;
|
blockingReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShipPlanStepSnapshot {
|
|
||||||
id: string;
|
|
||||||
kind: string;
|
|
||||||
status: string;
|
|
||||||
summary: string;
|
|
||||||
blockingReason?: string | null;
|
|
||||||
currentSubTaskIndex: number;
|
|
||||||
subTasks: ShipSubTaskSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShipPlanSnapshot {
|
|
||||||
id: string;
|
|
||||||
sourceKind: string;
|
|
||||||
sourceId: string;
|
|
||||||
kind: string;
|
|
||||||
status: string;
|
|
||||||
summary: string;
|
|
||||||
currentStepIndex: number;
|
|
||||||
createdAtUtc: string;
|
|
||||||
updatedAtUtc: string;
|
|
||||||
interruptReason?: string | null;
|
|
||||||
failureReason?: string | null;
|
|
||||||
steps: ShipPlanStepSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShipSnapshot {
|
export interface ShipSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -154,8 +129,6 @@ export interface ShipSnapshot {
|
|||||||
defaultBehavior: DefaultBehaviorSnapshot;
|
defaultBehavior: DefaultBehaviorSnapshot;
|
||||||
assignment?: ShipAssignmentSnapshot | null;
|
assignment?: ShipAssignmentSnapshot | null;
|
||||||
skills: ShipSkillProfileSnapshot;
|
skills: ShipSkillProfileSnapshot;
|
||||||
activePlan?: ShipPlanSnapshot | null;
|
|
||||||
currentStepId?: string | null;
|
|
||||||
activeSubTasks: ShipSubTaskSnapshot[];
|
activeSubTasks: ShipSubTaskSnapshot[];
|
||||||
controlSourceKind: string;
|
controlSourceKind: string;
|
||||||
controlSourceId?: string | null;
|
controlSourceId?: string | null;
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ export interface ShipOrderCommandRequest {
|
|||||||
knownStationsOnly?: boolean | null;
|
knownStationsOnly?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShipOrderUpdateCommandRequest {
|
||||||
|
kind: string;
|
||||||
|
priority: number;
|
||||||
|
interruptCurrentPlan: boolean;
|
||||||
|
label?: string | null;
|
||||||
|
targetEntityId?: string | null;
|
||||||
|
targetSystemId?: string | null;
|
||||||
|
targetPosition?: Vector3Dto | null;
|
||||||
|
sourceStationId?: string | null;
|
||||||
|
destinationStationId?: string | null;
|
||||||
|
itemId?: string | null;
|
||||||
|
anchorId?: string | null;
|
||||||
|
constructionSiteId?: string | null;
|
||||||
|
moduleId?: string | null;
|
||||||
|
waitSeconds?: number | null;
|
||||||
|
radius?: number | null;
|
||||||
|
maxSystemRange?: number | null;
|
||||||
|
knownStationsOnly?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShipDefaultBehaviorCommandRequest {
|
export interface ShipDefaultBehaviorCommandRequest {
|
||||||
kind: string;
|
kind: string;
|
||||||
homeSystemId?: string | null;
|
homeSystemId?: string | null;
|
||||||
|
|||||||
@@ -669,112 +669,6 @@ canvas {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-strip {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 50vw;
|
|
||||||
min-height: 128px;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
pointer-events: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
background: linear-gradient(180deg, rgba(5, 10, 18, 0), rgba(5, 10, 18, 0.92) 28%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card {
|
|
||||||
border-top: 1px solid rgba(127, 214, 255, 0.14);
|
|
||||||
border-right: 1px solid rgba(127, 214, 255, 0.1);
|
|
||||||
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
|
|
||||||
padding: 10px 12px 12px;
|
|
||||||
min-width: 208px;
|
|
||||||
max-width: 208px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--viewer-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(127, 214, 255, 0.38);
|
|
||||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card.is-selected {
|
|
||||||
border-top-color: rgba(255, 191, 105, 0.82);
|
|
||||||
background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card.is-followed {
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.15;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
color: var(--viewer-muted);
|
|
||||||
line-height: 1.35;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-header + p {
|
|
||||||
font-size: 0.62rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-badge {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(127, 214, 255, 0.12);
|
|
||||||
color: var(--viewer-accent);
|
|
||||||
font-size: 0.64rem;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-ai {
|
|
||||||
margin-top: 2px;
|
|
||||||
padding-top: 6px;
|
|
||||||
border-top: 1px solid rgba(127, 214, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-section-title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--viewer-accent);
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-history-button,
|
|
||||||
.history-window-copy,
|
.history-window-copy,
|
||||||
.history-window-close {
|
.history-window-close {
|
||||||
border: 1px solid rgba(127, 214, 255, 0.22);
|
border: 1px solid rgba(127, 214, 255, 0.22);
|
||||||
@@ -785,64 +679,15 @@ canvas {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ship-card-history-button {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
align-self: flex-end;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-window-copy,
|
.history-window-copy,
|
||||||
.history-window-close {
|
.history-window-close {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faction-card {
|
|
||||||
border-top-color: rgba(180, 130, 255, 0.3);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.faction-card:hover {
|
|
||||||
transform: none;
|
|
||||||
border-color: rgba(180, 130, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.station-card {
|
|
||||||
border-top-color: rgba(127, 255, 180, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.station-card:hover {
|
|
||||||
border-color: rgba(127, 255, 180, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ship-card-split-line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-action-button {
|
.selection-action-button {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
|
||||||
.ops-strip {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.ops-strip {
|
|
||||||
width: 100vw;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── GM Windows ──────────────────────────────────────────────────────────── */
|
/* ── GM Windows ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.gm-window {
|
.gm-window {
|
||||||
@@ -1859,6 +1704,65 @@ canvas {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card__header,
|
||||||
|
.entity-inspector-order-card__actions,
|
||||||
|
.entity-inspector-order-card__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--viewer-text);
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card__status {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(173, 220, 255, 0.78);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card__summary {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: var(--viewer-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-card__summary span:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-inspector-order-editor {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.entity-inspector-field {
|
.entity-inspector-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1870,6 +1774,10 @@ canvas {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-inspector-field--checkbox {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-inspector-field span {
|
.entity-inspector-field span {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--viewer-muted);
|
color: var(--viewer-muted);
|
||||||
@@ -1893,6 +1801,14 @@ canvas {
|
|||||||
border-color: rgba(173, 220, 255, 0.4);
|
border-color: rgba(173, 220, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-inspector-field--checkbox input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: #7fd6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-inspector-note {
|
.entity-inspector-note {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
color: var(--viewer-muted);
|
color: var(--viewer-muted);
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { defineStore } from "pinia";
|
|||||||
import type { Vector3Dto } from "../../contractsCommon";
|
import type { Vector3Dto } from "../../contractsCommon";
|
||||||
import type { Selectable } from "../../viewerTypes";
|
import type { Selectable } from "../../viewerTypes";
|
||||||
|
|
||||||
|
export interface ViewerOrderContextMenuPointSelection {
|
||||||
|
kind: "point";
|
||||||
|
id: "local-point";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ViewerOrderContextMenuTarget {
|
export interface ViewerOrderContextMenuTarget {
|
||||||
selection: Selectable;
|
selection: Selectable | ViewerOrderContextMenuPointSelection;
|
||||||
label: string;
|
label: string;
|
||||||
systemId?: string | null;
|
systemId?: string | null;
|
||||||
anchorId?: string | null;
|
anchorId?: string | null;
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
|
|||||||
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
||||||
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
|
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
|
||||||
if (activeSystemId) {
|
if (activeSystemId) {
|
||||||
const speedKilometers = povLevel === "system"
|
const panSpeed = povLevel === "system"
|
||||||
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
|
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
|
||||||
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
|
: THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
|
||||||
systemAnchor.addScaledVector(pan, speedKilometers * delta);
|
systemAnchor.addScaledVector(pan, panSpeed * delta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +133,11 @@ export function applyPanFromScreenDelta(
|
|||||||
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
|
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
|
||||||
|
|
||||||
if (activeSystemId) {
|
if (activeSystemId) {
|
||||||
|
if (povLevel === "local") {
|
||||||
|
systemAnchor.add(pan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||||
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
|
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
|
||||||
return;
|
return;
|
||||||
@@ -353,7 +358,7 @@ export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a local km position to system-scene display coordinates.
|
* Convert a system-space kilometer position to system-scene display coordinates.
|
||||||
* System scene coordinate system: star at origin, all positions scaled by
|
* System scene coordinate system: star at origin, all positions scaled by
|
||||||
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
|
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PovLevel } from "./viewerTypes";
|
import type { PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
export const NAV_DISTANCE: Record<PovLevel, number> = {
|
export const NAV_DISTANCE: Record<PovLevel, number> = {
|
||||||
local: 18,
|
local: 180,
|
||||||
system: 3200,
|
system: 3200,
|
||||||
galaxy: 32000,
|
galaxy: 32000,
|
||||||
};
|
};
|
||||||
@@ -21,6 +21,11 @@ export const MOON_RENDER_SCALE = 1.1;
|
|||||||
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
|
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
|
||||||
export const MIN_CAMERA_DISTANCE = 0.00005;
|
export const MIN_CAMERA_DISTANCE = 0.00005;
|
||||||
export const MAX_CAMERA_DISTANCE = 150000;
|
export const MAX_CAMERA_DISTANCE = 150000;
|
||||||
|
export const MIN_LOCAL_CAMERA_DISTANCE = 4;
|
||||||
|
export const MAX_LOCAL_CAMERA_DISTANCE = 120000;
|
||||||
|
export const LOCAL_SYSTEM_BACKDROP_DISTANCE = 650;
|
||||||
|
export const LOCAL_CAMERA_DISTANCE_AT_TRANSITION = 100000;
|
||||||
|
export const LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM = 40;
|
||||||
|
|
||||||
export interface ZoomBlend {
|
export interface ZoomBlend {
|
||||||
localWeight: number;
|
localWeight: number;
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export function createViewerControllers(host: any) {
|
|||||||
getCameraMode: () => host.cameraMode,
|
getCameraMode: () => host.cameraMode,
|
||||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||||
getPovLevel: () => host.povLevel,
|
getPovLevel: () => host.povLevel,
|
||||||
|
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
|
||||||
getSelectedItems: () => host.selectedItems,
|
getSelectedItems: () => host.selectedItems,
|
||||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||||
getCurrentDistance: () => host.currentDistance,
|
getCurrentDistance: () => host.currentDistance,
|
||||||
@@ -251,6 +252,8 @@ export function createViewerControllers(host: any) {
|
|||||||
},
|
},
|
||||||
getFollowCameraPosition: () => host.followCameraPosition,
|
getFollowCameraPosition: () => host.followCameraPosition,
|
||||||
getFollowCameraFocus: () => host.followCameraFocus,
|
getFollowCameraFocus: () => host.followCameraFocus,
|
||||||
|
getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
|
||||||
|
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
|
||||||
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
||||||
applyPanDelta: (delta: THREE.Vector2) => {
|
applyPanDelta: (delta: THREE.Vector2) => {
|
||||||
const bounds = host.renderer.domElement.getBoundingClientRect();
|
const bounds = host.renderer.domElement.getBoundingClientRect();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
|
import { MAX_CAMERA_DISTANCE, MAX_LOCAL_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, MIN_LOCAL_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
|
||||||
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
|
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
|
||||||
import { rawObject } from "./viewerScenePrimitives";
|
import { rawObject } from "./viewerScenePrimitives";
|
||||||
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
||||||
import type { StatsOverlayMode } from "./viewerHudState";
|
import type { StatsOverlayMode } from "./viewerHudState";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
|
PovLevel,
|
||||||
Selectable,
|
Selectable,
|
||||||
ShipVisual,
|
ShipVisual,
|
||||||
SystemVisual,
|
SystemVisual,
|
||||||
@@ -212,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
|
|||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateFromWheel(desiredDistance: number, deltaY: number) {
|
export function navigateFromWheel(desiredDistance: number, deltaY: number, povLevel: PovLevel) {
|
||||||
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
|
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
|
||||||
const zoomFactor = Math.exp(clampedDelta * 0.00135);
|
const zoomFactor = Math.exp(clampedDelta * 0.00135);
|
||||||
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
|
const minimumDistance = povLevel === "local" ? MIN_LOCAL_CAMERA_DISTANCE : MIN_CAMERA_DISTANCE;
|
||||||
|
const maximumDistance = povLevel === "local" ? MAX_LOCAL_CAMERA_DISTANCE : MAX_CAMERA_DISTANCE;
|
||||||
|
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, minimumDistance, maximumDistance);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyKeyboardControl(params: {
|
export function applyKeyboardControl(params: {
|
||||||
|
|||||||
@@ -32,43 +32,6 @@ export interface HudProgressBar {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsFactionCardState {
|
|
||||||
kind: "faction";
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
stateLines: string[];
|
|
||||||
priorities: { label: string; value: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpsStationCardState {
|
|
||||||
kind: "station";
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
badge: string;
|
|
||||||
selected: boolean;
|
|
||||||
lines: string[];
|
|
||||||
processes: HudProgressBar[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpsShipCardState {
|
|
||||||
kind: "ship";
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
badge: string;
|
|
||||||
selected: boolean;
|
|
||||||
followed: boolean;
|
|
||||||
locationLines: string[];
|
|
||||||
lines: string[];
|
|
||||||
action?: HudProgressBar;
|
|
||||||
aiLines: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpsStripState {
|
|
||||||
factions: OpsFactionCardState[];
|
|
||||||
stations: OpsStationCardState[];
|
|
||||||
ships: OpsShipCardState[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryWindowState {
|
export interface HistoryWindowState {
|
||||||
id: string;
|
id: string;
|
||||||
target: Selectable;
|
target: Selectable;
|
||||||
@@ -111,7 +74,6 @@ export interface ViewerHudState {
|
|||||||
systemPanel: HudHtmlPanelState;
|
systemPanel: HudHtmlPanelState;
|
||||||
detailPanel: HudHtmlPanelState;
|
detailPanel: HudHtmlPanelState;
|
||||||
error: HudErrorState;
|
error: HudErrorState;
|
||||||
opsStrip: OpsStripState;
|
|
||||||
historyWindows: HistoryWindowState[];
|
historyWindows: HistoryWindowState[];
|
||||||
hoverLabel: HoverLabelState;
|
hoverLabel: HoverLabelState;
|
||||||
marquee: MarqueeState;
|
marquee: MarqueeState;
|
||||||
@@ -161,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
message: "",
|
message: "",
|
||||||
},
|
},
|
||||||
opsStrip: {
|
|
||||||
factions: [],
|
|
||||||
stations: [],
|
|
||||||
ships: [],
|
|
||||||
},
|
|
||||||
historyWindows: [],
|
historyWindows: [],
|
||||||
hoverLabel: {
|
hoverLabel: {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
||||||
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
|
||||||
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
|
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, METERS_PER_KILOMETER, formatAdaptiveDistanceFromKilometers, formatAdaptiveDistanceFromMeters, formatSystemDistance } from "./viewerMath";
|
||||||
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
|
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
|
||||||
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
||||||
|
|
||||||
@@ -169,13 +169,17 @@ function formatHoverDistance(
|
|||||||
|| selection.kind === "construction-site";
|
|| selection.kind === "construction-site";
|
||||||
|
|
||||||
if (inActiveSystem && activeSystemId) {
|
if (inActiveSystem && activeSystemId) {
|
||||||
|
if (povLevel === "local") {
|
||||||
|
return formatAdaptiveDistanceFromMeters(displayDistance);
|
||||||
|
}
|
||||||
|
|
||||||
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||||
return povLevel === "system"
|
return povLevel === "system"
|
||||||
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
|
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
|
||||||
: formatAdaptiveDistanceFromKilometers(kilometers);
|
: formatAdaptiveDistanceFromKilometers(kilometers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
|
return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateMarqueeBox(
|
export function updateMarqueeBox(
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import type {
|
|||||||
WorldState,
|
WorldState,
|
||||||
PovLevel,
|
PovLevel,
|
||||||
} from "./viewerTypes";
|
} from "./viewerTypes";
|
||||||
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
|
import type {
|
||||||
|
ViewerOrderContextMenuPointSelection,
|
||||||
|
ViewerOrderContextMenuTarget,
|
||||||
|
} from "./ui/stores/viewerOrderContextMenu";
|
||||||
|
|
||||||
export interface ViewerInteractionContext {
|
export interface ViewerInteractionContext {
|
||||||
renderer: THREE.WebGLRenderer;
|
renderer: THREE.WebGLRenderer;
|
||||||
@@ -60,6 +63,8 @@ export interface ViewerInteractionContext {
|
|||||||
setCameraTargetShipId: (value: string | undefined) => void;
|
setCameraTargetShipId: (value: string | undefined) => void;
|
||||||
getFollowCameraPosition: () => THREE.Vector3;
|
getFollowCameraPosition: () => THREE.Vector3;
|
||||||
getFollowCameraFocus: () => THREE.Vector3;
|
getFollowCameraFocus: () => THREE.Vector3;
|
||||||
|
getLocalRootPosition: () => THREE.Vector3;
|
||||||
|
getFocusedAnchorId: () => string | undefined;
|
||||||
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
||||||
applyPanDelta: (delta: THREE.Vector2) => void;
|
applyPanDelta: (delta: THREE.Vector2) => void;
|
||||||
syncFollowStateFromSelection: () => void;
|
syncFollowStateFromSelection: () => void;
|
||||||
@@ -206,11 +211,9 @@ export class ViewerInteractionController {
|
|||||||
this.context.closeOrderContextMenu();
|
this.context.closeOrderContextMenu();
|
||||||
|
|
||||||
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
|
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
|
||||||
if (!picked) {
|
const target = picked
|
||||||
return;
|
? this.buildOrderContextTarget(picked)
|
||||||
}
|
: this.buildLocalPointContextTarget(event.clientX, event.clientY);
|
||||||
|
|
||||||
const target = this.buildOrderContextTarget(picked);
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -218,71 +221,6 @@ export class ViewerInteractionController {
|
|||||||
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
|
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly onOpsStripClick = (event: MouseEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
|
|
||||||
const historyShipId = historyButton?.dataset.historyShipId;
|
|
||||||
if (historyShipId) {
|
|
||||||
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
|
|
||||||
const shipId = shipCard?.dataset.shipId;
|
|
||||||
if (shipId) {
|
|
||||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
|
||||||
this.context.syncFollowStateFromSelection();
|
|
||||||
this.context.updatePanels();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stationCard = target.closest<HTMLElement>("[data-station-id]");
|
|
||||||
const stationId = stationCard?.dataset.stationId;
|
|
||||||
if (stationId) {
|
|
||||||
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
|
|
||||||
this.context.syncFollowStateFromSelection();
|
|
||||||
this.context.updatePanels();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly onOpsStripDoubleClick = (event: MouseEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.closest("[data-history-ship-id]")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
|
|
||||||
const shipId = shipCard?.dataset.shipId;
|
|
||||||
if (shipId) {
|
|
||||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
|
||||||
this.context.syncFollowStateFromSelection();
|
|
||||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
|
||||||
this.toggleCameraMode("tactical");
|
|
||||||
this.context.updatePanels();
|
|
||||||
this.context.updateGamePanel("Live");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stationCard = target.closest<HTMLElement>("[data-station-id]");
|
|
||||||
const stationId = stationCard?.dataset.stationId;
|
|
||||||
if (stationId) {
|
|
||||||
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
|
|
||||||
this.context.syncFollowStateFromSelection();
|
|
||||||
this.toggleCameraMode("tactical");
|
|
||||||
this.context.focusOnSelection({ kind: "station", id: stationId });
|
|
||||||
this.context.updatePanels();
|
|
||||||
this.context.updateGamePanel("Live");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
|
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
|
||||||
|
|
||||||
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
|
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
|
||||||
@@ -316,7 +254,7 @@ export class ViewerInteractionController {
|
|||||||
|
|
||||||
readonly onWheel = (event: WheelEvent) => {
|
readonly onWheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
|
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY, this.context.getPovLevel()));
|
||||||
this.context.updateGamePanel("Live");
|
this.context.updateGamePanel("Live");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,4 +446,45 @@ export class ViewerInteractionController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLocalPointContextTarget(clientX: number, clientY: number): ViewerOrderContextMenuTarget | null {
|
||||||
|
if (this.context.getPovLevel() !== "local") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = this.context.getWorld();
|
||||||
|
const systemId = this.context.getActiveSystemId();
|
||||||
|
const anchorId = this.context.getFocusedAnchorId();
|
||||||
|
if (!world || !systemId || !anchorId || !world.anchors.has(anchorId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = this.context.renderer.domElement.getBoundingClientRect();
|
||||||
|
this.context.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||||
|
this.context.mouse.y = -((clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||||
|
this.context.raycaster.setFromCamera(this.context.mouse, this.context.localCamera);
|
||||||
|
|
||||||
|
const localRootPosition = this.context.getLocalRootPosition();
|
||||||
|
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -localRootPosition.y);
|
||||||
|
const worldIntersection = new THREE.Vector3();
|
||||||
|
if (!this.context.raycaster.ray.intersectPlane(plane, worldIntersection)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPosition = worldIntersection.sub(localRootPosition);
|
||||||
|
const rounded = localPosition.clone().round();
|
||||||
|
const selection: ViewerOrderContextMenuPointSelection = { kind: "point", id: "local-point" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
selection,
|
||||||
|
label: `Point ${rounded.x}m, ${rounded.y}m, ${rounded.z}m`,
|
||||||
|
systemId,
|
||||||
|
anchorId,
|
||||||
|
targetPosition: {
|
||||||
|
x: rounded.x,
|
||||||
|
y: rounded.y,
|
||||||
|
z: rounded.z,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ import type {
|
|||||||
* Camera far plane covers immediate surroundings.
|
* Camera far plane covers immediate surroundings.
|
||||||
*/
|
*/
|
||||||
export class LocalLayer {
|
export class LocalLayer {
|
||||||
|
readonly localRoot = new THREE.Group();
|
||||||
|
readonly fineGrid = createLocalGrid(1000, 10, 0x35506d, 0x233449, 0.42);
|
||||||
|
readonly majorGrid = createLocalGrid(10000, 100, 0x6d88a3, 0x4b6078, 0.42);
|
||||||
|
readonly outerGrid = createLocalGrid(80000, 1000, 0x7e98b2, 0x55687f, 0.26);
|
||||||
readonly scene = new THREE.Scene();
|
readonly scene = new THREE.Scene();
|
||||||
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
|
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200000);
|
||||||
readonly nodeGroup = new THREE.Group();
|
readonly nodeGroup = new THREE.Group();
|
||||||
readonly stationGroup = new THREE.Group();
|
readonly stationGroup = new THREE.Group();
|
||||||
readonly claimGroup = new THREE.Group();
|
readonly claimGroup = new THREE.Group();
|
||||||
@@ -36,18 +40,24 @@ export class LocalLayer {
|
|||||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
|
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
|
||||||
keyLight.position.set(180, 220, 140);
|
keyLight.position.set(180, 220, 140);
|
||||||
this.scene.add(keyLight);
|
this.scene.add(keyLight);
|
||||||
this.scene.add(
|
this.localRoot.add(
|
||||||
|
this.fineGrid,
|
||||||
|
this.majorGrid,
|
||||||
|
this.outerGrid,
|
||||||
this.nodeGroup,
|
this.nodeGroup,
|
||||||
this.stationGroup,
|
this.stationGroup,
|
||||||
this.claimGroup,
|
this.claimGroup,
|
||||||
this.constructionSiteGroup,
|
this.constructionSiteGroup,
|
||||||
this.shipGroup,
|
this.shipGroup,
|
||||||
);
|
);
|
||||||
|
this.scene.add(this.localRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCamera(orbitOffset: THREE.Vector3) {
|
updateCamera(localFocus: THREE.Vector3, orbitOffset: THREE.Vector3, anchorOffset: THREE.Vector3) {
|
||||||
this.camera.position.copy(orbitOffset);
|
const worldFocus = localFocus.clone().add(anchorOffset);
|
||||||
this.camera.lookAt(LocalLayer.ORIGIN);
|
this.localRoot.position.copy(anchorOffset);
|
||||||
|
this.camera.position.copy(worldFocus).add(orbitOffset);
|
||||||
|
this.camera.lookAt(worldFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
onResize(aspect: number) {
|
onResize(aspect: number) {
|
||||||
@@ -59,3 +69,13 @@ export class LocalLayer {
|
|||||||
renderer.render(this.scene, this.camera);
|
renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLocalGrid(sizeMeters: number, stepMeters: number, majorColor: number, minorColor: number, opacity: number) {
|
||||||
|
const divisions = Math.max(1, Math.round(sizeMeters / stepMeters));
|
||||||
|
const grid = new THREE.GridHelper(sizeMeters, divisions, majorColor, minorColor);
|
||||||
|
const material = grid.material as THREE.Material & { opacity: number; transparent: boolean };
|
||||||
|
material.transparent = true;
|
||||||
|
material.opacity = opacity;
|
||||||
|
grid.position.y = -0.04;
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
import type { ZoomBlend } from "./viewerConstants";
|
import type { ZoomBlend } from "./viewerConstants";
|
||||||
|
|
||||||
export const KILOMETERS_PER_AU = 149_597_870.7;
|
export const KILOMETERS_PER_AU = 149_597_870.7;
|
||||||
|
export const METERS_PER_KILOMETER = 1000;
|
||||||
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
|
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
|
||||||
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
|
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatLocalDistance(value: number): string {
|
export function formatLocalDistance(value: number): string {
|
||||||
return `${formatNumber(value, 0)} km`;
|
return `${formatNumber(value, value >= 100 ? 0 : 1)} m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSystemDistance(value: number): string {
|
export function formatSystemDistance(value: number): string {
|
||||||
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
|
|||||||
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
|
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatAdaptiveDistanceFromMeters(meters: number): string {
|
||||||
|
const absoluteMeters = Math.max(0, meters);
|
||||||
|
if (absoluteMeters >= METERS_PER_KILOMETER) {
|
||||||
|
const kilometers = absoluteMeters / METERS_PER_KILOMETER;
|
||||||
|
return `${formatNumber(kilometers, kilometers >= 100 ? 0 : 2)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatNumber(absoluteMeters, absoluteMeters >= 100 ? 0 : 1)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatShipSpeed(ship: ShipSnapshot): string {
|
export function formatShipSpeed(ship: ShipSnapshot): string {
|
||||||
const speed = Math.max(0, ship.travelSpeed);
|
const speed = Math.max(0, ship.travelSpeed);
|
||||||
const unit = ship.travelSpeedUnit;
|
const unit = ship.travelSpeedUnit;
|
||||||
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function computeZoomBlend(distance: number): ZoomBlend {
|
export function computeZoomBlend(distance: number): ZoomBlend {
|
||||||
const localToSystem = smoothBand(distance, 1200, 5200);
|
const localToSystem = smoothBand(distance, 120, 650);
|
||||||
const systemToUniverse = smoothBand(distance, 9000, 22000);
|
const systemToUniverse = smoothBand(distance, 9000, 22000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export class ViewerNavigationController {
|
|||||||
return toDisplayLocalPosition(localPosition);
|
return toDisplayLocalPosition(localPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a display position for the system camera, derived from a raw local position in km. */
|
/** Returns a display position for the system camera, derived from a raw local position in meters. */
|
||||||
toSystemDisplayPosition(localPosition: THREE.Vector3) {
|
toSystemDisplayPosition(localPosition: THREE.Vector3) {
|
||||||
return toDisplayLocalPosition(localPosition);
|
return toDisplayLocalPosition(localPosition);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
|
||||||
import type { FactionSnapshot } from "./contractsFactions";
|
|
||||||
import type {
|
|
||||||
HudProgressBar,
|
|
||||||
OpsFactionCardState,
|
|
||||||
OpsShipCardState,
|
|
||||||
OpsStationCardState,
|
|
||||||
OpsStripState,
|
|
||||||
} from "./viewerHudState";
|
|
||||||
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
|
|
||||||
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
|
|
||||||
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
|
|
||||||
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
|
||||||
import { viewerPinia } from "./ui/stores/pinia";
|
|
||||||
|
|
||||||
function buildFactionCard(world: WorldState, faction: FactionSnapshot): OpsFactionCardState {
|
|
||||||
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
|
|
||||||
if (playerFaction && playerFaction.sovereignFactionId === faction.id) {
|
|
||||||
const selectedDirective = playerFaction.directives[0];
|
|
||||||
return {
|
|
||||||
kind: "faction",
|
|
||||||
id: faction.id,
|
|
||||||
label: `${faction.label} Command`,
|
|
||||||
stateLines: [
|
|
||||||
`Player ${playerFaction.assetRegistry.shipIds.length} ships · ${playerFaction.assetRegistry.stationIds.length} stations`,
|
|
||||||
`Groups ${playerFaction.fleets.length + playerFaction.taskForces.length + playerFaction.stationGroups.length + playerFaction.economicRegions.length + playerFaction.fronts.length + playerFaction.reserves.length}`,
|
|
||||||
`Intent ${playerFaction.strategicIntent.strategicPosture} · ${playerFaction.strategicIntent.economicPosture}`,
|
|
||||||
`Alerts ${playerFaction.alerts.length} · Decisions ${playerFaction.decisionLog.length}`,
|
|
||||||
`Lead ${selectedDirective ? `${selectedDirective.behaviorKind} · ${selectedDirective.scopeKind}` : "no active directives"}`,
|
|
||||||
],
|
|
||||||
priorities: [
|
|
||||||
{ label: "Reserve", value: `${Math.round(playerFaction.strategicIntent.desiredReserveRatio * 100)}%` },
|
|
||||||
{ label: "Auto", value: `${Number(playerFaction.strategicIntent.allowDelegatedEconomicAutomation)}/${Number(playerFaction.strategicIntent.allowDelegatedCombatAutomation)}` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategicState = faction.strategicState;
|
|
||||||
const economic = strategicState.economicAssessment;
|
|
||||||
const activeCampaigns = strategicState.campaigns.filter((campaign) => campaign.status === "active");
|
|
||||||
const activeTheaters = strategicState.theaters.filter((theater) => theater.status === "active");
|
|
||||||
const activeWars = world.geopolitics?.diplomacy.wars.filter((war) => war.factionAId === faction.id || war.factionBId === faction.id).length ?? 0;
|
|
||||||
const contestedSystems = world.geopolitics?.territory.controlStates.filter((state) =>
|
|
||||||
state.isContested && (state.controllerFactionId === faction.id || state.primaryClaimantFactionId === faction.id || state.claimantFactionIds.includes(faction.id))).length ?? 0;
|
|
||||||
const leadCampaign = [...strategicState.campaigns]
|
|
||||||
.sort((left, right) => right.priority - left.priority)[0];
|
|
||||||
const leadTheater = [...strategicState.theaters]
|
|
||||||
.sort((left, right) => right.priority - left.priority)[0];
|
|
||||||
const latestDecision = [...faction.decisionLog]
|
|
||||||
.sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0];
|
|
||||||
return {
|
|
||||||
kind: "faction",
|
|
||||||
id: faction.id,
|
|
||||||
label: faction.label,
|
|
||||||
stateLines: [
|
|
||||||
`Posture ${faction.doctrine.strategicPosture} · ${faction.doctrine.militaryPosture}`,
|
|
||||||
`Campaigns ${activeCampaigns.length} · Fronts ${activeTheaters.length} · Wars ${activeWars}`,
|
|
||||||
`Commit ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} mil · ${economic.minerShipCount}/${economic.targetMinerShipCount} min`,
|
|
||||||
`Reserve ${strategicState.budget.reservedMilitaryAssets} mil · ${strategicState.budget.reservedLogisticsAssets} log`,
|
|
||||||
`Bottleneck ${economic.industrialBottleneckItemId ?? "none"} · Contested ${contestedSystems}${latestDecision ? ` · ${latestDecision.kind}` : ""}`,
|
|
||||||
],
|
|
||||||
priorities: [
|
|
||||||
...(leadCampaign ? [{ label: leadCampaign.kind, value: leadCampaign.priority.toFixed(0) }] : []),
|
|
||||||
...(leadTheater ? [{ label: leadTheater.kind, value: leadTheater.priority.toFixed(0) }] : []),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProgressBar(label: string, progress: number): HudProgressBar {
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
valueLabel: `${Math.round(progress * 100)}%`,
|
|
||||||
progress: Number((progress * 100).toFixed(1)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
|
|
||||||
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
|
||||||
return {
|
|
||||||
kind: "station",
|
|
||||||
id: station.id,
|
|
||||||
label: station.label,
|
|
||||||
badge: station.category,
|
|
||||||
selected: isSelected,
|
|
||||||
lines: [
|
|
||||||
station.systemId,
|
|
||||||
`Docked ${station.dockedShips} / ${station.dockingPads}`,
|
|
||||||
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
|
|
||||||
`Modules ${station.installedModules.length}`,
|
|
||||||
],
|
|
||||||
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShipCard(
|
|
||||||
world: WorldState,
|
|
||||||
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
|
|
||||||
isSelected: boolean,
|
|
||||||
isFollowed: boolean,
|
|
||||||
): OpsShipCardState {
|
|
||||||
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
|
||||||
const shipLocation = describeShipLocation(world, ship);
|
|
||||||
const shipState = describeShipState(world, ship);
|
|
||||||
const shipAction = describeShipCurrentAction(ship);
|
|
||||||
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
|
|
||||||
const topOrder = [...ship.orderQueue]
|
|
||||||
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: "ship",
|
|
||||||
id: ship.id,
|
|
||||||
label: ship.name,
|
|
||||||
badge: ship.type,
|
|
||||||
selected: isSelected,
|
|
||||||
followed: isFollowed,
|
|
||||||
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
|
|
||||||
lines: [
|
|
||||||
`Cargo ${cargo.toFixed(0)}`,
|
|
||||||
`State ${shipState}`,
|
|
||||||
],
|
|
||||||
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
|
|
||||||
aiLines: [
|
|
||||||
`Assignment ${ship.assignment?.kind ?? "unassigned"}`,
|
|
||||||
`Behavior ${getShipBehaviorLabel(ship.defaultBehavior.kind)}`,
|
|
||||||
`Plan ${ship.activePlan ? `${ship.activePlan.kind}${currentStep ? ` · ${currentStep.kind}` : ""}` : "none"}`,
|
|
||||||
`Orders ${topOrder ? `${getShipOrderLabel(topOrder.kind)} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOpsStripState(
|
|
||||||
world: WorldState | undefined,
|
|
||||||
selectedItems: Selectable[],
|
|
||||||
cameraMode: CameraMode,
|
|
||||||
cameraTargetShipId?: string,
|
|
||||||
povLevel?: PovLevel,
|
|
||||||
activeSystemId?: string,
|
|
||||||
): OpsStripState {
|
|
||||||
if (!world) {
|
|
||||||
return {
|
|
||||||
factions: [],
|
|
||||||
stations: [],
|
|
||||||
ships: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
|
|
||||||
|
|
||||||
const factions = [...world.factions.values()]
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map((faction) => buildFactionCard(world, faction));
|
|
||||||
|
|
||||||
const stations = [...world.stations.values()]
|
|
||||||
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
|
||||||
.map((station) => buildStationCard(
|
|
||||||
station,
|
|
||||||
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
|
|
||||||
));
|
|
||||||
|
|
||||||
const ships = [...world.ships.values()]
|
|
||||||
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
|
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
|
||||||
.map((ship) => buildShipCard(
|
|
||||||
world,
|
|
||||||
ship,
|
|
||||||
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
|
|
||||||
cameraMode === "follow" && cameraTargetShipId === ship.id,
|
|
||||||
));
|
|
||||||
|
|
||||||
return { factions, stations, ships };
|
|
||||||
}
|
|
||||||
@@ -346,7 +346,6 @@ export function buildDetailPanelState(params: DetailPanelParams) {
|
|||||||
const shipBehavior = describeShipBehavior(ship);
|
const shipBehavior = describeShipBehavior(ship);
|
||||||
const shipOrder = describeShipOrder(ship);
|
const shipOrder = describeShipOrder(ship);
|
||||||
const shipAction = describeShipCurrentAction(ship);
|
const shipAction = describeShipCurrentAction(ship);
|
||||||
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
|
|
||||||
const orderQueue = ship.orderQueue.length > 0
|
const orderQueue = ship.orderQueue.length > 0
|
||||||
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
|
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
|
||||||
: "none";
|
: "none";
|
||||||
@@ -369,7 +368,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
|
|||||||
<p>State ${shipState}</p>
|
<p>State ${shipState}</p>
|
||||||
<p>Order ${shipOrder}</p>
|
<p>Order ${shipOrder}</p>
|
||||||
<p>Queue ${orderQueue}</p>
|
<p>Queue ${orderQueue}</p>
|
||||||
<p>Plan ${ship.activePlan ? `${ship.activePlan.kind} · ${ship.activePlan.status}` : "none"}${currentStep ? `<br>Step ${currentStep.kind} · ${currentStep.status}` : ""}</p>
|
<p>Activity ${subTaskList}</p>
|
||||||
<p>Subtasks ${subTaskList}</p>
|
<p>Subtasks ${subTaskList}</p>
|
||||||
${ship.lastReplanReason ? `<p>Last replan ${ship.lastReplanReason}</p>` : ""}
|
${ship.lastReplanReason ? `<p>Last replan ${ship.lastReplanReason}</p>` : ""}
|
||||||
${ship.lastAccessFailureReason ? `<p>Access ${ship.lastAccessFailureReason}</p>` : ""}
|
${ship.lastAccessFailureReason ? `<p>Access ${ship.lastAccessFailureReason}</p>` : ""}
|
||||||
@@ -463,7 +462,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
|
|||||||
<p>${celestial.systemId}</p>
|
<p>${celestial.systemId}</p>
|
||||||
<p>Parent ${celestial.parentAnchorId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
<p>Parent ${celestial.parentAnchorId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
|
||||||
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
|
||||||
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
|
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} m</p>
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface ViewerPresentationContext {
|
|||||||
getCameraMode: () => any;
|
getCameraMode: () => any;
|
||||||
getCameraTargetShipId: () => string | undefined;
|
getCameraTargetShipId: () => string | undefined;
|
||||||
getPovLevel: () => any;
|
getPovLevel: () => any;
|
||||||
|
getFocusedAnchorId: () => string | undefined;
|
||||||
getSelectedItems: () => Selectable[];
|
getSelectedItems: () => Selectable[];
|
||||||
getWorldTimeSyncMs: () => number;
|
getWorldTimeSyncMs: () => number;
|
||||||
getCurrentDistance: () => number;
|
getCurrentDistance: () => number;
|
||||||
@@ -106,12 +107,52 @@ export class ViewerPresentationController {
|
|||||||
|
|
||||||
applyZoomPresentation() {
|
applyZoomPresentation() {
|
||||||
const povLevel = this.context.getPovLevel();
|
const povLevel = this.context.getPovLevel();
|
||||||
|
const world = this.context.getWorld();
|
||||||
|
const focusedAnchorId = this.context.getFocusedAnchorId();
|
||||||
|
const focusedAnchor = focusedAnchorId ? world?.anchors.get(focusedAnchorId) : undefined;
|
||||||
|
const focusedPlanetMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)$/);
|
||||||
|
const focusedMoonMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)-moon-(\d+)$/);
|
||||||
|
const focusedPlanetIndex = focusedMoonMatch
|
||||||
|
? Number.parseInt(focusedMoonMatch[1], 10) - 1
|
||||||
|
: (focusedPlanetMatch ? Number.parseInt(focusedPlanetMatch[1], 10) - 1 : undefined);
|
||||||
|
const focusedMoonIndex = focusedMoonMatch ? Number.parseInt(focusedMoonMatch[2], 10) - 1 : undefined;
|
||||||
|
|
||||||
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||||
|
|
||||||
const showPlanetIcons = povLevel !== "local";
|
const showPlanetIcons = povLevel !== "local";
|
||||||
for (const visual of this.context.planetVisuals) {
|
for (const [planetIndex, visual] of this.context.planetVisuals.entries()) {
|
||||||
visual.icon.setVisible(showPlanetIcons);
|
visual.icon.setVisible(showPlanetIcons);
|
||||||
|
if (povLevel === "local") {
|
||||||
|
const showPlanetMesh = focusedAnchor?.kind === "planet"
|
||||||
|
? planetIndex === focusedPlanetIndex
|
||||||
|
: focusedAnchor?.kind === "moon"
|
||||||
|
? planetIndex === focusedPlanetIndex
|
||||||
|
: false;
|
||||||
|
visual.mesh.setVisible(showPlanetMesh);
|
||||||
|
visual.orbit.setVisible(false);
|
||||||
|
if (visual.ring) {
|
||||||
|
visual.ring.setVisible(showPlanetMesh);
|
||||||
|
}
|
||||||
|
for (const [moonIndex, moon] of visual.moons.entries()) {
|
||||||
|
const showMoonMesh = focusedAnchor?.kind === "moon"
|
||||||
|
&& planetIndex === focusedPlanetIndex
|
||||||
|
&& moonIndex === focusedMoonIndex;
|
||||||
|
moon.mesh.setVisible(showMoonMesh);
|
||||||
|
moon.icon.setVisible(false);
|
||||||
|
moon.orbit.setVisible(false);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visual.mesh.setVisible(true);
|
||||||
|
visual.orbit.setVisible(true);
|
||||||
|
if (visual.ring) {
|
||||||
|
visual.ring.setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const systemVisual of this.context.systemVisuals.values()) {
|
||||||
|
systemVisual.starCluster.setVisible(povLevel !== "local" || focusedAnchor?.kind === "star");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import type { ShipSnapshot } from "./contracts";
|
|||||||
export function shipSize(ship: ShipSnapshot) {
|
export function shipSize(ship: ShipSnapshot) {
|
||||||
switch (ship.type) {
|
switch (ship.type) {
|
||||||
case "carrier":
|
case "carrier":
|
||||||
return 0.018;
|
return 18;
|
||||||
case "battleship":
|
case "battleship":
|
||||||
return 0.012;
|
return 12;
|
||||||
case "destroyer":
|
case "destroyer":
|
||||||
return 0.009;
|
return 9;
|
||||||
case "builder":
|
case "builder":
|
||||||
case "freighter":
|
case "freighter":
|
||||||
case "transporter":
|
case "transporter":
|
||||||
case "resupplier":
|
case "resupplier":
|
||||||
case "miner":
|
case "miner":
|
||||||
case "largeminer":
|
case "largeminer":
|
||||||
return 0.01;
|
return 10;
|
||||||
default:
|
default:
|
||||||
return 0.007;
|
return 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
createClaimMesh,
|
createClaimMesh,
|
||||||
createConstructionSiteMesh,
|
createConstructionSiteMesh,
|
||||||
|
createLocalResourceDepositMesh,
|
||||||
|
createLocalResourceNodeMesh,
|
||||||
createNodeMesh,
|
createNodeMesh,
|
||||||
createResourceDepositMesh,
|
createResourceDepositMesh,
|
||||||
createShipMesh,
|
createShipMesh,
|
||||||
@@ -182,7 +184,7 @@ export class ViewerSceneDataController {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mesh = createNodeMesh(node);
|
const mesh = createLocalResourceNodeMesh(node);
|
||||||
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
|
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
|
||||||
const localPosition = new THREE.Vector3(0, 0, 0);
|
const localPosition = new THREE.Vector3(0, 0, 0);
|
||||||
mesh.setPosition(localPosition);
|
mesh.setPosition(localPosition);
|
||||||
@@ -201,7 +203,7 @@ export class ViewerSceneDataController {
|
|||||||
});
|
});
|
||||||
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
|
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
|
||||||
for (const deposit of node.deposits) {
|
for (const deposit of node.deposits) {
|
||||||
const depositMesh = createResourceDepositMesh(deposit, node);
|
const depositMesh = createLocalResourceDepositMesh(deposit, node);
|
||||||
this.context.localNodeGroup.add(rawObject(depositMesh));
|
this.context.localNodeGroup.add(rawObject(depositMesh));
|
||||||
}
|
}
|
||||||
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
|
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
|
||||||
|
|||||||
@@ -64,6 +64,47 @@ export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node
|
|||||||
return createSceneNode(mesh);
|
return createSceneNode(mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createLocalResourceNodeMesh(node: ResourceNodeSnapshot): SceneNode {
|
||||||
|
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
|
||||||
|
const oreRatio = node.maxOre <= 0.01 ? 0 : node.oreRemaining / node.maxOre;
|
||||||
|
const radius = isGas
|
||||||
|
? 120 + (oreRatio * 30)
|
||||||
|
: 55 + (oreRatio * 20);
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(radius, isGas ? 18 : 16, isGas ? 18 : 16),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: isGas ? 0x7fd6ff : 0xb28b59,
|
||||||
|
roughness: isGas ? 0.4 : 0.92,
|
||||||
|
metalness: isGas ? 0.06 : 0.08,
|
||||||
|
transparent: isGas,
|
||||||
|
opacity: isGas ? 0.5 : 1,
|
||||||
|
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xb28b59).multiplyScalar(isGas ? 0.18 : 0.03),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return createSceneNode(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalResourceDepositMesh(deposit: ResourceDepositSnapshot, node: ResourceNodeSnapshot): SceneNode {
|
||||||
|
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
|
||||||
|
const oreRatio = deposit.maxOre <= 0.01 ? 0 : deposit.oreRemaining / deposit.maxOre;
|
||||||
|
const radius = isGas
|
||||||
|
? 8 + (oreRatio * 4)
|
||||||
|
: 3 + (oreRatio * 4);
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(radius, 12, 12),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: isGas ? 0x92deff : 0xd0ad77,
|
||||||
|
roughness: isGas ? 0.36 : 0.95,
|
||||||
|
metalness: isGas ? 0.04 : 0.02,
|
||||||
|
transparent: isGas,
|
||||||
|
opacity: isGas ? 0.58 : 1,
|
||||||
|
emissive: new THREE.Color(isGas ? 0x92deff : 0xd0ad77).multiplyScalar(isGas ? 0.16 : 0.025),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mesh.position.copy(toThreeVector(deposit.localPosition));
|
||||||
|
return createSceneNode(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
|
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
|
||||||
const color = celestialColor(node.kind);
|
const color = celestialColor(node.kind);
|
||||||
return createSceneNode(new THREE.Mesh(
|
return createSceneNode(new THREE.Mesh(
|
||||||
@@ -229,17 +270,31 @@ export function createStationMesh(station: StationSnapshot): SceneNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
|
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
|
||||||
const geometry = new THREE.ConeGeometry(size, length, 7);
|
const root = new THREE.Group();
|
||||||
geometry.rotateX(Math.PI / 2);
|
const material = new THREE.MeshStandardMaterial({
|
||||||
const mesh = new THREE.Mesh(
|
|
||||||
geometry,
|
|
||||||
new THREE.MeshStandardMaterial({
|
|
||||||
color,
|
color,
|
||||||
emissive: new THREE.Color(color).multiplyScalar(0.18),
|
emissive: new THREE.Color(color).multiplyScalar(0.28),
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
const bodyRadius = Math.max(size * 0.48, 2.4);
|
||||||
|
const bodyLength = Math.max(length - (bodyRadius * 1.8), bodyRadius * 1.2);
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.CapsuleGeometry(bodyRadius, bodyLength, 6, 12),
|
||||||
|
material,
|
||||||
);
|
);
|
||||||
mesh.position.copy(toThreeVector(ship.localPosition));
|
body.rotation.x = Math.PI / 2;
|
||||||
return createSceneNode(mesh);
|
root.add(body);
|
||||||
|
|
||||||
|
const nose = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(Math.max(bodyRadius * 0.72, 1.8), Math.max(bodyRadius * 1.4, 3.2), 8),
|
||||||
|
material,
|
||||||
|
);
|
||||||
|
nose.rotation.x = Math.PI / 2;
|
||||||
|
nose.position.z = (bodyLength * 0.5) + (bodyRadius * 0.55);
|
||||||
|
root.add(nose);
|
||||||
|
|
||||||
|
root.position.copy(toThreeVector(ship.localPosition));
|
||||||
|
return createSceneNode(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {
|
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
} from "./viewerScenePrimitives";
|
} from "./viewerScenePrimitives";
|
||||||
import type { SceneNode } from "./viewerScenePrimitives";
|
import type { SceneNode } from "./viewerScenePrimitives";
|
||||||
|
|
||||||
/** Scale a local km position to system-scene display coordinates. */
|
/** Scale a system-space kilometer position to system-scene display coordinates. */
|
||||||
function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
|
function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
|
||||||
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,10 +274,10 @@ export function resolveFocusedAnchorId(world: WorldState | undefined, selectedIt
|
|||||||
return orbitBackedAnchor?.id;
|
return orbitBackedAnchor?.id;
|
||||||
}
|
}
|
||||||
if (selected.kind === "planet") {
|
if (selected.kind === "planet") {
|
||||||
return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
|
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
|
||||||
}
|
}
|
||||||
if (selected.kind === "moon") {
|
if (selected.kind === "moon") {
|
||||||
return `${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
|
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -429,8 +429,7 @@ export function describeShipBehavior(ship: ShipSnapshot): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function describeShipOrder(ship: ShipSnapshot): string {
|
export function describeShipOrder(ship: ShipSnapshot): string {
|
||||||
const activeOrder = [...ship.orderQueue]
|
const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
|
||||||
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
|
||||||
if (activeOrder) {
|
if (activeOrder) {
|
||||||
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
|
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
|
||||||
}
|
}
|
||||||
@@ -439,10 +438,6 @@ export function describeShipOrder(ship: ShipSnapshot): string {
|
|||||||
return describeShipObjective(ship.assignment.kind);
|
return describeShipObjective(ship.assignment.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ship.activePlan) {
|
|
||||||
return ship.activePlan.summary || ship.activePlan.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getShipBehaviorLabel(ship.defaultBehavior.kind);
|
return getShipBehaviorLabel(ship.defaultBehavior.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||||
import type { ViewerHudState } from "./viewerHudState";
|
import type { ViewerHudState } from "./viewerHudState";
|
||||||
import { buildOpsStripState } from "./viewerOpsStrip";
|
|
||||||
import { useGmStore } from "./ui/stores/gmStore";
|
import { useGmStore } from "./ui/stores/gmStore";
|
||||||
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
||||||
import { viewerPinia } from "./ui/stores/pinia";
|
import { viewerPinia } from "./ui/stores/pinia";
|
||||||
@@ -193,15 +192,6 @@ export class ViewerWorldLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rebuildFactions(_factions: FactionSnapshot[]) {
|
rebuildFactions(_factions: FactionSnapshot[]) {
|
||||||
this.context.hudState.opsStrip = buildOpsStripState(
|
|
||||||
this.context.getWorld(),
|
|
||||||
this.context.getSelectedItems(),
|
|
||||||
this.context.getCameraMode(),
|
|
||||||
this.context.getCameraTargetShipId(),
|
|
||||||
this.context.getPovLevel(),
|
|
||||||
this.context.getActiveSystemId(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const world = this.context.getWorld();
|
const world = this.context.getWorld();
|
||||||
if (world) {
|
if (world) {
|
||||||
useGmStore(viewerPinia).updateWorld(
|
useGmStore(viewerPinia).updateWorld(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DISPLAY_UNITS_PER_KILOMETER,
|
DISPLAY_UNITS_PER_KILOMETER,
|
||||||
DISPLAY_UNITS_PER_LIGHT_YEAR,
|
DISPLAY_UNITS_PER_LIGHT_YEAR,
|
||||||
KILOMETERS_PER_AU,
|
KILOMETERS_PER_AU,
|
||||||
|
METERS_PER_KILOMETER,
|
||||||
computeMoonLocalPosition,
|
computeMoonLocalPosition,
|
||||||
computePlanetLocalPosition,
|
computePlanetLocalPosition,
|
||||||
currentWorldTimeSeconds,
|
currentWorldTimeSeconds,
|
||||||
@@ -137,8 +138,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localPosition = getAnimatedShipLocalPosition(visual, now);
|
const localPosition = getAnimatedShipLocalPosition(visual, now);
|
||||||
const displayPosition = context.toDisplayLocalPosition(localPosition);
|
visual.mesh.setPosition(localPosition);
|
||||||
visual.mesh.setPosition(displayPosition);
|
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(renderMode === "local");
|
visual.mesh.setVisible(renderMode === "local");
|
||||||
visual.icon.setVisible(renderMode === "local");
|
visual.icon.setVisible(renderMode === "local");
|
||||||
@@ -201,29 +201,28 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.localNodeVisuals.values()) {
|
for (const visual of context.localNodeVisuals.values()) {
|
||||||
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
|
visual.mesh.setPosition(visual.localPosition.clone());
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(renderMode === "local");
|
visual.mesh.setVisible(renderMode === "local");
|
||||||
visual.icon.setVisible(renderMode === "local");
|
visual.icon.setVisible(renderMode === "local");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.localStationVisuals.values()) {
|
for (const visual of context.localStationVisuals.values()) {
|
||||||
const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
|
visual.mesh.setPosition(visual.localPosition.clone());
|
||||||
visual.mesh.setPosition(displayPosition);
|
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(renderMode === "local");
|
visual.mesh.setVisible(renderMode === "local");
|
||||||
visual.icon.setVisible(renderMode === "local");
|
visual.icon.setVisible(renderMode === "local");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.localClaimVisuals.values()) {
|
for (const visual of context.localClaimVisuals.values()) {
|
||||||
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
|
visual.mesh.setPosition(visual.localPosition.clone());
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(renderMode === "local");
|
visual.mesh.setVisible(renderMode === "local");
|
||||||
visual.icon.setVisible(renderMode === "local");
|
visual.icon.setVisible(renderMode === "local");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const visual of context.localConstructionSiteVisuals.values()) {
|
for (const visual of context.localConstructionSiteVisuals.values()) {
|
||||||
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
|
visual.mesh.setPosition(visual.localPosition.clone());
|
||||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||||
visual.mesh.setVisible(renderMode === "local");
|
visual.mesh.setVisible(renderMode === "local");
|
||||||
visual.icon.setVisible(renderMode === "local");
|
visual.icon.setVisible(renderMode === "local");
|
||||||
@@ -409,16 +408,12 @@ export function describeGameStatus(params: GameStatusParams) {
|
|||||||
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
|
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
|
||||||
: "";
|
: "";
|
||||||
// System space: systemAnchor in AU — changes only during system navigation
|
// System space: systemAnchor in AU — changes only during system navigation
|
||||||
const sysPos = systemAnchor
|
const sysPos = povLevel !== "local" && systemAnchor
|
||||||
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
|
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
|
||||||
: "";
|
: "";
|
||||||
// Local space: position relative to the focused celestial's orbital anchor in km
|
// Local space: local focus in meters relative to the focused anchor
|
||||||
const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
|
const locPos = povLevel === "local" && systemAnchor
|
||||||
const celestialAnchor = focusedAnchorId
|
? `loc pos: ${fmtVec(systemAnchor, 0)} m`
|
||||||
? (world?.anchors.get(focusedAnchorId)?.systemPosition ?? world?.celestials.get(focusedAnchorId)?.orbitalAnchor)
|
|
||||||
: undefined;
|
|
||||||
const locPos = systemAnchor && celestialAnchor
|
|
||||||
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
|
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -445,6 +440,33 @@ export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveLocalAnchorOffset(world: WorldState | undefined, focusedAnchorId?: string): THREE.Vector3 {
|
||||||
|
if (!world || !focusedAnchorId) {
|
||||||
|
return new THREE.Vector3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = world.anchors.get(focusedAnchorId);
|
||||||
|
if (!anchor) {
|
||||||
|
return new THREE.Vector3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.kind === "lagrange-point" || anchor.kind === "resource-node") {
|
||||||
|
return new THREE.Vector3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyRadiusMeters = resolveAnchorBodyRadius(world, anchor) * METERS_PER_KILOMETER;
|
||||||
|
if (bodyRadiusMeters <= 1) {
|
||||||
|
return new THREE.Vector3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeOffset = Math.min(
|
||||||
|
Math.max(bodyRadiusMeters * 1.08, 120),
|
||||||
|
Math.max(anchor.localSpaceRadius * 0.55, 300),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new THREE.Vector3(-safeOffset, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveNodeOrbital(
|
export function deriveNodeOrbital(
|
||||||
context: WorldOrbitalContext,
|
context: WorldOrbitalContext,
|
||||||
node: ResourceNodeSnapshot | ResourceNodeDelta,
|
node: ResourceNodeSnapshot | ResourceNodeDelta,
|
||||||
@@ -536,6 +558,32 @@ export function resolvePointPosition(context: WorldOrbitalContext, _systemId: st
|
|||||||
return new THREE.Vector3(0, 0, 0);
|
return new THREE.Vector3(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAnchorBodyRadius(world: WorldState, anchor: { id: string; systemId: string; kind: string }) {
|
||||||
|
const system = world.systems.get(anchor.systemId);
|
||||||
|
if (!system) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.kind === "star") {
|
||||||
|
return system.stars[0]?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planetMatch = /^node-[^-]+-planet-(\d+)$/.exec(anchor.id);
|
||||||
|
if (planetMatch) {
|
||||||
|
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
|
||||||
|
return system.planets[planetIndex]?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moonMatch = /^node-[^-]+-planet-(\d+)-moon-(\d+)$/.exec(anchor.id);
|
||||||
|
if (moonMatch) {
|
||||||
|
const planetIndex = Number.parseInt(moonMatch[1], 10) - 1;
|
||||||
|
const moonIndex = Number.parseInt(moonMatch[2], 10) - 1;
|
||||||
|
return system.planets[planetIndex]?.moons[moonIndex]?.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
|
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
|
||||||
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
|
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
26
docs/VALIDATION-WORKSHEET.md
Normal file
26
docs/VALIDATION-WORKSHEET.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
## Improve CreateFactionForm
|
||||||
|
|
||||||
|
Confirm whether GM faction creation is limited to predefined faction ids or is incorrectly restricted to `terran`. Improve the GM faction creation flow so it presents valid faction choices clearly instead of relying on unclear freeform id edits.
|
||||||
|
From: V-010 validation
|
||||||
|
|
||||||
|
## Improve GM Ship Spawn Options
|
||||||
|
|
||||||
|
Extend the GM ship spawn form so it allows choosing what kind of ship to create instead of always spawning the same default result. At minimum, support a few clear presets such as hauler, fighter, miner, and builder. Longer term, allow selecting the ship type directly and optionally configuring modules before spawn.
|
||||||
|
From: V-011 validation, V-013 validation
|
||||||
|
|
||||||
|
## Spawn GM Ships In A Neutral Starting State
|
||||||
|
|
||||||
|
Spawn GM ships with a minimal default behavior such as `hold-position` instead of immediately assigning `local-auto-mine`. Newly created ships should start in a predictable manual-control state unless the GM explicitly asks for another behavior.
|
||||||
|
From: V-011 validation
|
||||||
|
|
||||||
|
## Spawn GM Ships At A Chosen Anchor
|
||||||
|
|
||||||
|
Extend GM ship spawning so the GM can choose an anchor, not just a system. Ships should spawn into the selected anchor's localspace at a safe non-colliding position.
|
||||||
|
From: V-011 validation
|
||||||
|
|
||||||
|
## Improve GM Station Spawn Options
|
||||||
|
|
||||||
|
Add a proper station spawn flow or form so the GM can configure the station before creating it. The spawn flow should allow choosing the station role or preset and selecting its intended location before it appears in the world.
|
||||||
|
From: V-012 validation
|
||||||
713
docs/VALIDATION.md
Normal file
713
docs/VALIDATION.md
Normal file
@@ -0,0 +1,713 @@
|
|||||||
|
# Manual Validation Plan
|
||||||
|
|
||||||
|
This document defines the manual validation passes to run against the current game basis.
|
||||||
|
|
||||||
|
It is intentionally focused on behavior validation, not implementation details.
|
||||||
|
|
||||||
|
The goal is to verify that the simulation can perform the core actions of the game correctly before writing deeper automated simulation tests.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This validation plan answers the following questions:
|
||||||
|
|
||||||
|
- does the world boot cleanly and reproducibly
|
||||||
|
- can we create the minimum actors needed to exercise gameplay
|
||||||
|
- can ships receive and complete direct orders
|
||||||
|
- can ships run supported default behaviors without getting stuck
|
||||||
|
- do movement, mining, docking, and combat work at the simulation level
|
||||||
|
- does the viewer reflect the same state the backend is executing
|
||||||
|
|
||||||
|
This document is the manual test source of truth for the current phase.
|
||||||
|
|
||||||
|
Later, these same checks should become simulation-first tests running directly against the real runtime.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This phase is intentionally centered on `empty.json`.
|
||||||
|
|
||||||
|
That is correct for now.
|
||||||
|
|
||||||
|
The purpose of `empty.json` is to validate primitive actions and control behavior with minimal scenario noise.
|
||||||
|
|
||||||
|
It is not yet the basis for validating full economy, expansion, or long-horizon faction behavior.
|
||||||
|
|
||||||
|
Those should be validated later using richer scenarios after the primitives are trustworthy.
|
||||||
|
|
||||||
|
## Current Baseline
|
||||||
|
|
||||||
|
Development startup currently loads:
|
||||||
|
|
||||||
|
- [`shared/data/scenarios/empty.json`](/home/jbourdon/repos/space-game/shared/data/scenarios/empty.json)
|
||||||
|
|
||||||
|
The backend startup path is defined in:
|
||||||
|
|
||||||
|
- [`apps/backend/Program.cs`](/home/jbourdon/repos/space-game/apps/backend/Program.cs)
|
||||||
|
|
||||||
|
World reset returns to the startup scenario through:
|
||||||
|
|
||||||
|
- [`apps/backend/Universe/Api/ResetWorldHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Universe/Api/ResetWorldHandler.cs)
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Manual runs should use a reproducible local development setup.
|
||||||
|
|
||||||
|
Suggested startup:
|
||||||
|
|
||||||
|
1. Start postgres with `./scripts/start-postgres.sh`
|
||||||
|
2. Start backend in development mode
|
||||||
|
3. Start the viewer
|
||||||
|
4. Log in as a GM user
|
||||||
|
5. Reset the world before each test pass
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
|
||||||
|
- [`scripts/start-postgres.sh`](/home/jbourdon/repos/space-game/scripts/start-postgres.sh)
|
||||||
|
- [`apps/backend/appsettings.Development.json`](/home/jbourdon/repos/space-game/apps/backend/appsettings.Development.json)
|
||||||
|
- [`apps/viewer/package.json`](/home/jbourdon/repos/space-game/apps/viewer/package.json)
|
||||||
|
|
||||||
|
Development GM credentials currently include:
|
||||||
|
|
||||||
|
- `gm` / `gm`
|
||||||
|
- `admin` / `admin`
|
||||||
|
|
||||||
|
## Test Method
|
||||||
|
|
||||||
|
Each manual test should record:
|
||||||
|
|
||||||
|
- setup
|
||||||
|
- action
|
||||||
|
- expected result
|
||||||
|
- observed result
|
||||||
|
- pass or fail
|
||||||
|
- notes
|
||||||
|
|
||||||
|
Recommended rule:
|
||||||
|
|
||||||
|
- if a test leaves the world in a noisy or questionable state, reset before the next test
|
||||||
|
|
||||||
|
Recommended evidence to capture:
|
||||||
|
|
||||||
|
- ship state
|
||||||
|
- ship spatial state
|
||||||
|
- active plan and subtasks
|
||||||
|
- order queue
|
||||||
|
- inventory changes
|
||||||
|
- station docking state
|
||||||
|
- viewer selection and inspector state
|
||||||
|
|
||||||
|
## Phase 1: Boot And Baseline
|
||||||
|
|
||||||
|
These tests must pass before behavior testing has value.
|
||||||
|
|
||||||
|
### V-001 Backend boots cleanly
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
- start backend in development mode
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- startup succeeds
|
||||||
|
- auth schema initializes
|
||||||
|
- dev users seed
|
||||||
|
- world loads from `empty.json`
|
||||||
|
- no startup exception is thrown
|
||||||
|
|
||||||
|
### V-002 Viewer connects and renders world
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
- start viewer and open the app
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- world snapshot loads
|
||||||
|
- live delta stream connects
|
||||||
|
- no obvious contract mismatch or rendering crash appears
|
||||||
|
|
||||||
|
### V-003 Reset returns world to clean baseline
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
- use the GM reset action
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- world returns to startup scenario
|
||||||
|
- previously spawned factions, ships, and stations are gone
|
||||||
|
- sequence and snapshot refresh behave cleanly
|
||||||
|
|
||||||
|
### V-004 Empty world is actually minimal
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
- inspect the world after reset
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- systems, celestials, anchors, and resource nodes exist
|
||||||
|
- no initial factions, stations, or ships exist unless intentionally seeded later
|
||||||
|
|
||||||
|
## Phase 2: Minimal Actor Creation
|
||||||
|
|
||||||
|
These tests prove the empty world can be turned into a controlled validation sandbox.
|
||||||
|
|
||||||
|
### V-010 Create a faction
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- use the GM faction creation flow
|
||||||
|
|
||||||
|
Relevant API:
|
||||||
|
|
||||||
|
- `POST /api/gm/factions`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the faction appears in the world
|
||||||
|
- it is visible in the GM UI
|
||||||
|
- no duplicate or invalid-creation error occurs for a valid faction id
|
||||||
|
|
||||||
|
### V-011 Spawn a ship
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- spawn a ship for the created faction in a known system
|
||||||
|
|
||||||
|
Relevant API:
|
||||||
|
|
||||||
|
- `POST /api/gm/ships`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the ship appears in the selected system
|
||||||
|
- the ship has a valid id, faction, system, and spatial state
|
||||||
|
- the viewer can select and inspect it
|
||||||
|
|
||||||
|
### V-012 Spawn a station
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- spawn a station for the created faction in a known system
|
||||||
|
|
||||||
|
Relevant API:
|
||||||
|
|
||||||
|
- `POST /api/gm/stations`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the station appears in the world
|
||||||
|
- the station has a valid anchor association or valid placement according to current runtime rules
|
||||||
|
- the viewer can focus and inspect it
|
||||||
|
|
||||||
|
### V-013 Spawn multiple ships of different roles
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- create at least:
|
||||||
|
- one miner-capable ship
|
||||||
|
- one combat-capable ship
|
||||||
|
- one generic utility or trader if available
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- each ship spawns without corrupting world state
|
||||||
|
- each ship reports sensible movement, cargo, and behavior fields
|
||||||
|
|
||||||
|
## Phase 3: Direct Order Validation
|
||||||
|
|
||||||
|
This phase validates immediate control and plan execution.
|
||||||
|
|
||||||
|
Relevant backend surface:
|
||||||
|
|
||||||
|
- [`apps/backend/Ships/Contracts/ShipCommands.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/ShipCommands.cs)
|
||||||
|
- [`apps/backend/Ships/Contracts/Ships.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/Ships.cs)
|
||||||
|
- [`apps/backend/Ships/Api/EnqueueShipOrderHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs)
|
||||||
|
|
||||||
|
### V-020 Queue a move or fly order
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- issue a direct move-style order to a ship
|
||||||
|
- prefer `fly-and-wait` through the current viewer flow
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the order appears in the queue
|
||||||
|
- an active plan is created
|
||||||
|
- subtasks are coherent
|
||||||
|
- the ship moves toward the target
|
||||||
|
- the order eventually completes
|
||||||
|
|
||||||
|
Watch for:
|
||||||
|
|
||||||
|
- ship never leaving idle
|
||||||
|
- plan created but no subtask progress
|
||||||
|
- target position mismatch
|
||||||
|
- order stays executing forever
|
||||||
|
|
||||||
|
### V-021 Queue follow ship
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- spawn two ships
|
||||||
|
- issue `follow-ship` from one to the other
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the follower tracks the target ship
|
||||||
|
- the follower updates position as the target moves
|
||||||
|
- no oscillation or runaway drift appears
|
||||||
|
|
||||||
|
### V-022 Queue attack target
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- spawn two ships from opposing factions if required by current hostility logic
|
||||||
|
- issue `attack-target`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- order is accepted
|
||||||
|
- attacker closes to engagement range
|
||||||
|
- combat state transitions occur
|
||||||
|
- health changes on the target if combat is functioning
|
||||||
|
|
||||||
|
Watch for:
|
||||||
|
|
||||||
|
- invalid target acceptance
|
||||||
|
- attacker never approaching
|
||||||
|
- attacker stuck in transit or wait state
|
||||||
|
- combat order silently failing
|
||||||
|
|
||||||
|
### V-023 Queue mine resource
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- issue `mine-and-deliver` against a valid resource in the current system
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship selects a valid resource node or deposit
|
||||||
|
- ship reaches the mining location
|
||||||
|
- mining progress occurs
|
||||||
|
- cargo increases
|
||||||
|
- delivery or post-mining behavior is coherent
|
||||||
|
|
||||||
|
Watch for:
|
||||||
|
|
||||||
|
- no valid mining target selected
|
||||||
|
- ship arrives but never mines
|
||||||
|
- cargo remains unchanged
|
||||||
|
- order fails with missing target when a target exists
|
||||||
|
|
||||||
|
### V-024 Queue dock and wait if station exists
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- spawn a station
|
||||||
|
- issue a docking-capable order path
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship requests or performs docking
|
||||||
|
- docked state is visible
|
||||||
|
- station dock count updates
|
||||||
|
- undocking or wait completion works
|
||||||
|
|
||||||
|
### V-025 Remove an order
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- queue an order, then remove it before completion
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the order is removed cleanly
|
||||||
|
- the ship replans safely
|
||||||
|
- the ship returns to default behavior or idle state
|
||||||
|
- no orphan active subtasks remain
|
||||||
|
|
||||||
|
## Phase 4: Default Behavior Validation
|
||||||
|
|
||||||
|
This phase validates autonomous ship control rather than one-shot direct orders.
|
||||||
|
|
||||||
|
Relevant backend surface:
|
||||||
|
|
||||||
|
- [`apps/backend/Shared/Runtime/ShipAutomationCatalog.cs`](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs)
|
||||||
|
- [`apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs)
|
||||||
|
|
||||||
|
Relevant viewer surfaces:
|
||||||
|
|
||||||
|
- [`apps/viewer/src/components/ViewerEntityInspectorPanel.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue)
|
||||||
|
- [`apps/viewer/src/components/ViewerShipOrderContextMenu.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue)
|
||||||
|
|
||||||
|
### V-030 Hold position
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- set default behavior to `hold-position`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship remains stable
|
||||||
|
- behavior is reflected in inspector state
|
||||||
|
- no unintended autonomous orders are generated
|
||||||
|
|
||||||
|
### V-031 Fly and wait behavior
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- set default behavior to `fly-and-wait`
|
||||||
|
- provide a valid target position or object
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- behavior-backed order is synthesized
|
||||||
|
- ship moves to the target
|
||||||
|
- ship waits as configured
|
||||||
|
- behavior continues to own control after completion
|
||||||
|
|
||||||
|
### V-032 Follow ship behavior
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- set default behavior to `follow-ship`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- managed follow behavior is generated
|
||||||
|
- the ship stays near its target within reasonable tolerance
|
||||||
|
|
||||||
|
### V-033 Patrol behavior
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- configure patrol points if supported through current UI flow
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- patrol orders are generated from the behavior
|
||||||
|
- ship cycles patrol movement cleanly
|
||||||
|
- if a threat appears, patrol can interrupt into attack behavior as intended
|
||||||
|
|
||||||
|
### V-034 Local auto mine
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- set `local-auto-mine` on a miner in a system with mineable nodes
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- if a valid local mining target exists, the ship mines it
|
||||||
|
- if no valid target exists, failure is readable and not destructive
|
||||||
|
|
||||||
|
Note:
|
||||||
|
|
||||||
|
- current catalog marks this as partially supported
|
||||||
|
|
||||||
|
### V-035 Advanced or expert auto mine
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- set `advanced-auto-mine` or `expert-auto-mine`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- behavior synthesizes a mine-and-deliver run
|
||||||
|
- ship selects a resource source and delivery path
|
||||||
|
- behavior can repeat after completion
|
||||||
|
|
||||||
|
### V-036 Combat guard behaviors
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- validate one or more of:
|
||||||
|
- `protect-position`
|
||||||
|
- `protect-ship`
|
||||||
|
- `protect-station`
|
||||||
|
- `police`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- behavior creates managed guard or intercept orders
|
||||||
|
- threat response is coherent
|
||||||
|
- ship returns to guarding behavior after engagement if still valid
|
||||||
|
|
||||||
|
## Phase 5: Spatial And Transit Validation
|
||||||
|
|
||||||
|
This phase validates the new universe-model runtime behavior.
|
||||||
|
|
||||||
|
Primary concern:
|
||||||
|
|
||||||
|
- ships should behave as anchor-aware entities rather than generic free-flying system dots
|
||||||
|
|
||||||
|
### V-040 Spatial state is coherent at rest
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- a resting ship reports a sensible `SpatialState`
|
||||||
|
- `SpaceLayer`, `CurrentSystemId`, `CurrentAnchorId`, and `MovementRegime` agree with the visible world state
|
||||||
|
|
||||||
|
### V-041 Local movement remains local
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- local movement updates local position coherently
|
||||||
|
- the ship does not accidentally enter invalid transit state
|
||||||
|
|
||||||
|
### V-042 Intra-system transit is explicit
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- send a ship between distant anchors if the current order flow supports it
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- movement regime transitions are explicit
|
||||||
|
- transit state reports origin, destination, and progress
|
||||||
|
- arrival returns the ship to a valid anchor-local state
|
||||||
|
|
||||||
|
### V-043 Inter-system travel if available
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- attempt a cross-system route through current supported mechanics
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- system change happens through a coherent transit path
|
||||||
|
- no entity duplication or dropped ship occurs
|
||||||
|
|
||||||
|
## Phase 6: Docking, Cargo, And Station Interaction
|
||||||
|
|
||||||
|
These tests prove basic station interaction works.
|
||||||
|
|
||||||
|
### V-050 Docking updates both sides
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship shows docked station id
|
||||||
|
- station docked ship list updates
|
||||||
|
- dock count changes are visible in the viewer
|
||||||
|
|
||||||
|
### V-051 Cargo transfer changes inventory
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- use a mining or delivery flow involving a station
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship inventory changes
|
||||||
|
- station inventory changes
|
||||||
|
- transfer is not purely cosmetic
|
||||||
|
|
||||||
|
### V-052 Invalid docking fails cleanly
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- attempt docking or a delivery path with a ship or station that should not support it
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- failure is visible and readable
|
||||||
|
- ship does not become stuck in permanent docking state
|
||||||
|
|
||||||
|
## Phase 7: Combat Validation
|
||||||
|
|
||||||
|
These tests are still primitive in the empty-world phase.
|
||||||
|
|
||||||
|
The goal is not full tactical balance.
|
||||||
|
|
||||||
|
The goal is to prove the combat loop exists and behaves coherently.
|
||||||
|
|
||||||
|
### V-060 Attack order enters engagement
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- attacker closes on target
|
||||||
|
- attack state appears
|
||||||
|
- target health changes if weapons and hostility permit combat
|
||||||
|
|
||||||
|
### V-061 Combat resolves to a stable end state
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- one of the following happens cleanly:
|
||||||
|
- target destroyed
|
||||||
|
- attacker disengages
|
||||||
|
- order fails with a readable reason
|
||||||
|
|
||||||
|
No permanent broken state should remain.
|
||||||
|
|
||||||
|
### V-062 Non-combat ship does not behave like a combat ship
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- issue combat pressure to a non-combat or civilian ship if possible
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- behavior is limited, defensive, or clearly incapable
|
||||||
|
- it should not unrealistically perform like a dedicated combat hull unless current design says it can
|
||||||
|
|
||||||
|
## Phase 8: Invalid And Edge Cases
|
||||||
|
|
||||||
|
These are mandatory because many simulation regressions hide in failure handling rather than happy paths.
|
||||||
|
|
||||||
|
### V-070 Invalid target order
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- send an order with a missing or invalid target
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- backend rejects the order or marks it failed cleanly
|
||||||
|
- no corrupted plan remains
|
||||||
|
|
||||||
|
### V-071 Remove target during execution
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- destroy or invalidate the target context while a ship is executing an order
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ship replans or fails safely
|
||||||
|
- no null-state or endless execution loop appears
|
||||||
|
|
||||||
|
### V-072 Reset during active simulation
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- reset the world while ships are active
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- viewer refreshes cleanly
|
||||||
|
- no stale selected entity state causes crashes
|
||||||
|
- world stream recovers to fresh baseline
|
||||||
|
|
||||||
|
### V-073 Behavior with impossible prerequisites
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- assign a behavior that requires a target, station, or ware that is not available
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- failure is readable
|
||||||
|
- ship falls back safely
|
||||||
|
- behavior does not create runaway order spam
|
||||||
|
|
||||||
|
## Phase 9: Viewer-State Validation
|
||||||
|
|
||||||
|
The simulation may be correct while the viewer is misleading.
|
||||||
|
|
||||||
|
That is still a failure.
|
||||||
|
|
||||||
|
### V-080 Inspector reflects real ship state
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- order queue, active plan, subtasks, inventory, health, and spatial state match observed behavior
|
||||||
|
|
||||||
|
### V-081 Selection survives world updates
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- selecting a ship or station remains stable through normal delta updates
|
||||||
|
|
||||||
|
### V-082 Focus and follow modes remain usable
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- camera focus and tracking do not break during movement, docking, or combat
|
||||||
|
|
||||||
|
### V-083 Context actions target the intended entity
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
- use context menu actions such as:
|
||||||
|
- mine resource
|
||||||
|
- fly to and wait
|
||||||
|
- follow ship
|
||||||
|
- attack
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- the generated order matches the selected target
|
||||||
|
- the resulting ship action matches the command label
|
||||||
|
|
||||||
|
## Recommended Manual Run Order
|
||||||
|
|
||||||
|
Run in this order:
|
||||||
|
|
||||||
|
1. boot and reset validation
|
||||||
|
2. faction creation
|
||||||
|
3. ship spawn
|
||||||
|
4. station spawn
|
||||||
|
5. direct navigation order
|
||||||
|
6. direct mining order
|
||||||
|
7. docking and delivery
|
||||||
|
8. direct attack order
|
||||||
|
9. default behavior checks
|
||||||
|
10. edge and failure checks
|
||||||
|
11. viewer consistency pass
|
||||||
|
|
||||||
|
## Minimum Pass Criteria
|
||||||
|
|
||||||
|
The current basis of the game should be considered working only if all of the following are true:
|
||||||
|
|
||||||
|
- world startup and reset are reliable
|
||||||
|
- actors can be spawned into an empty baseline
|
||||||
|
- at least one ship can move successfully
|
||||||
|
- at least one ship can mine successfully
|
||||||
|
- at least one ship can attack successfully
|
||||||
|
- at least one ship can dock and transfer inventory successfully
|
||||||
|
- direct orders can be added and removed cleanly
|
||||||
|
- default behaviors can control ships without obvious stuck states
|
||||||
|
- viewer state remains trustworthy during all of the above
|
||||||
|
|
||||||
|
## Failure Reporting
|
||||||
|
|
||||||
|
When a test fails, record:
|
||||||
|
|
||||||
|
- test id
|
||||||
|
- exact setup
|
||||||
|
- exact action taken
|
||||||
|
- whether failure happened in backend, simulation behavior, or viewer representation
|
||||||
|
- whether reset recovers the world cleanly
|
||||||
|
- likely regression area if visible from inspector or logs
|
||||||
|
|
||||||
|
Suggested failure categories:
|
||||||
|
|
||||||
|
- startup
|
||||||
|
- API contract
|
||||||
|
- planning
|
||||||
|
- subtask execution
|
||||||
|
- movement
|
||||||
|
- docking
|
||||||
|
- mining
|
||||||
|
- combat
|
||||||
|
- inventory
|
||||||
|
- viewer sync
|
||||||
|
- reset or stream lifecycle
|
||||||
|
|
||||||
|
## Follow-Up
|
||||||
|
|
||||||
|
After this manual pass stabilizes:
|
||||||
|
|
||||||
|
1. turn the most important Phase 1 through Phase 4 checks into runtime-level simulation tests
|
||||||
|
2. prefer real simulation execution over mocked unit tests
|
||||||
|
3. add richer scenario validation only after primitive behavior passes consistently
|
||||||
|
|
||||||
|
That next phase should validate composed loops:
|
||||||
|
|
||||||
|
- mine -> dock -> unload
|
||||||
|
- trade route -> station inventory update
|
||||||
|
- construction support
|
||||||
|
- guard and intercept response
|
||||||
|
- longer-run autonomous behavior without manual intervention
|
||||||
66
shared/data/scenarios/minimal.json
Normal file
66
shared/data/scenarios/minimal.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"worldGeneration": {
|
||||||
|
"seed": 1,
|
||||||
|
"targetSystemCount": 1,
|
||||||
|
"useKnownSystems": false,
|
||||||
|
"aiControllerFactionCount": 0,
|
||||||
|
"generatePlayerFaction": false
|
||||||
|
},
|
||||||
|
"systems": [
|
||||||
|
{
|
||||||
|
"id": "minimal",
|
||||||
|
"label": "Minimal Test System",
|
||||||
|
"position": [0, 0, 0],
|
||||||
|
"stars": [
|
||||||
|
{
|
||||||
|
"kind": "main-sequence",
|
||||||
|
"color": "#fff1b8",
|
||||||
|
"glow": "#ffd35a",
|
||||||
|
"size": 420000,
|
||||||
|
"orbitRadius": 0,
|
||||||
|
"orbitSpeed": 0,
|
||||||
|
"orbitPhaseAtEpoch": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"asteroidField": {
|
||||||
|
"decorationCount": 0,
|
||||||
|
"radiusOffset": 0,
|
||||||
|
"radiusVariance": 0,
|
||||||
|
"heightVariance": 0
|
||||||
|
},
|
||||||
|
"resourceNodes": [
|
||||||
|
{
|
||||||
|
"sourceKind": "asteroid-belt",
|
||||||
|
"angle": 0.6,
|
||||||
|
"radiusOffset": 180000,
|
||||||
|
"inclinationDegrees": 3,
|
||||||
|
"oreAmount": 12000,
|
||||||
|
"itemId": "ore",
|
||||||
|
"shardCount": 9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"planets": [
|
||||||
|
{
|
||||||
|
"label": "Primer",
|
||||||
|
"planetType": "terrestrial",
|
||||||
|
"shape": "sphere",
|
||||||
|
"moons": [],
|
||||||
|
"orbitRadius": 0.8,
|
||||||
|
"orbitSpeed": 0.14,
|
||||||
|
"orbitEccentricity": 0.01,
|
||||||
|
"orbitInclination": 0,
|
||||||
|
"orbitLongitudeOfAscendingNode": 0,
|
||||||
|
"orbitArgumentOfPeriapsis": 0,
|
||||||
|
"orbitPhaseAtEpoch": 0,
|
||||||
|
"size": 6200,
|
||||||
|
"color": "#6ea7d4",
|
||||||
|
"tilt": 0,
|
||||||
|
"hasRing": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"initialStations": [],
|
||||||
|
"shipFormations": [],
|
||||||
|
"patrolRoutes": []
|
||||||
|
}
|
||||||
153
tests/backend/ShipAiServiceExecutionTests.cs
Normal file
153
tests/backend/ShipAiServiceExecutionTests.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SpaceGame.Api.Definitions;
|
||||||
|
using SpaceGame.Api.Industry.Planning;
|
||||||
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Ships.AI;
|
||||||
|
using SpaceGame.Api.Ships.Runtime;
|
||||||
|
using SpaceGame.Api.Universe.Contracts;
|
||||||
|
using SpaceGame.Api.Universe.Runtime;
|
||||||
|
using SpaceGame.Api.Universe.Simulation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ShipAiServiceExecutionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MoveOrder_CompletesAndIsRemovedAfterArrival()
|
||||||
|
{
|
||||||
|
var ship = new ShipRuntime
|
||||||
|
{
|
||||||
|
Id = "ship-1",
|
||||||
|
SystemId = "sys-1",
|
||||||
|
Definition = CreateShipDefinition(),
|
||||||
|
FactionId = "player",
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
TargetPosition = Vector3.Zero,
|
||||||
|
SpatialState = new ShipSpatialStateRuntime
|
||||||
|
{
|
||||||
|
CurrentSystemId = "sys-1",
|
||||||
|
CurrentAnchorId = null,
|
||||||
|
LocalPosition = Vector3.Zero,
|
||||||
|
SystemPosition = Vector3.Zero,
|
||||||
|
},
|
||||||
|
DefaultBehavior = new DefaultBehaviorRuntime
|
||||||
|
{
|
||||||
|
Kind = ShipBehaviorKinds.Idle,
|
||||||
|
},
|
||||||
|
Skills = new ShipSkillProfileRuntime
|
||||||
|
{
|
||||||
|
Navigation = 3,
|
||||||
|
Trade = 3,
|
||||||
|
Mining = 3,
|
||||||
|
Combat = 3,
|
||||||
|
Construction = 3,
|
||||||
|
},
|
||||||
|
Health = 100f,
|
||||||
|
};
|
||||||
|
|
||||||
|
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||||
|
{
|
||||||
|
Id = "move-1",
|
||||||
|
Kind = ShipOrderKinds.Move,
|
||||||
|
SourceKind = ShipOrderSourceKind.Player,
|
||||||
|
SourceId = "test",
|
||||||
|
Label = "Fly to point",
|
||||||
|
TargetSystemId = "sys-1",
|
||||||
|
TargetPosition = new Vector3(5f, 0f, 0f),
|
||||||
|
});
|
||||||
|
|
||||||
|
var world = CreateWorld(ship);
|
||||||
|
var service = new ShipAiService(new TestBalanceService());
|
||||||
|
var events = new List<SimulationEventRecord>();
|
||||||
|
|
||||||
|
service.UpdateShip(world, ship, 1f, events);
|
||||||
|
service.UpdateShip(world, ship, 0.01f, events);
|
||||||
|
|
||||||
|
ship.Position.Should().Be(new Vector3(5f, 0f, 0f));
|
||||||
|
ship.OrderQueue.Should().BeEmpty();
|
||||||
|
ship.ActiveOrderId.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimulationWorld CreateWorld(ShipRuntime ship)
|
||||||
|
{
|
||||||
|
return new SimulationWorld
|
||||||
|
{
|
||||||
|
Label = "test",
|
||||||
|
Seed = 1,
|
||||||
|
Systems =
|
||||||
|
[
|
||||||
|
new SystemRuntime
|
||||||
|
{
|
||||||
|
Definition = new SolarSystemDefinition
|
||||||
|
{
|
||||||
|
Id = "sys-1",
|
||||||
|
Label = "System 1",
|
||||||
|
Position = [0f, 0f, 0f],
|
||||||
|
Stars = [],
|
||||||
|
AsteroidField = new AsteroidFieldDefinition(),
|
||||||
|
ResourceNodes = [],
|
||||||
|
Planets = [],
|
||||||
|
},
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Anchors = [],
|
||||||
|
Nodes = [],
|
||||||
|
Celestials = [],
|
||||||
|
Wrecks = [],
|
||||||
|
Stations = [],
|
||||||
|
Ships = [ship],
|
||||||
|
Factions = [],
|
||||||
|
Commanders = [],
|
||||||
|
Claims = [],
|
||||||
|
ConstructionSites = [],
|
||||||
|
MarketOrders = [],
|
||||||
|
Policies = [],
|
||||||
|
ShipDefinitions = new Dictionary<string, ShipDefinition>(StringComparer.Ordinal),
|
||||||
|
ItemDefinitions = new Dictionary<string, ItemDefinition>(StringComparer.Ordinal),
|
||||||
|
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(StringComparer.Ordinal),
|
||||||
|
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(StringComparer.Ordinal),
|
||||||
|
Recipes = new Dictionary<string, RecipeDefinition>(StringComparer.Ordinal),
|
||||||
|
ProductionGraph = new ProductionGraph
|
||||||
|
{
|
||||||
|
Commodities = new Dictionary<string, ProductionCommodityNode>(StringComparer.Ordinal),
|
||||||
|
Processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal),
|
||||||
|
ProcessesByOutputId = new Dictionary<string, IReadOnlyList<ProductionProcessNode>>(StringComparer.Ordinal),
|
||||||
|
ProcessesByInputId = new Dictionary<string, IReadOnlyList<ProductionProcessNode>>(StringComparer.Ordinal),
|
||||||
|
OutputsByModuleId = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal),
|
||||||
|
},
|
||||||
|
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShipDefinition CreateShipDefinition()
|
||||||
|
{
|
||||||
|
return new ShipDefinition
|
||||||
|
{
|
||||||
|
Id = "ship-def",
|
||||||
|
Name = "Test Ship",
|
||||||
|
Size = "small",
|
||||||
|
Hull = 100f,
|
||||||
|
Purpose = ShipPurpose.Trade,
|
||||||
|
Type = ShipType.Courier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestBalanceService : IBalanceService
|
||||||
|
{
|
||||||
|
public float SimulationSpeedMultiplier => 1f;
|
||||||
|
public float YPlane => 0f;
|
||||||
|
public float ArrivalThreshold => 1f;
|
||||||
|
public float MiningRate => 10f;
|
||||||
|
public float MiningCycleSeconds => 1f;
|
||||||
|
public float TransferRate => 10f;
|
||||||
|
public float DockingDuration => 0.1f;
|
||||||
|
public float UndockingDuration => 0.1f;
|
||||||
|
public float UndockDistance => 10f;
|
||||||
|
|
||||||
|
public BalanceOptions GetCurrent() => new();
|
||||||
|
|
||||||
|
public BalanceOptions Update(BalanceOptions candidate) => candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
tests/backend/ShipOrderQueueTests.cs
Normal file
140
tests/backend/ShipOrderQueueTests.cs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SpaceGame.Api.Shared.Runtime;
|
||||||
|
using SpaceGame.Api.Ships.Runtime;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SpaceGame.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ShipOrderQueueTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Enqueue_Throws_WhenQueueIsFull()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
foreach (var index in Enumerable.Range(0, ShipOrderQueue.MaxOrders))
|
||||||
|
{
|
||||||
|
queue.Enqueue(CreateOrder($"order-{index}", ShipOrderSourceKind.Player, priority: index));
|
||||||
|
}
|
||||||
|
|
||||||
|
var act = () => queue.Enqueue(CreateOrder("overflow", ShipOrderSourceKind.Player));
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("Order queue is full.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCurrentOrder_UsesQueueOrder()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("player-first", ShipOrderSourceKind.Player));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("commander-second", ShipOrderSourceKind.Commander, priority: 100));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("behavior-third", ShipOrderSourceKind.Behavior, priority: 999));
|
||||||
|
|
||||||
|
var currentOrder = queue.GetCurrentOrder();
|
||||||
|
|
||||||
|
currentOrder.Should().NotBeNull();
|
||||||
|
currentOrder!.Id.Should().Be("player-first");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCurrentOrder_IgnoresTerminalStatuses()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("completed-player", ShipOrderSourceKind.Player, priority: 100, status: OrderStatus.Completed));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("active-commander", ShipOrderSourceKind.Commander, priority: 1, status: OrderStatus.Active));
|
||||||
|
|
||||||
|
var currentOrder = queue.GetCurrentOrder();
|
||||||
|
|
||||||
|
currentOrder.Should().NotBeNull();
|
||||||
|
currentOrder!.Id.Should().Be("active-commander");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnqueuePlayerOrder_InsertsBeforeManagedOrders()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("behavior-2", ShipOrderSourceKind.Commander));
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
|
||||||
|
|
||||||
|
queue.Select(order => order.Id).Should().Equal("player-1", "player-2", "behavior-1", "behavior-2");
|
||||||
|
queue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)!.Id.Should().Be("player-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddOrReplaceManagedOrder_ReplacesMatchingOrderWithoutGrowingQueue()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Initial label"));
|
||||||
|
|
||||||
|
queue.AddOrReplaceManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Updated label", priority: 7));
|
||||||
|
|
||||||
|
queue.Count.Should().Be(1);
|
||||||
|
queue.FindById("order-1").Should().NotBeNull();
|
||||||
|
queue.FindById("order-1")!.Label.Should().Be("Updated label");
|
||||||
|
queue.FindById("order-1")!.Priority.Should().Be(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveById_RemovesMatchingOrder()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("remove-me", ShipOrderSourceKind.Player));
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("keep-me", ShipOrderSourceKind.Player));
|
||||||
|
|
||||||
|
var removed = queue.RemoveById("remove-me");
|
||||||
|
|
||||||
|
removed.Should().BeTrue();
|
||||||
|
queue.Select(order => order.Id).Should().ContainSingle().Which.Should().Be("keep-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryMovePlayerOrder_ReordersWithinPlayerSegment()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
|
||||||
|
|
||||||
|
var moved = queue.TryMovePlayerOrder("player-2", 0);
|
||||||
|
|
||||||
|
moved.Should().BeTrue();
|
||||||
|
queue.Select(order => order.Id).Should().Equal("player-2", "player-1", "behavior-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompleteOrder_RemovesCompletedDirectOrderFromQueue()
|
||||||
|
{
|
||||||
|
var queue = new ShipOrderQueue();
|
||||||
|
queue.EnqueuePlayerOrder(CreateOrder("direct-order", ShipOrderSourceKind.Player, status: OrderStatus.Active));
|
||||||
|
queue.EnqueueManagedOrder(CreateOrder("behavior-order", ShipOrderSourceKind.Behavior));
|
||||||
|
|
||||||
|
var completed = queue.TryCompleteOrder("direct-order");
|
||||||
|
|
||||||
|
completed.Should().BeTrue();
|
||||||
|
queue.FindById("direct-order").Should().BeNull();
|
||||||
|
queue.GetCurrentOrder()!.Id.Should().Be("behavior-order");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShipOrderRuntime CreateOrder(
|
||||||
|
string id,
|
||||||
|
ShipOrderSourceKind sourceKind,
|
||||||
|
int priority = 0,
|
||||||
|
OrderStatus status = OrderStatus.Queued,
|
||||||
|
string? label = null,
|
||||||
|
DateTimeOffset? createdAtUtc = null)
|
||||||
|
{
|
||||||
|
return new ShipOrderRuntime
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Kind = "test-order",
|
||||||
|
SourceKind = sourceKind,
|
||||||
|
SourceId = $"{sourceKind}-source",
|
||||||
|
Priority = priority,
|
||||||
|
Status = status,
|
||||||
|
Label = label,
|
||||||
|
CreatedAtUtc = createdAtUtc ?? new DateTimeOffset(2026, 4, 8, 12, 0, 0, TimeSpan.Zero),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tests/backend/SpaceGame.Api.Tests.csproj
Normal file
28
tests/backend/SpaceGame.Api.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit.v3" Version="3.1.0" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../apps/backend/SpaceGame.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user