Refine ship orders and viewer controls
This commit is contained in:
@@ -559,6 +559,9 @@ public sealed class ShipCargoDefinition
|
||||
public sealed class ScenarioDefinition
|
||||
{
|
||||
public required WorldGenerationOptions WorldGeneration { get; set; }
|
||||
// Temporary QA escape hatch so a scenario can pin an exact topology.
|
||||
// Do not treat this as the long-term world authoring model.
|
||||
public List<SolarSystemDefinition>? Systems { get; set; }
|
||||
public required List<InitialStationDefinition> InitialStations { get; set; }
|
||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||
|
||||
@@ -2834,7 +2834,7 @@ internal sealed class CommanderPlanningService
|
||||
TargetEntityId = objective.TargetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
DestinationStationId = objective.BehaviorKind == DockAndWait ? objective.TargetEntityId : null,
|
||||
DestinationStationId = objective.BehaviorKind == DockAtStation ? objective.TargetEntityId : null,
|
||||
ItemId = objective.ItemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f),
|
||||
@@ -2874,13 +2874,13 @@ internal sealed class CommanderPlanningService
|
||||
|
||||
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
|
||||
{
|
||||
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
|
||||
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0;
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return changed;
|
||||
}
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
|
||||
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (ShipOrdersEqual(existing, desiredOrder))
|
||||
@@ -2888,18 +2888,18 @@ internal sealed class CommanderPlanningService
|
||||
return changed;
|
||||
}
|
||||
|
||||
ship.OrderQueue.Remove(existing);
|
||||
changed = true;
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
|
||||
{
|
||||
changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
|
||||
changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count < 8)
|
||||
if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -672,12 +672,7 @@ internal sealed class PlayerFactionService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count >= 8)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
|
||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
||||
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||
{
|
||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||
Kind = request.Kind,
|
||||
@@ -704,12 +699,7 @@ internal sealed class PlayerFactionService
|
||||
AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = "player-order";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = request.Label ?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-enqueued";
|
||||
@@ -731,28 +721,18 @@ internal sealed class PlayerFactionService
|
||||
return null;
|
||||
}
|
||||
|
||||
var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
||||
if (removed > 0)
|
||||
var removed = ship.OrderQueue.RemoveById(orderId);
|
||||
if (removed)
|
||||
{
|
||||
AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlReason = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Label ?? order.Kind)
|
||||
.FirstOrDefault()
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-player-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-removed";
|
||||
@@ -760,6 +740,93 @@ internal sealed class PlayerFactionService
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? UpdateDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, ShipOrderUpdateCommandRequest request)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var order = ship.OrderQueue.FindById(orderId);
|
||||
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
order.Priority = request.Priority;
|
||||
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
|
||||
order.Label = request.Label;
|
||||
order.TargetEntityId = request.TargetEntityId;
|
||||
order.TargetSystemId = request.TargetSystemId;
|
||||
order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
|
||||
order.SourceStationId = request.SourceStationId;
|
||||
order.DestinationStationId = request.DestinationStationId;
|
||||
order.ItemId = request.ItemId;
|
||||
order.AnchorId = request.AnchorId;
|
||||
order.ConstructionSiteId = request.ConstructionSiteId;
|
||||
order.ModuleId = request.ModuleId;
|
||||
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
|
||||
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
|
||||
order.MaxSystemRange = request.MaxSystemRange;
|
||||
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
|
||||
order.Status = OrderStatus.Queued;
|
||||
order.FailureReason = null;
|
||||
|
||||
AddDecision(player, "ship-order-updated", $"Updated order {orderId} on {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? request.Label
|
||||
?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-updated";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? ReorderDirectShipOrder(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, string orderId, int targetIndex)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
if (!player.AssetRegistry.ShipIds.Contains(shipId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
|
||||
{
|
||||
return ship;
|
||||
}
|
||||
|
||||
AddDecision(player, "ship-order-reordered", $"Reordered order {orderId} on {ship.Definition.Name}.", "ship", shipId);
|
||||
player.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-player-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "player-order-reordered";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, IPlayerStateStore playerStateStore, string playerId, string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
var player = EnsureInitializedDomain(world, playerStateStore, playerId);
|
||||
@@ -1321,25 +1388,15 @@ internal sealed class PlayerFactionService
|
||||
? "player-directive"
|
||||
: automation is not null
|
||||
? "player-automation"
|
||||
: ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
: ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "player-order"
|
||||
: "player-manual";
|
||||
var desiredControlSourceId = directive?.Id
|
||||
?? automation?.Id
|
||||
?? ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
?? ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
var desiredControlReason = directive?.Label
|
||||
?? automation?.Label
|
||||
?? ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Label ?? order.Kind)
|
||||
.FirstOrDefault()
|
||||
?? ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control");
|
||||
|
||||
var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment);
|
||||
@@ -1438,7 +1495,7 @@ internal sealed class PlayerFactionService
|
||||
private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation)
|
||||
{
|
||||
var aiOrderId = directive is null ? null : $"player-order-{directive.Id}";
|
||||
var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
|
||||
var changed = ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0;
|
||||
|
||||
var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false;
|
||||
if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind))
|
||||
@@ -1470,17 +1527,16 @@ internal sealed class PlayerFactionService
|
||||
KnownStationsOnly = directive.KnownStationsOnly,
|
||||
};
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId);
|
||||
var existing = ship.OrderQueue.FindById(aiOrderId!);
|
||||
if (existing is null)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ShipOrdersEqual(existing, desiredOrder))
|
||||
{
|
||||
ship.OrderQueue.Remove(existing);
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Npgsql;
|
||||
using SpaceGame.Api.Universe.Bootstrap;
|
||||
|
||||
const string StartupScenarioPath = "scenarios/empty.json";
|
||||
const string StartupScenarioPath = "scenarios/minimal.json";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
|
||||
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 ExpertAutoMine = "expert-auto-mine";
|
||||
|
||||
public const string DockAndWait = "dock-and-wait";
|
||||
public const string FlyAndWait = "fly-and-wait";
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string Move = "move";
|
||||
public const string FlyToObject = "fly-to-object";
|
||||
public const string FollowShip = "follow-ship";
|
||||
public const string HoldPosition = "hold-position";
|
||||
@@ -60,29 +60,29 @@ public static class ShipAutomationCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<ShipBehaviorDefinition> Behaviors =
|
||||
[
|
||||
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the active patrol context."),
|
||||
new(ShipBehaviorKinds.Patrol, "Patrol", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the active patrol context."),
|
||||
new(ShipBehaviorKinds.Police, "Police", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship inspection orders from the active policing context."),
|
||||
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait orders from the defended position context."),
|
||||
new(ShipBehaviorKinds.ProtectPosition, "Protect Position", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move orders from the defended position context."),
|
||||
new(ShipBehaviorKinds.ProtectShip, "Protect Ship", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or follow-ship escort orders from the guarded ship context."),
|
||||
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or fly-and-wait guard orders from the defended station context."),
|
||||
new(ShipBehaviorKinds.ProtectStation, "Protect Station", "Combat", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing attack or move guard orders from the defended station context."),
|
||||
|
||||
new(ShipBehaviorKinds.LocalAutoMine, "Local AutoMine", "Mining", ShipAutomationSupportStatus.PartiallySupported, "Queue-backed for solo mining; broader order-generation model still in progress."),
|
||||
new(ShipBehaviorKinds.AdvancedAutoMine, "Advanced AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
|
||||
new(ShipBehaviorKinds.ExpertAutoMine, "Expert AutoMine", "Mining", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal mine-and-deliver run order from the current mining opportunity."),
|
||||
|
||||
new(ShipBehaviorKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.FlyAndWait, "Fly And Wait", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.Move, "Fly To Position", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.Supported, "Queue-backed behavior using the same building-block order as the direct order."),
|
||||
new(ShipBehaviorKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Default baseline behavior; queue-backed behavior order is active."),
|
||||
|
||||
new(ShipBehaviorKinds.AutoSalvage, "AutoSalvage", "Salvage", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal salvage run order for wreck recovery."),
|
||||
|
||||
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from the current market context."),
|
||||
new(ShipBehaviorKinds.LocalAutoTrade, "Local AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from the current market context."),
|
||||
new(ShipBehaviorKinds.AdvancedAutoTrade, "Advanced AutoTrade", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
|
||||
new(ShipBehaviorKinds.FillShortages, "Fill Shortages", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route orders from the current market context."),
|
||||
new(ShipBehaviorKinds.FindBuildTasks, "Find Build Tasks", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing construction-support trade routes from the current market context."),
|
||||
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-and-wait orders from known-station context."),
|
||||
new(ShipBehaviorKinds.RevisitKnownStations, "Revisit Known Stations", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing trade-route or dock-at-station orders from known-station context."),
|
||||
new(ShipBehaviorKinds.SupplyFleet, "Supply Fleet", "Trade", ShipAutomationSupportStatus.Supported, "Queue-backed behavior synthesizing an internal fleet supply run order."),
|
||||
|
||||
new(ShipBehaviorKinds.RepeatOrders, "Repeat Orders", "Advanced", ShipAutomationSupportStatus.Supported, "Queue-backed behavior generating the current repeat-order template at the bottom of the stack."),
|
||||
@@ -94,12 +94,11 @@ public static class ShipAutomationCatalog
|
||||
|
||||
public static readonly IReadOnlyList<ShipOrderDefinition> Orders =
|
||||
[
|
||||
new(ShipOrderKinds.DockAndWait, "Dock And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.FlyAndWait, "Fly To And Wait", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.DockAtStation, "Dock At Station", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.Move, "Fly To", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order completes on arrival."),
|
||||
new(ShipOrderKinds.FlyToObject, "Fly To Object", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.FollowShip, "Follow Ship", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.HoldPosition, "Hold Position", "Navigation", ShipAutomationSupportStatus.Supported, "Direct order supported in backend."),
|
||||
new(ShipOrderKinds.Move, "Move", "Navigation", ShipAutomationSupportStatus.PartiallySupported, "Low-level direct movement order; viewer may present richer labels such as Fly To And Wait instead."),
|
||||
|
||||
new(ShipOrderKinds.AttackTarget, "Attack Target", "Combat", ShipAutomationSupportStatus.PartiallySupported, "Direct order supported in backend."),
|
||||
|
||||
|
||||
@@ -46,16 +46,6 @@ public enum AiPlanStatus
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum AiPlanStepStatus
|
||||
{
|
||||
Planned,
|
||||
Running,
|
||||
Blocked,
|
||||
Completed,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
public enum AiPlanSourceKind
|
||||
{
|
||||
Rule,
|
||||
@@ -165,8 +155,6 @@ public static class ShipOrderKinds
|
||||
{
|
||||
public const string Move = "move";
|
||||
public const string DockAtStation = "dock-at-station";
|
||||
public const string DockAndWait = "dock-and-wait";
|
||||
public const string FlyAndWait = "fly-and-wait";
|
||||
public const string FlyToObject = "fly-to-object";
|
||||
public const string FollowShip = "follow-ship";
|
||||
public const string TradeRoute = "trade-route";
|
||||
@@ -324,17 +312,6 @@ public static class SimulationEnumMappings
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanStepStatus status) => status switch
|
||||
{
|
||||
AiPlanStepStatus.Planned => "planned",
|
||||
AiPlanStepStatus.Running => "running",
|
||||
AiPlanStepStatus.Blocked => "blocked",
|
||||
AiPlanStepStatus.Completed => "completed",
|
||||
AiPlanStepStatus.Failed => "failed",
|
||||
AiPlanStepStatus.Interrupted => "interrupted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this AiPlanSourceKind kind) => kind switch
|
||||
{
|
||||
AiPlanSourceKind.Rule => "rule",
|
||||
|
||||
@@ -7,6 +7,22 @@ public static class SimulationUnits
|
||||
|
||||
public static float AuToKilometers(float au) => au * KilometersPerAu;
|
||||
|
||||
public static float KilometersToMeters(float kilometers) => kilometers * MetersPerKilometer;
|
||||
|
||||
public static float MetersToKilometers(float meters) => meters / MetersPerKilometer;
|
||||
|
||||
public static Vector3 KilometersToMeters(Vector3 kilometers) =>
|
||||
new(
|
||||
KilometersToMeters(kilometers.X),
|
||||
KilometersToMeters(kilometers.Y),
|
||||
KilometersToMeters(kilometers.Z));
|
||||
|
||||
public static Vector3 MetersToKilometers(Vector3 meters) =>
|
||||
new(
|
||||
MetersToKilometers(meters.X),
|
||||
MetersToKilometers(meters.Y),
|
||||
MetersToKilometers(meters.Z));
|
||||
|
||||
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
|
||||
auPerSecond * KilometersPerAu;
|
||||
|
||||
|
||||
@@ -6,27 +6,12 @@ namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) =>
|
||||
ship.OrderQueue
|
||||
.Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active)
|
||||
.OrderByDescending(GetOrderSourcePriority)
|
||||
.ThenByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
|
||||
{
|
||||
ShipOrderSourceKind.Player => 300,
|
||||
ShipOrderSourceKind.Commander => 200,
|
||||
ShipOrderSourceKind.Behavior => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
|
||||
ship.OrderQueue.RemoveAll(order =>
|
||||
ship.OrderQueue.RemoveWhere(order =>
|
||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& order.Id.StartsWith("behavior-", StringComparison.Ordinal)
|
||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||
|
||||
if (desiredOrder is null)
|
||||
@@ -34,10 +19,10 @@ public sealed partial class ShipAiService
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
|
||||
var existing = ship.OrderQueue.FindById(desiredOrder.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,8 +31,7 @@ public sealed partial class ShipAiService
|
||||
return;
|
||||
}
|
||||
|
||||
ship.OrderQueue.Remove(existing);
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
||||
}
|
||||
|
||||
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
|
||||
@@ -76,7 +60,7 @@ public sealed partial class ShipAiService
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal))
|
||||
if (string.Equals(behaviorKind, DockAtStation, StringComparison.Ordinal))
|
||||
{
|
||||
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
@@ -88,38 +72,36 @@ public sealed partial class ShipAiService
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-dock-and-wait",
|
||||
Kind = ShipOrderKinds.DockAndWait,
|
||||
Id = $"behavior-{ship.Id}-dock-at-station",
|
||||
Kind = ShipOrderKinds.DockAtStation,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Dock and wait at {station.Label}",
|
||||
Label = $"Dock at {station.Label}",
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal))
|
||||
if (string.Equals(behaviorKind, Move, StringComparison.Ordinal))
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-fly-and-wait",
|
||||
Kind = ShipOrderKinds.FlyAndWait,
|
||||
Id = $"behavior-{ship.Id}-move",
|
||||
Kind = ShipOrderKinds.Move,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Fly and wait",
|
||||
Label = "Fly to position",
|
||||
TargetSystemId = systemId,
|
||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
@@ -306,13 +288,12 @@ public sealed partial class ShipAiService
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(
|
||||
return CreateManagedMoveOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
"Protect position",
|
||||
targetSystemId,
|
||||
targetPosition,
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
@@ -365,13 +346,12 @@ public sealed partial class ShipAiService
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(
|
||||
return CreateManagedMoveOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
$"Guard {station.Label}",
|
||||
station.SystemId,
|
||||
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
@@ -410,7 +390,7 @@ public sealed partial class ShipAiService
|
||||
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}");
|
||||
return CreateManagedDockAtStationOrder(ship, behaviorKind, visitStation, $"Revisit {visitStation.Label}");
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = "no-trade-route";
|
||||
@@ -641,7 +621,7 @@ public sealed partial class ShipAiService
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait");
|
||||
return CreateManagedMoveOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-move");
|
||||
}
|
||||
|
||||
private static ShipOrderRuntime CreateManagedAttackOrder(
|
||||
@@ -687,11 +667,11 @@ public sealed partial class ShipAiService
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) =>
|
||||
private static ShipOrderRuntime CreateManagedDockAtStationOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, string label) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait",
|
||||
Kind = ShipOrderKinds.DockAndWait,
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-at-station",
|
||||
Kind = ShipOrderKinds.DockAtStation,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
@@ -700,25 +680,23 @@ public sealed partial class ShipAiService
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder(
|
||||
private static ShipOrderRuntime CreateManagedMoveOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
float waitSeconds,
|
||||
float radius,
|
||||
string? orderIdSuffix = null) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||
Kind = ShipOrderKinds.FlyAndWait,
|
||||
Kind = ShipOrderKinds.Move,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
@@ -726,7 +704,6 @@ public sealed partial class ShipAiService
|
||||
Label = label,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
return subTask.Kind switch
|
||||
{
|
||||
@@ -636,12 +636,13 @@ public sealed partial class ShipAiService
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
|
||||
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||
? ship.Position
|
||||
? localSystemOffset
|
||||
: new Vector3(
|
||||
currentAnchor.Position.X + ship.Position.X,
|
||||
currentAnchor.Position.Y + ship.Position.Y,
|
||||
currentAnchor.Position.Z + ship.Position.Z);
|
||||
currentAnchor.Position.X + localSystemOffset.X,
|
||||
currentAnchor.Position.Y + localSystemOffset.Y,
|
||||
currentAnchor.Position.Z + localSystemOffset.Z);
|
||||
|
||||
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
|
||||
{
|
||||
@@ -650,12 +651,13 @@ public sealed partial class ShipAiService
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id ?? currentAnchor?.Id;
|
||||
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||
? targetPosition
|
||||
? arrivalSystemOffset
|
||||
: new Vector3(
|
||||
targetAnchor.Position.X + targetPosition.X,
|
||||
targetAnchor.Position.Y + targetPosition.Y,
|
||||
targetAnchor.Position.Z + targetPosition.Z);
|
||||
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
@@ -663,12 +665,13 @@ public sealed partial class ShipAiService
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
var movedSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||
? ship.Position
|
||||
? movedSystemOffset
|
||||
: new Vector3(
|
||||
currentAnchor.Position.X + ship.Position.X,
|
||||
currentAnchor.Position.Y + ship.Position.Y,
|
||||
currentAnchor.Position.Z + ship.Position.Z);
|
||||
currentAnchor.Position.X + movedSystemOffset.X,
|
||||
currentAnchor.Position.Y + movedSystemOffset.Y,
|
||||
currentAnchor.Position.Z + movedSystemOffset.Z);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
@@ -822,12 +825,13 @@ public sealed partial class ShipAiService
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.CurrentAnchorId = targetAnchor?.Id;
|
||||
ship.SpatialState.DestinationAnchorId = targetAnchor?.Id;
|
||||
var arrivalSystemOffset = SimulationUnits.MetersToKilometers(targetPosition);
|
||||
ship.SpatialState.SystemPosition = targetAnchor is null
|
||||
? targetPosition
|
||||
? arrivalSystemOffset
|
||||
: new Vector3(
|
||||
targetAnchor.Position.X + targetPosition.X,
|
||||
targetAnchor.Position.Y + targetPosition.Y,
|
||||
targetAnchor.Position.Z + targetPosition.Z);
|
||||
targetAnchor.Position.X + arrivalSystemOffset.X,
|
||||
targetAnchor.Position.Y + arrivalSystemOffset.Y,
|
||||
targetAnchor.Position.Z + arrivalSystemOffset.Z);
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
@@ -185,13 +185,14 @@ public sealed partial class ShipAiService
|
||||
{
|
||||
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
|
||||
{
|
||||
var localOffset = SimulationUnits.MetersToKilometers(station.Position);
|
||||
return new Vector3(
|
||||
anchor.Position.X + station.Position.X,
|
||||
anchor.Position.Y + station.Position.Y,
|
||||
anchor.Position.Z + station.Position.Z);
|
||||
anchor.Position.X + localOffset.X,
|
||||
anchor.Position.Y + localOffset.Y,
|
||||
anchor.Position.Z + localOffset.Z);
|
||||
}
|
||||
|
||||
return station.Position;
|
||||
return SimulationUnits.MetersToKilometers(station.Position);
|
||||
}
|
||||
|
||||
private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node)
|
||||
@@ -216,17 +217,18 @@ public sealed partial class ShipAiService
|
||||
|
||||
if (ResolveCurrentAnchor(world, ship) is { } anchor)
|
||||
{
|
||||
var localOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
return new Vector3(
|
||||
anchor.Position.X + ship.Position.X,
|
||||
anchor.Position.Y + ship.Position.Y,
|
||||
anchor.Position.Z + ship.Position.Z);
|
||||
anchor.Position.X + localOffset.X,
|
||||
anchor.Position.Y + localOffset.Y,
|
||||
anchor.Position.Z + localOffset.Z);
|
||||
}
|
||||
|
||||
return ship.Position;
|
||||
return SimulationUnits.MetersToKilometers(ship.Position);
|
||||
}
|
||||
|
||||
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
|
||||
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation);
|
||||
ship.Definition.Speed * GetSkillFactor(ship.Skills.Navigation);
|
||||
|
||||
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
|
||||
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
|
||||
@@ -997,9 +999,6 @@ public sealed partial class ShipAiService
|
||||
? null
|
||||
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment;
|
||||
|
||||
private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) =>
|
||||
plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex];
|
||||
|
||||
private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
|
||||
{
|
||||
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId)
|
||||
@@ -1032,41 +1031,40 @@ public sealed partial class ShipAiService
|
||||
|
||||
private static void TrackHistory(ShipRuntime ship)
|
||||
{
|
||||
var plan = ship.ActivePlan;
|
||||
var step = GetCurrentStep(plan);
|
||||
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex];
|
||||
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
|
||||
var orderId = ship.ActiveOrderId ?? "none";
|
||||
var subTask = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||
var signature = $"{ship.State.ToContractValue()}|{orderId}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
|
||||
if (ship.LastSignature == signature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.LastSignature = signature;
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} order={orderId} task={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
|
||||
if (ship.History.Count > 24)
|
||||
{
|
||||
ship.History.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection<SimulationEventRecord> events)
|
||||
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousOrderId, string? previousTaskId, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var currentPlanId = ship.ActivePlan?.Id;
|
||||
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
||||
var currentOrderId = ship.ActiveOrderId;
|
||||
var currentTaskId = ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id;
|
||||
var occurredAtUtc = DateTimeOffset.UtcNow;
|
||||
if (previousState != ship.State)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal))
|
||||
if (!string.Equals(previousOrderId, currentOrderId, StringComparison.Ordinal))
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-changed", $"{ship.Definition.Name} switched active order.", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal))
|
||||
if (!string.Equals(previousTaskId, currentTaskId, StringComparison.Ordinal))
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Name} advanced active task.", occurredAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,323 +1,179 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship);
|
||||
var failureReason = ship.LastAccessFailureReason;
|
||||
if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal))
|
||||
{
|
||||
return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle");
|
||||
}
|
||||
|
||||
if (IsBehaviorBlockingFailure(behaviorKind, failureReason))
|
||||
{
|
||||
return CreateBlockedPlan(
|
||||
ship,
|
||||
AiPlanSourceKind.DefaultBehavior,
|
||||
sourceId,
|
||||
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason),
|
||||
failureReason!);
|
||||
}
|
||||
|
||||
return CreateIdlePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.DefaultBehavior,
|
||||
sourceId,
|
||||
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason));
|
||||
}
|
||||
|
||||
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
|
||||
{
|
||||
"missing-item" => true,
|
||||
"no-suitable-buyer" => true,
|
||||
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true,
|
||||
"no-mineable-node" when string.Equals(behaviorKind, ShipBehaviorKinds.LocalAutoMine, StringComparison.Ordinal) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason)
|
||||
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource";
|
||||
|
||||
return failureReason switch
|
||||
{
|
||||
"missing-item" => "No mining ware configured",
|
||||
"no-suitable-buyer" => $"No buyer for {itemId} in {systemId}",
|
||||
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}",
|
||||
"no-mineable-node" => "No mineable node",
|
||||
"no-home-station" => "No home station",
|
||||
"no-trade-route" => "No trade route",
|
||||
"no-fleet-to-supply" => "No fleet to supply",
|
||||
"station-missing" => "No station to dock",
|
||||
"target-ship-missing" => "No ship to follow",
|
||||
"target-missing" => "No object target",
|
||||
"no-salvage-target" => "No salvage target",
|
||||
"no-repeat-orders" => "No repeat orders",
|
||||
"no-construction-site" => "No construction site",
|
||||
"support-station-missing" => "No support station",
|
||||
_ => "Idle",
|
||||
};
|
||||
return assignment is null
|
||||
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
||||
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildTradeSubTasks(ShipRuntime ship, TradeRoutePlan route)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.TradeRoute,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}",
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
]),
|
||||
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
|
||||
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildFleetSupplySubTasks(FleetSupplyPlan plan)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
||||
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
|
||||
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
|
||||
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildConstructionSubTasks(ConstructionSiteRuntime site, StationRuntime supportStation)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
SupplyFleet,
|
||||
plan.Summary,
|
||||
[
|
||||
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
|
||||
var targetPosition = supportStation.Position;
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
||||
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
|
||||
]),
|
||||
CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Name}",
|
||||
[
|
||||
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
|
||||
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
||||
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildAttackSubTasks(string targetEntityId, string? targetSystemId, string summary)
|
||||
{
|
||||
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
"construction-support",
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
||||
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f)
|
||||
]),
|
||||
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
|
||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? string.Empty, Vector3.Zero, targetEntityId, 26f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFlyToObjectSubTasks(string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, supportStation.Position, site.Id, 12f, 0f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowShipSubTasks(ShipRuntime targetShip, float radius, float durationSeconds, string summary) =>
|
||||
BuildFollowSubTasks(targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildFollowSubTasks(string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.AttackTarget,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildHoldSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.DockAndWait,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
||||
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node, StationRuntime homeStation)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FlyAndWait,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
|
||||
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningSubTasks(ShipRuntime ship, ResourceNodeRuntime node)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FlyToObject,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildLocalMiningDeliverySubTasks(ShipRuntime ship, StationRuntime buyer, string itemId)
|
||||
{
|
||||
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FollowShip,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-follow", "follow-target", summary,
|
||||
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildSalvageSubTasks(ShipRuntime ship, WreckRuntime wreck, StationRuntime homeStation, Vector3 approach)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
Idle,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
|
||||
{
|
||||
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = blockingReason;
|
||||
|
||||
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
|
||||
step.Status = AiPlanStepStatus.Blocked;
|
||||
step.BlockingReason = blockingReason;
|
||||
|
||||
var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]);
|
||||
plan.Status = AiPlanStatus.Blocked;
|
||||
plan.FailureReason = blockingReason;
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static ShipPlanRuntime CreatePlan(
|
||||
ShipRuntime ship,
|
||||
AiPlanSourceKind sourceKind,
|
||||
string sourceId,
|
||||
string kind,
|
||||
string summary,
|
||||
IReadOnlyList<ShipPlanStepRuntime> steps)
|
||||
{
|
||||
var plan = new ShipPlanRuntime
|
||||
{
|
||||
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
|
||||
SourceKind = sourceKind,
|
||||
SourceId = sourceId,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
};
|
||||
plan.Steps.AddRange(steps);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
||||
{
|
||||
var step = new ShipPlanStepRuntime
|
||||
private static ShipSubTaskRuntime CreateSubTask(
|
||||
string id,
|
||||
string kind,
|
||||
string summary,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? targetEntityId,
|
||||
float threshold,
|
||||
float amount,
|
||||
string? itemId = null,
|
||||
string? moduleId = null,
|
||||
string? targetAnchorId = null,
|
||||
string? targetResourceNodeId = null,
|
||||
string? targetResourceDepositId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetAnchorId = targetAnchorId,
|
||||
TargetResourceNodeId = targetResourceNodeId,
|
||||
TargetResourceDepositId = targetResourceDepositId,
|
||||
ItemId = itemId,
|
||||
ModuleId = moduleId,
|
||||
Threshold = threshold,
|
||||
Amount = amount,
|
||||
};
|
||||
step.SubTasks.AddRange(subTasks);
|
||||
return step;
|
||||
}
|
||||
|
||||
private static ShipSubTaskRuntime CreateSubTask(
|
||||
string id,
|
||||
string kind,
|
||||
string summary,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? targetEntityId,
|
||||
float threshold,
|
||||
float amount,
|
||||
string? itemId = null,
|
||||
string? moduleId = null,
|
||||
string? targetAnchorId = null,
|
||||
string? targetResourceNodeId = null,
|
||||
string? targetResourceDepositId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetAnchorId = targetAnchorId,
|
||||
TargetResourceNodeId = targetResourceNodeId,
|
||||
TargetResourceDepositId = targetResourceDepositId,
|
||||
ItemId = itemId,
|
||||
ModuleId = moduleId,
|
||||
Threshold = threshold,
|
||||
Amount = amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
@@ -7,7 +6,7 @@ namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship)
|
||||
private ShipOrderRuntime? BuildEmergencyOrder(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
if (policy is null)
|
||||
@@ -37,86 +36,75 @@ public sealed partial class ShipAiService
|
||||
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
||||
.FirstOrDefault();
|
||||
|
||||
var plan = new ShipPlanRuntime
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}",
|
||||
SourceKind = AiPlanSourceKind.Rule,
|
||||
Id = $"rule-{ship.Id}-flee",
|
||||
Kind = ShipOrderKinds.Flee,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = ShipOrderKinds.Flee,
|
||||
Kind = "safety-flee",
|
||||
Summary = "Emergency retreat",
|
||||
Priority = 1000,
|
||||
InterruptCurrentPlan = true,
|
||||
Label = "Emergency retreat",
|
||||
TargetEntityId = safeStation?.Id,
|
||||
TargetSystemId = safeStation?.SystemId ?? ship.SystemId,
|
||||
TargetPosition = safeStation?.Position ?? ship.Position,
|
||||
DestinationStationId = safeStation?.Id,
|
||||
Radius = safeStation is null ? 0f : MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f),
|
||||
};
|
||||
|
||||
if (safeStation is null)
|
||||
{
|
||||
plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "Hold position away from hostiles",
|
||||
[
|
||||
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f)
|
||||
]));
|
||||
return plan;
|
||||
}
|
||||
|
||||
plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station",
|
||||
[
|
||||
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
|
||||
]));
|
||||
plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station",
|
||||
[
|
||||
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f)
|
||||
]));
|
||||
return plan;
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return order.Kind switch
|
||||
{
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Flee, StringComparison.Ordinal) => BuildFleeSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMoveSubTasks(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderSubTasks(world, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderSubTasks(world, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderSubTasks(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderSubTasks(order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldSubTasks(ship, order),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
|
||||
private IReadOnlyList<ShipSubTaskRuntime> BuildFleeSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
return assignment is null
|
||||
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
||||
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
||||
var safeStation = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||
if (safeStation is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f),
|
||||
];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f),
|
||||
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
private static IReadOnlyList<ShipSubTaskRuntime> BuildMoveSubTasks(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
ShipOrderKinds.Move,
|
||||
order.Label ?? "Move order",
|
||||
[
|
||||
CreateStep("step-move", "travel", order.Label ?? "Travel",
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, MathF.Max(0f, order.Radius), 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildDockOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||
if (station is null)
|
||||
@@ -125,25 +113,14 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
"dock-at-station",
|
||||
order.Label ?? $"Dock at {station.Label}",
|
||||
[
|
||||
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
|
||||
return
|
||||
[
|
||||
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
|
||||
]),
|
||||
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
|
||||
[
|
||||
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f)
|
||||
])
|
||||
]);
|
||||
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f),
|
||||
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
||||
];
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildTradeOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
|
||||
{
|
||||
@@ -158,10 +135,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary);
|
||||
return BuildTradeSubTasks(ship, route);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var systemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var itemId = order.ItemId;
|
||||
@@ -198,10 +175,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
|
||||
return BuildLocalMiningSubTasks(ship, node);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineLocalOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||
var node = ResolveNode(world, order.TargetEntityId)
|
||||
@@ -212,10 +189,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}");
|
||||
return BuildLocalMiningSubTasks(ship, node);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildMineAndDeliverRunOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var anchor = ResolveMiningAnchor(world, order.AnchorId, order.TargetEntityId);
|
||||
var node = ResolveNode(world, order.TargetEntityId)
|
||||
@@ -229,10 +206,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildMiningPlan(world, ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}");
|
||||
return BuildMiningSubTasks(ship, node, buyer);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildSellMinedCargoOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
|
||||
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||
@@ -241,10 +218,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}");
|
||||
return BuildLocalMiningDeliverySubTasks(ship, buyer, order.ItemId);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildAutoSalvageOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
||||
@@ -255,29 +232,10 @@ public sealed partial class ShipAiService
|
||||
}
|
||||
|
||||
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
AutoSalvage,
|
||||
order.Label ?? $"Salvage {wreck.ItemId}",
|
||||
[
|
||||
CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
]),
|
||||
CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||
])
|
||||
]);
|
||||
return BuildSalvageSubTasks(ship, wreck, homeStation, approach);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildSupplyFleetOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var sourceStation = ResolveStation(world, order.SourceStationId);
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
@@ -303,10 +261,10 @@ public sealed partial class ShipAiService
|
||||
amount,
|
||||
MathF.Max(16f, order.Radius),
|
||||
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
|
||||
return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan);
|
||||
return BuildFleetSupplySubTasks(plan);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildBuildOrderSubTasks(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
|
||||
if (site is null)
|
||||
@@ -322,10 +280,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}");
|
||||
return BuildConstructionSubTasks(site, supportStation);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildAttackOrderSubTasks(ShipOrderRuntime order)
|
||||
{
|
||||
var targetId = order.TargetEntityId;
|
||||
if (targetId is null)
|
||||
@@ -334,45 +292,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target");
|
||||
return BuildAttackSubTasks(targetId, order.TargetSystemId, order.Label ?? "Attack target");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
ShipOrderKinds.HoldPosition,
|
||||
order.Label ?? "Hold position",
|
||||
[
|
||||
CreateStep("step-hold", ShipOrderKinds.HoldPosition, order.Label ?? "Hold position",
|
||||
[
|
||||
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
order.FailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var systemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||
return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildFlyToObjectOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
|
||||
{
|
||||
var targetEntityId = order.TargetEntityId;
|
||||
if (targetEntityId is null)
|
||||
@@ -388,10 +311,10 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
|
||||
return BuildFlyToObjectSubTasks(objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
private IReadOnlyList<ShipSubTaskRuntime>? BuildFollowShipOrderSubTasks(SimulationWorld world, ShipOrderRuntime order)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
@@ -400,71 +323,6 @@ public sealed partial class ShipAiService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary)
|
||||
{
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.MineAndDeliver,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id)
|
||||
]),
|
||||
CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildLocalMiningPlan(SimulationWorld world, ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
|
||||
{
|
||||
var deposit = SelectMiningDeposit(node, ship.Id);
|
||||
var extractionPosition = GetResourceHoldPosition(deposit?.Position ?? Vector3.Zero, ship.Id, 20f);
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.MineLocal,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} field", node.SystemId, Vector3.Zero, node.AnchorId, 8f, 0f, targetAnchorId: node.AnchorId),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId, targetAnchorId: node.AnchorId, targetResourceNodeId: node.Id, targetResourceDepositId: deposit?.Id)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary)
|
||||
{
|
||||
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.SellMinedCargo,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}",
|
||||
[
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
return BuildFollowShipSubTasks(targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,191 +26,195 @@ public sealed partial class ShipAiService
|
||||
}
|
||||
|
||||
var previousState = ship.State;
|
||||
var previousPlanId = ship.ActivePlan?.Id;
|
||||
var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
||||
|
||||
EnsurePlan(world, ship, events);
|
||||
ExecutePlan(world, ship, deltaSeconds, events);
|
||||
TrackHistory(ship);
|
||||
EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events);
|
||||
}
|
||||
|
||||
private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var emergencyPlan = BuildEmergencyPlan(world, ship);
|
||||
if (emergencyPlan is not null)
|
||||
{
|
||||
ship.LastReplanReason = "rule-safety";
|
||||
ReplacePlan(ship, emergencyPlan, "rule-safety", events);
|
||||
return;
|
||||
}
|
||||
var previousOrderId = ship.ActiveOrderId;
|
||||
var previousTaskId = GetCurrentSubTask(ship)?.Id;
|
||||
|
||||
SyncEmergencyOrders(world, ship);
|
||||
SyncBehaviorOrders(world, ship);
|
||||
var topOrder = GetTopOrder(ship);
|
||||
if (topOrder is not null && topOrder.Status == OrderStatus.Queued)
|
||||
{
|
||||
topOrder.Status = OrderStatus.Active;
|
||||
}
|
||||
|
||||
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
|
||||
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
|
||||
var currentPlan = ship.ActivePlan;
|
||||
|
||||
if (currentPlan is not null
|
||||
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
|
||||
&& currentPlan.SourceKind == desiredSourceKind
|
||||
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
|
||||
&& !ship.NeedsReplan)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
|
||||
? BuildOrderPlan(world, ship, topOrder!)
|
||||
: BuildBehaviorFallbackPlan(world, ship);
|
||||
|
||||
if (nextPlan is null)
|
||||
{
|
||||
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
|
||||
}
|
||||
|
||||
if (nextPlan.Kind != Idle)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
}
|
||||
|
||||
ReplacePlan(ship, nextPlan, "replanned", events);
|
||||
EnsureOrderExecution(world, ship, events);
|
||||
ExecuteOrder(world, ship, deltaSeconds, events);
|
||||
TrackHistory(ship);
|
||||
EmitStateEvents(ship, previousState, previousOrderId, previousTaskId, events);
|
||||
}
|
||||
|
||||
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
private void EnsureOrderExecution(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var plan = ship.ActivePlan;
|
||||
if (plan is null)
|
||||
var currentOrder = ship.OrderQueue.GetCurrentOrder();
|
||||
if (currentOrder is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
ClearActiveOrder(ship);
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
||||
if (currentOrder.Status == OrderStatus.Queued)
|
||||
{
|
||||
currentOrder.Status = OrderStatus.Active;
|
||||
}
|
||||
|
||||
if (!ship.NeedsReplan
|
||||
&& string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal)
|
||||
&& ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count)
|
||||
{
|
||||
CompletePlan(ship, plan, events);
|
||||
return;
|
||||
}
|
||||
|
||||
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
var step = plan.Steps[plan.CurrentStepIndex];
|
||||
if (step.Status == AiPlanStepStatus.Planned)
|
||||
if (ship.ReplanCooldownSeconds > 0f && !string.Equals(ship.ActiveOrderId, currentOrder.Id, StringComparison.Ordinal))
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Running;
|
||||
}
|
||||
|
||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
||||
{
|
||||
CompleteStep(plan, step);
|
||||
return;
|
||||
}
|
||||
|
||||
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
|
||||
var subTasks = BuildOrderSubTasks(world, ship, currentOrder);
|
||||
if (subTasks is null || subTasks.Count == 0)
|
||||
{
|
||||
FailOrder(ship, currentOrder, currentOrder.FailureReason ?? "order-unavailable");
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.1f;
|
||||
ship.LastReplanReason = currentOrder.FailureReason ?? "order-unavailable";
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
BeginOrderExecution(ship, currentOrder, subTasks);
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-started", $"{ship.Definition.Name} started {currentOrder.Label ?? currentOrder.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void ExecuteOrder(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var order = ship.ActiveOrderId is null ? null : ship.OrderQueue.FindById(ship.ActiveOrderId);
|
||||
if (order is null)
|
||||
{
|
||||
ClearActiveOrder(ship);
|
||||
ApplyIdleOrBlockedState(world, ship);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||
{
|
||||
CompleteOrderExecution(ship, order, events);
|
||||
return;
|
||||
}
|
||||
|
||||
var subTask = ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||
if (subTask.Status == WorkStatus.Pending)
|
||||
{
|
||||
subTask.Status = WorkStatus.Active;
|
||||
}
|
||||
else if (subTask.Status == WorkStatus.Blocked)
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Blocked;
|
||||
step.BlockingReason = subTask.BlockingReason;
|
||||
plan.Status = AiPlanStatus.Blocked;
|
||||
ship.State = ShipState.Blocked;
|
||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
plan.Status = AiPlanStatus.Running;
|
||||
|
||||
var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds);
|
||||
var outcome = UpdateSubTask(world, ship, subTask, deltaSeconds);
|
||||
switch (outcome)
|
||||
{
|
||||
case SubTaskOutcome.Active:
|
||||
step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running;
|
||||
plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running;
|
||||
return;
|
||||
case SubTaskOutcome.Completed:
|
||||
subTask.Status = WorkStatus.Completed;
|
||||
subTask.Progress = 1f;
|
||||
step.CurrentSubTaskIndex += 1;
|
||||
step.BlockingReason = null;
|
||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
||||
ship.ActiveSubTaskIndex += 1;
|
||||
if (ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count)
|
||||
{
|
||||
CompleteStep(plan, step);
|
||||
CompleteOrderExecution(ship, order, events);
|
||||
}
|
||||
|
||||
return;
|
||||
case SubTaskOutcome.Failed:
|
||||
subTask.Status = WorkStatus.Failed;
|
||||
step.Status = AiPlanStepStatus.Failed;
|
||||
plan.Status = AiPlanStatus.Failed;
|
||||
plan.FailureReason = subTask.BlockingReason ?? "subtask-failed";
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.5f;
|
||||
ship.LastReplanReason = plan.FailureReason;
|
||||
FailOrderExecution(ship, order, subTask.BlockingReason ?? "subtask-failed", events);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step)
|
||||
private static void BeginOrderExecution(ShipRuntime ship, ShipOrderRuntime order, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Completed;
|
||||
step.BlockingReason = null;
|
||||
plan.CurrentStepIndex += 1;
|
||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
||||
{
|
||||
plan.Status = AiPlanStatus.Completed;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
plan.Status = AiPlanStatus.Completed;
|
||||
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
|
||||
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
|
||||
: null;
|
||||
if (completedOrder is not null)
|
||||
{
|
||||
completedOrder.Status = OrderStatus.Completed;
|
||||
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
|
||||
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||
}
|
||||
}
|
||||
ship.ActivePlan = null;
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.25f;
|
||||
ship.LastReplanReason = "plan-completed";
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
|
||||
{
|
||||
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
|
||||
ship.ActivePlan.InterruptReason = reason;
|
||||
}
|
||||
|
||||
ship.ActivePlan = nextPlan;
|
||||
ship.ActiveOrderId = order.Id;
|
||||
ship.ActiveSubTaskIndex = 0;
|
||||
ship.ActiveSubTasks.Clear();
|
||||
ship.ActiveSubTasks.AddRange(subTasks);
|
||||
ship.NeedsReplan = false;
|
||||
ship.ReplanCooldownSeconds = 0f;
|
||||
ship.LastReplanReason = reason;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
|
||||
ship.LastReplanReason = "order-execution-started";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
}
|
||||
|
||||
private static void ClearActiveOrder(ShipRuntime ship)
|
||||
{
|
||||
ship.ActiveOrderId = null;
|
||||
ship.ActiveSubTaskIndex = 0;
|
||||
ship.ActiveSubTasks.Clear();
|
||||
}
|
||||
|
||||
private void CompleteOrderExecution(ShipRuntime ship, ShipOrderRuntime order, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
ship.OrderQueue.TryCompleteOrder(order.Id);
|
||||
if (order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(order.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||
}
|
||||
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.25f;
|
||||
ship.LastReplanReason = "order-completed";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-completed", $"{ship.Definition.Name} completed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void FailOrderExecution(ShipRuntime ship, ShipOrderRuntime order, string failureReason, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
FailOrder(ship, order, failureReason);
|
||||
ClearActiveOrder(ship);
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.5f;
|
||||
ship.LastReplanReason = failureReason;
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "order-failed", $"{ship.Definition.Name} failed {order.Label ?? order.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static void FailOrder(ShipRuntime ship, ShipOrderRuntime order, string failureReason)
|
||||
{
|
||||
ship.OrderQueue.TryFailOrder(order.Id, failureReason);
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
}
|
||||
|
||||
private static ShipSubTaskRuntime? GetCurrentSubTask(ShipRuntime ship) =>
|
||||
ship.ActiveSubTaskIndex >= ship.ActiveSubTasks.Count ? null : ship.ActiveSubTasks[ship.ActiveSubTaskIndex];
|
||||
|
||||
private void ApplyIdleOrBlockedState(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var (behaviorKind, _) = ResolveBehaviorSource(world, ship);
|
||||
if (IsBehaviorBlockingFailure(behaviorKind, ship.LastAccessFailureReason))
|
||||
{
|
||||
ship.State = ShipState.Blocked;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
}
|
||||
|
||||
private void SyncEmergencyOrders(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var desiredOrder = BuildEmergencyOrder(world, ship);
|
||||
ship.OrderQueue.RemoveWhere(order =>
|
||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(order.SourceId, ShipOrderKinds.Flee, StringComparison.Ordinal)
|
||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.OrderQueue.AddOrReplaceManagedOrderAtFront(desiredOrder);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
bool? KnownStationsOnly);
|
||||
|
||||
public sealed record ShipOrderUpdateCommandRequest(
|
||||
string Kind,
|
||||
int Priority,
|
||||
bool InterruptCurrentPlan,
|
||||
string? Label,
|
||||
string? TargetEntityId,
|
||||
string? TargetSystemId,
|
||||
Vector3Dto? TargetPosition,
|
||||
string? SourceStationId,
|
||||
string? DestinationStationId,
|
||||
string? ItemId,
|
||||
string? AnchorId,
|
||||
string? ConstructionSiteId,
|
||||
string? ModuleId,
|
||||
float? WaitSeconds,
|
||||
float? Radius,
|
||||
int? MaxSystemRange,
|
||||
bool? KnownStationsOnly);
|
||||
|
||||
public sealed record ShipOrderReorderRequest(
|
||||
int TargetIndex);
|
||||
|
||||
public sealed record ShipOrderTemplateCommandRequest(
|
||||
string Kind,
|
||||
string? Label,
|
||||
|
||||
@@ -108,29 +108,6 @@ public sealed record ShipSubTaskSnapshot(
|
||||
float TotalSeconds,
|
||||
string? BlockingReason);
|
||||
|
||||
public sealed record ShipPlanStepSnapshot(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string Summary,
|
||||
string? BlockingReason,
|
||||
int CurrentSubTaskIndex,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> SubTasks);
|
||||
|
||||
public sealed record ShipPlanSnapshot(
|
||||
string Id,
|
||||
string SourceKind,
|
||||
string SourceId,
|
||||
string Kind,
|
||||
string Status,
|
||||
string Summary,
|
||||
int CurrentStepIndex,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? InterruptReason,
|
||||
string? FailureReason,
|
||||
IReadOnlyList<ShipPlanStepSnapshot> Steps);
|
||||
|
||||
public sealed record ShipSnapshot(
|
||||
string Id,
|
||||
string Name,
|
||||
@@ -146,8 +123,6 @@ public sealed record ShipSnapshot(
|
||||
DefaultBehaviorSnapshot DefaultBehavior,
|
||||
ShipAssignmentSnapshot? Assignment,
|
||||
ShipSkillProfileSnapshot Skills,
|
||||
ShipPlanSnapshot? ActivePlan,
|
||||
string? CurrentStepId,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||
string ControlSourceKind,
|
||||
string? ControlSourceId,
|
||||
@@ -182,8 +157,6 @@ public sealed record ShipDelta(
|
||||
DefaultBehaviorSnapshot DefaultBehavior,
|
||||
ShipAssignmentSnapshot? Assignment,
|
||||
ShipSkillProfileSnapshot Skills,
|
||||
ShipPlanSnapshot? ActivePlan,
|
||||
string? CurrentStepId,
|
||||
IReadOnlyList<ShipSubTaskSnapshot> ActiveSubTasks,
|
||||
string ControlSourceKind,
|
||||
string? ControlSourceId,
|
||||
|
||||
@@ -12,8 +12,7 @@ public sealed class ShipRuntime
|
||||
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||
public ShipState State { get; set; } = ShipState.Idle;
|
||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||
public List<ShipOrderRuntime> OrderQueue { get; } = [];
|
||||
public ShipPlanRuntime? ActivePlan { get; set; }
|
||||
public ShipOrderQueue OrderQueue { get; } = new();
|
||||
public required ShipSkillProfileRuntime Skills { get; set; }
|
||||
public bool NeedsReplan { get; set; } = true;
|
||||
public float ReplanCooldownSeconds { get; set; }
|
||||
@@ -30,10 +29,190 @@ public sealed class ShipRuntime
|
||||
public float Health { get; set; }
|
||||
public HashSet<string> KnownStationIds { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> History { get; } = [];
|
||||
public string? ActiveOrderId { get; set; }
|
||||
public int ActiveSubTaskIndex { get; set; }
|
||||
public List<ShipSubTaskRuntime> ActiveSubTasks { get; } = [];
|
||||
public string LastSignature { get; set; } = string.Empty;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ShipOrderQueue : IReadOnlyList<ShipOrderRuntime>
|
||||
{
|
||||
public const int MaxOrders = 8;
|
||||
|
||||
private readonly List<ShipOrderRuntime> _orders = [];
|
||||
|
||||
public int Count => _orders.Count;
|
||||
|
||||
public ShipOrderRuntime this[int index] => _orders[index];
|
||||
|
||||
public IEnumerator<ShipOrderRuntime> GetEnumerator() => _orders.GetEnumerator();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
public void Enqueue(ShipOrderRuntime order)
|
||||
{
|
||||
if (_orders.Count >= MaxOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public void EnqueuePlayerOrder(ShipOrderRuntime order)
|
||||
{
|
||||
if (order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
throw new InvalidOperationException("Player segment only accepts player orders.");
|
||||
}
|
||||
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
_orders.Insert(GetManagedInsertionIndex(), order);
|
||||
}
|
||||
|
||||
public void EnqueueManagedOrder(ShipOrderRuntime order)
|
||||
{
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public void AddOrReplaceManagedOrder(ShipOrderRuntime order)
|
||||
=> AddOrReplaceManagedOrder(order, insertAtFront: false);
|
||||
|
||||
public void AddOrReplaceManagedOrderAtFront(ShipOrderRuntime order)
|
||||
=> AddOrReplaceManagedOrder(order, insertAtFront: true);
|
||||
|
||||
private void AddOrReplaceManagedOrder(ShipOrderRuntime order, bool insertAtFront)
|
||||
{
|
||||
var existingIndex = _orders.FindIndex(candidate => string.Equals(candidate.Id, order.Id, StringComparison.Ordinal));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
_orders[existingIndex] = order;
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCapacityForNewOrder(order.Id);
|
||||
if (insertAtFront)
|
||||
{
|
||||
_orders.Insert(GetManagedInsertionIndex(), order);
|
||||
return;
|
||||
}
|
||||
|
||||
_orders.Add(order);
|
||||
}
|
||||
|
||||
public bool Remove(ShipOrderRuntime order) => RemoveById(order.Id);
|
||||
|
||||
public bool RemoveById(string orderId) => _orders.RemoveAll(order => string.Equals(order.Id, orderId, StringComparison.Ordinal)) > 0;
|
||||
|
||||
public int RemoveWhere(Predicate<ShipOrderRuntime> predicate) => _orders.RemoveAll(predicate);
|
||||
|
||||
public ShipOrderRuntime? FindById(string orderId) => _orders.FirstOrDefault(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
|
||||
|
||||
public ShipOrderRuntime? FindLeadingOrderForSource(ShipOrderSourceKind sourceKind) =>
|
||||
_orders.FirstOrDefault(order => order.SourceKind == sourceKind);
|
||||
|
||||
public string? GetLeadingOrderLabelForSource(ShipOrderSourceKind sourceKind) =>
|
||||
FindLeadingOrderForSource(sourceKind) is { } order
|
||||
? order.Label ?? order.Kind
|
||||
: null;
|
||||
|
||||
public bool HasOrdersFromSource(ShipOrderSourceKind sourceKind) => _orders.Any(order => order.SourceKind == sourceKind);
|
||||
|
||||
public ShipOrderRuntime? GetCurrentOrder() =>
|
||||
_orders.FirstOrDefault(order => order.Status is OrderStatus.Queued or OrderStatus.Active);
|
||||
|
||||
public bool TryMovePlayerOrder(string orderId, int targetIndex)
|
||||
{
|
||||
var currentIndex = _orders.FindIndex(order => string.Equals(order.Id, orderId, StringComparison.Ordinal));
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var order = _orders[currentIndex];
|
||||
if (order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerOrderIds = _orders
|
||||
.Select((candidate, index) => (candidate, index))
|
||||
.Where(entry => entry.candidate.SourceKind == ShipOrderSourceKind.Player)
|
||||
.Select(entry => entry.index)
|
||||
.ToList();
|
||||
if (playerOrderIds.Count <= 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var clampedPlayerIndex = Math.Clamp(targetIndex, 0, playerOrderIds.Count - 1);
|
||||
var destinationIndex = playerOrderIds[clampedPlayerIndex];
|
||||
if (currentIndex == destinationIndex)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_orders.RemoveAt(currentIndex);
|
||||
if (currentIndex < destinationIndex)
|
||||
{
|
||||
destinationIndex -= 1;
|
||||
}
|
||||
|
||||
_orders.Insert(destinationIndex, order);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCompleteOrder(string orderId) => TryTransitionOrder(orderId, OrderStatus.Completed);
|
||||
|
||||
public bool TryFailOrder(string orderId, string? failureReason = null)
|
||||
{
|
||||
var order = FindById(orderId);
|
||||
if (order is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
order.FailureReason = failureReason ?? order.FailureReason;
|
||||
if (order.SourceKind == ShipOrderSourceKind.Player)
|
||||
{
|
||||
order.Status = OrderStatus.Failed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryTransitionOrder(orderId, OrderStatus.Failed);
|
||||
}
|
||||
|
||||
public bool TryTransitionOrder(string orderId, OrderStatus terminalStatus)
|
||||
{
|
||||
var order = FindById(orderId);
|
||||
if (order is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
order.Status = terminalStatus;
|
||||
return RemoveById(orderId);
|
||||
}
|
||||
|
||||
private int GetManagedInsertionIndex() =>
|
||||
_orders.TakeWhile(order => order.SourceKind == ShipOrderSourceKind.Player).Count();
|
||||
|
||||
private void EnsureCapacityForNewOrder(string orderId)
|
||||
{
|
||||
if (FindById(orderId) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_orders.Count >= MaxOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShipSkillProfileRuntime
|
||||
{
|
||||
public int Navigation { get; set; }
|
||||
@@ -111,33 +290,6 @@ public sealed class ShipOrderTemplateRuntime
|
||||
public bool KnownStationsOnly { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipPlanRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required AiPlanSourceKind SourceKind { get; init; }
|
||||
public required string SourceId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Summary { get; set; }
|
||||
public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned;
|
||||
public int CurrentStepIndex { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? InterruptReason { get; set; }
|
||||
public string? FailureReason { get; set; }
|
||||
public List<ShipPlanStepRuntime> Steps { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipPlanStepRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Summary { get; set; }
|
||||
public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned;
|
||||
public int CurrentSubTaskIndex { get; set; }
|
||||
public string? BlockingReason { get; set; }
|
||||
public List<ShipSubTaskRuntime> SubTasks { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipSubTaskRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
@@ -201,8 +201,6 @@ internal sealed class SimulationProjectionService
|
||||
ship.DefaultBehavior,
|
||||
ship.Assignment,
|
||||
ship.Skills,
|
||||
ship.ActivePlan,
|
||||
ship.CurrentStepId,
|
||||
ship.ActiveSubTasks,
|
||||
ship.ControlSourceKind,
|
||||
ship.ControlSourceId,
|
||||
@@ -569,9 +567,6 @@ internal sealed class SimulationProjectionService
|
||||
ship.TargetPosition.Z.ToString("0.###"),
|
||||
ship.State.ToContractValue(),
|
||||
string.Join(",", ship.OrderQueue
|
||||
.OrderByDescending(GetOrderSourcePriority)
|
||||
.ThenByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => $"{order.Id}:{order.Kind}:{order.SourceKind.ToContractValue()}:{order.SourceId}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")),
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.DefaultBehavior.TargetEntityId ?? "none",
|
||||
@@ -595,9 +590,6 @@ internal sealed class SimulationProjectionService
|
||||
ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment
|
||||
? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}"
|
||||
: "no-assignment",
|
||||
ship.ActivePlan?.Kind ?? "none",
|
||||
ship.ActivePlan?.Status.ToContractValue() ?? "none",
|
||||
ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1",
|
||||
string.Join(",",
|
||||
ToActiveSubTaskSnapshots(ship).Select(subTask =>
|
||||
$"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")),
|
||||
@@ -620,7 +612,9 @@ internal sealed class SimulationProjectionService
|
||||
ship.Skills.Combat.ToString(CultureInfo.InvariantCulture),
|
||||
ship.Skills.Construction.ToString(CultureInfo.InvariantCulture),
|
||||
ship.Health.ToString("0.###"),
|
||||
GetCurrentShipStep(ship)?.Id ?? "none");
|
||||
ship.ActiveSubTaskIndex >= 0 && ship.ActiveSubTaskIndex < ship.ActiveSubTasks.Count
|
||||
? ship.ActiveSubTasks[ship.ActiveSubTaskIndex].Id
|
||||
: "none");
|
||||
|
||||
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||
string.Join(",",
|
||||
@@ -889,8 +883,6 @@ internal sealed class SimulationProjectionService
|
||||
ToDefaultBehaviorSnapshot(ship.DefaultBehavior),
|
||||
ToShipAssignmentSnapshot(commander),
|
||||
new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction),
|
||||
ToShipPlanSnapshot(ship.ActivePlan),
|
||||
GetCurrentShipStep(ship)?.Id,
|
||||
ToActiveSubTaskSnapshots(ship),
|
||||
ship.ControlSourceKind,
|
||||
ship.ControlSourceId,
|
||||
@@ -923,7 +915,7 @@ internal sealed class SimulationProjectionService
|
||||
{
|
||||
MovementRegimeKind.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
|
||||
MovementRegimeKind.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
|
||||
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
|
||||
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())), "m/s"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -936,9 +928,6 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
private static IReadOnlyList<ShipOrderSnapshot> ToShipOrderSnapshots(ShipRuntime ship) =>
|
||||
ship.OrderQueue
|
||||
.OrderByDescending(GetOrderSourcePriority)
|
||||
.ThenByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => new ShipOrderSnapshot(
|
||||
order.Id,
|
||||
order.Kind,
|
||||
@@ -965,14 +954,6 @@ internal sealed class SimulationProjectionService
|
||||
order.FailureReason))
|
||||
.ToList();
|
||||
|
||||
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
|
||||
{
|
||||
ShipOrderSourceKind.Player => 300,
|
||||
ShipOrderSourceKind.Commander => 200,
|
||||
ShipOrderSourceKind.Behavior => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) =>
|
||||
new(
|
||||
behavior.Kind,
|
||||
@@ -1039,38 +1020,6 @@ internal sealed class SimulationProjectionService
|
||||
assignment.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan)
|
||||
{
|
||||
if (plan is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ShipPlanSnapshot(
|
||||
plan.Id,
|
||||
plan.SourceKind.ToContractValue(),
|
||||
plan.SourceId,
|
||||
plan.Kind,
|
||||
plan.Status.ToContractValue(),
|
||||
plan.Summary,
|
||||
plan.CurrentStepIndex,
|
||||
plan.CreatedAtUtc,
|
||||
plan.UpdatedAtUtc,
|
||||
plan.InterruptReason,
|
||||
plan.FailureReason,
|
||||
plan.Steps.Select(ToShipPlanStepSnapshot).ToList());
|
||||
}
|
||||
|
||||
private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) =>
|
||||
new(
|
||||
step.Id,
|
||||
step.Kind,
|
||||
step.Status.ToContractValue(),
|
||||
step.Summary,
|
||||
step.BlockingReason,
|
||||
step.CurrentSubTaskIndex,
|
||||
step.SubTasks.Select(ToShipSubTaskSnapshot).ToList());
|
||||
|
||||
private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) =>
|
||||
new(
|
||||
subTask.Id,
|
||||
@@ -1094,23 +1043,12 @@ internal sealed class SimulationProjectionService
|
||||
|
||||
private static IReadOnlyList<ShipSubTaskSnapshot> ToActiveSubTaskSnapshots(ShipRuntime ship)
|
||||
{
|
||||
var step = GetCurrentShipStep(ship);
|
||||
if (step is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return step.SubTasks
|
||||
return ship.ActiveSubTasks
|
||||
.Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked)
|
||||
.Select(ToShipSubTaskSnapshot)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) =>
|
||||
ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count
|
||||
? null
|
||||
: ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex];
|
||||
|
||||
private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander)
|
||||
{
|
||||
var assignment = commander.Assignment;
|
||||
|
||||
@@ -314,24 +314,28 @@ public sealed class SpatialBuilder
|
||||
ResourceNodeDefinition definition,
|
||||
float oreAmount)
|
||||
{
|
||||
var depositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 12);
|
||||
var derivedDepositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 18);
|
||||
var depositCount = Math.Clamp(definition.ShardCount > 0 ? definition.ShardCount : derivedDepositCount, 4, 48);
|
||||
var deposits = new List<ResourceDepositRuntime>(depositCount);
|
||||
var weightTotal = 0f;
|
||||
var weights = new float[depositCount];
|
||||
var random = new Random(ComputeDeterministicSeed(systemId, nodeId, "resource-deposits"));
|
||||
for (var index = 0; index < depositCount; index += 1)
|
||||
{
|
||||
var weight = 0.8f + (Hash01(systemId, nodeId, $"weight-{index}") * 1.6f);
|
||||
var weight = 0.8f + (NextFloat01(random) * 1.6f);
|
||||
weights[index] = weight;
|
||||
weightTotal += weight;
|
||||
}
|
||||
|
||||
var scatterRadius = MathF.Max(140f, LocalSpaceRadius * 0.58f);
|
||||
// Resource node localspace should read as a compact mineable field around the node core,
|
||||
// not as sparse debris spread across the entire anchor volume.
|
||||
var scatterRadius = MathF.Max(120f, MathF.Min(LocalSpaceRadius * 0.2f, 900f));
|
||||
for (var index = 0; index < depositCount; index += 1)
|
||||
{
|
||||
var angle = Hash01(systemId, nodeId, $"angle-{index}") * MathF.PI * 2f;
|
||||
var radiusFactor = 0.22f + (Hash01(systemId, nodeId, $"radius-{index}") * 0.74f);
|
||||
var angle = NextFloat01(random) * MathF.PI * 2f;
|
||||
var radiusFactor = 0.12f + (NextFloat01(random) * 0.82f);
|
||||
var radius = scatterRadius * MathF.Sqrt(radiusFactor);
|
||||
var vertical = (Hash01(systemId, nodeId, $"vertical-{index}") - 0.5f) * MathF.Max(60f, scatterRadius * 0.14f);
|
||||
var vertical = (NextFloat01(random) - 0.5f) * MathF.Max(40f, scatterRadius * 0.18f);
|
||||
var localPosition = new Vector3(
|
||||
MathF.Cos(angle) * radius,
|
||||
vertical,
|
||||
@@ -351,6 +355,32 @@ public sealed class SpatialBuilder
|
||||
return deposits;
|
||||
}
|
||||
|
||||
private static int ComputeDeterministicSeed(string systemId, string nodeId, string salt)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = 17;
|
||||
foreach (var character in systemId)
|
||||
{
|
||||
hash = (hash * 31) + character;
|
||||
}
|
||||
|
||||
foreach (var character in nodeId)
|
||||
{
|
||||
hash = (hash * 31) + character;
|
||||
}
|
||||
|
||||
foreach (var character in salt)
|
||||
{
|
||||
hash = (hash * 31) + character;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static float NextFloat01(Random random) => (float)random.NextDouble();
|
||||
|
||||
private static float Hash01(string systemId, string nodeId, string salt)
|
||||
{
|
||||
unchecked
|
||||
@@ -391,13 +421,15 @@ public sealed class SpatialBuilder
|
||||
|
||||
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<AnchorRuntime> anchors)
|
||||
{
|
||||
var systemPosition = SimulationUnits.MetersToKilometers(position);
|
||||
var nearestAnchor = anchors
|
||||
.Where(anchor => anchor.SystemId == systemId)
|
||||
.OrderBy(anchor => anchor.Position.DistanceTo(position))
|
||||
.OrderBy(anchor => anchor.Position.DistanceTo(systemPosition))
|
||||
.FirstOrDefault();
|
||||
var localPosition = nearestAnchor is null
|
||||
? position
|
||||
: position.Subtract(nearestAnchor.Position);
|
||||
var localPosition = position;
|
||||
var resolvedSystemPosition = nearestAnchor is null
|
||||
? systemPosition
|
||||
: Add(nearestAnchor.Position, SimulationUnits.MetersToKilometers(localPosition));
|
||||
|
||||
return new ShipSpatialStateRuntime
|
||||
{
|
||||
@@ -405,7 +437,7 @@ public sealed class SpatialBuilder
|
||||
SpaceLayer = SpaceLayerKind.LocalSpace,
|
||||
CurrentAnchorId = nearestAnchor?.Id,
|
||||
LocalPosition = localPosition,
|
||||
SystemPosition = position,
|
||||
SystemPosition = resolvedSystemPosition,
|
||||
MovementRegime = MovementRegimeKind.LocalFlight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ public sealed class WorldBuilder(
|
||||
WorldGenerationOptions worldGenerationOptions,
|
||||
ScenarioDefinition? scenarioDefinition)
|
||||
{
|
||||
var topology = topologyBuilder.Build(worldGenerationOptions);
|
||||
// Temporary QA override: allow a scenario to provide an exact system list
|
||||
// instead of going through procedural topology generation.
|
||||
var topology = scenarioDefinition?.Systems is { Count: > 0 } scenarioSystems
|
||||
? topologyBuilder.Build(scenarioSystems)
|
||||
: topologyBuilder.Build(worldGenerationOptions);
|
||||
var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems);
|
||||
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal));
|
||||
|
||||
|
||||
@@ -13,6 +13,22 @@ public sealed class WorldTopologyBuilder(
|
||||
generationService.PrepareKnownSystems(staticData.KnownSystems),
|
||||
worldGenerationOptions);
|
||||
|
||||
return BuildFromDefinitions(systems);
|
||||
}
|
||||
|
||||
public WorldBuildTopology Build(IReadOnlyList<SolarSystemDefinition> systems)
|
||||
{
|
||||
if (systems.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Scenario-defined systems cannot be empty.");
|
||||
}
|
||||
|
||||
// Temporary QA-only path for fixed-topology scenarios such as "minimal".
|
||||
return BuildFromDefinitions(systems);
|
||||
}
|
||||
|
||||
private WorldBuildTopology BuildFromDefinitions(IReadOnlyList<SolarSystemDefinition> systems)
|
||||
{
|
||||
var systemRuntimes = systems
|
||||
.Select(definition => new SystemRuntime
|
||||
{
|
||||
|
||||
@@ -308,9 +308,10 @@ internal sealed class OrbitalStateUpdater
|
||||
}
|
||||
|
||||
ship.SpatialState.CurrentAnchorId = currentAnchor?.Id;
|
||||
var localSystemOffset = SimulationUnits.MetersToKilometers(ship.Position);
|
||||
ship.SpatialState.SystemPosition = currentAnchor is null
|
||||
? ship.Position
|
||||
: Add(currentAnchor.Position, ship.Position);
|
||||
? localSystemOffset
|
||||
: Add(currentAnchor.Position, localSystemOffset);
|
||||
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
|
||||
@@ -129,6 +129,39 @@ public sealed class WorldService
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? UpdateShipOrder(string shipId, string orderId, ShipOrderUpdateCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
ValidateShipOrderRequestUnsafe(shipId, ToCommandRequest(request));
|
||||
var ship = CanCurrentActorAccessGm()
|
||||
? UpdateGmShipOrderUnsafe(shipId, orderId, request)
|
||||
: _playerFaction.UpdateDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? ReorderShipOrder(string shipId, string orderId, ShipOrderReorderRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var ship = CanCurrentActorAccessGm()
|
||||
? ReorderGmShipOrderUnsafe(shipId, orderId, request.TargetIndex)
|
||||
: _playerFaction.ReorderDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request.TargetIndex);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetShipSnapshotUnsafe(ship.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
lock (_sync)
|
||||
@@ -694,6 +727,30 @@ public sealed class WorldService
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyShipOrderRequest(ShipOrderRuntime order, ShipOrderUpdateCommandRequest request)
|
||||
{
|
||||
order.Priority = request.Priority;
|
||||
order.InterruptCurrentPlan = request.InterruptCurrentPlan;
|
||||
order.Label = request.Label;
|
||||
order.TargetEntityId = request.TargetEntityId;
|
||||
order.TargetSystemId = request.TargetSystemId;
|
||||
order.TargetPosition = request.TargetPosition is null
|
||||
? null
|
||||
: new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
|
||||
order.SourceStationId = request.SourceStationId;
|
||||
order.DestinationStationId = request.DestinationStationId;
|
||||
order.ItemId = request.ItemId;
|
||||
order.AnchorId = request.AnchorId;
|
||||
order.ConstructionSiteId = request.ConstructionSiteId;
|
||||
order.ModuleId = request.ModuleId;
|
||||
order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f);
|
||||
order.Radius = MathF.Max(0f, request.Radius ?? 0f);
|
||||
order.MaxSystemRange = request.MaxSystemRange;
|
||||
order.KnownStationsOnly = request.KnownStationsOnly ?? false;
|
||||
order.Status = OrderStatus.Queued;
|
||||
order.FailureReason = null;
|
||||
}
|
||||
|
||||
private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
|
||||
{
|
||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
@@ -702,12 +759,7 @@ public sealed class WorldService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ship.OrderQueue.Count >= 8)
|
||||
{
|
||||
throw new InvalidOperationException("Order queue is full.");
|
||||
}
|
||||
|
||||
ship.OrderQueue.Add(new ShipOrderRuntime
|
||||
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
|
||||
{
|
||||
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
|
||||
Kind = request.Kind,
|
||||
@@ -732,12 +784,7 @@ public sealed class WorldService
|
||||
});
|
||||
|
||||
ship.ControlSourceKind = "gm-order";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = request.Label ?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "gm-order-enqueued";
|
||||
@@ -753,22 +800,12 @@ public sealed class WorldService
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.OrderQueue.RemoveAll(order => order.Id == orderId);
|
||||
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
ship.OrderQueue.RemoveById(orderId);
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "gm-order"
|
||||
: "gm-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Id)
|
||||
.FirstOrDefault();
|
||||
ship.ControlReason = ship.OrderQueue
|
||||
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
||||
.OrderByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.Select(order => order.Label ?? order.Kind)
|
||||
.FirstOrDefault()
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-gm-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "gm-order-removed";
|
||||
@@ -776,6 +813,59 @@ public sealed class WorldService
|
||||
return ship;
|
||||
}
|
||||
|
||||
private ShipRuntime? UpdateGmShipOrderUnsafe(string shipId, string orderId, ShipOrderUpdateCommandRequest request)
|
||||
{
|
||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var order = ship.OrderQueue.FindById(orderId);
|
||||
if (order is null || order.SourceKind != ShipOrderSourceKind.Player)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ApplyShipOrderRequest(order, request);
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "gm-order"
|
||||
: "gm-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? request.Label
|
||||
?? request.Kind;
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "gm-order-updated";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
private ShipRuntime? ReorderGmShipOrderUnsafe(string shipId, string orderId, int targetIndex)
|
||||
{
|
||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
if (ship is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex))
|
||||
{
|
||||
return ship;
|
||||
}
|
||||
|
||||
ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player)
|
||||
? "gm-order"
|
||||
: "gm-manual";
|
||||
ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id;
|
||||
ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player)
|
||||
?? "manual-gm-control";
|
||||
ship.NeedsReplan = true;
|
||||
ship.LastReplanReason = "gm-order-reordered";
|
||||
ship.LastDeltaSignature = string.Empty;
|
||||
return ship;
|
||||
}
|
||||
|
||||
private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
|
||||
{
|
||||
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
||||
@@ -837,6 +927,26 @@ public sealed class WorldService
|
||||
return ship;
|
||||
}
|
||||
|
||||
private static ShipOrderCommandRequest ToCommandRequest(ShipOrderUpdateCommandRequest request) =>
|
||||
new(
|
||||
request.Kind,
|
||||
request.Priority,
|
||||
request.InterruptCurrentPlan,
|
||||
request.Label,
|
||||
request.TargetEntityId,
|
||||
request.TargetSystemId,
|
||||
request.TargetPosition,
|
||||
request.SourceStationId,
|
||||
request.DestinationStationId,
|
||||
request.ItemId,
|
||||
request.AnchorId,
|
||||
request.ConstructionSiteId,
|
||||
request.ModuleId,
|
||||
request.WaitSeconds,
|
||||
request.Radius,
|
||||
request.MaxSystemRange,
|
||||
request.KnownStationsOnly);
|
||||
|
||||
private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
|
||||
{
|
||||
Id = $"commander-faction-{faction.Id}",
|
||||
@@ -915,12 +1025,15 @@ public sealed class WorldService
|
||||
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius);
|
||||
}
|
||||
|
||||
private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) =>
|
||||
_world.Anchors
|
||||
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
|
||||
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
|
||||
.OrderBy(candidate => candidate.Position.DistanceTo(position))
|
||||
.FirstOrDefault();
|
||||
private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position)
|
||||
{
|
||||
var systemPosition = SimulationUnits.MetersToKilometers(position);
|
||||
return _world.Anchors
|
||||
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
|
||||
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
|
||||
.OrderBy(candidate => candidate.Position.DistanceTo(systemPosition))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private string? ResolveNearestAnchorId(string systemId, Vector3 position) =>
|
||||
ResolveNearestConstructibleAnchor(systemId, position)?.Id;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
|
||||
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
|
||||
LOCAL_SYSTEM_BACKDROP_DISTANCE,
|
||||
MAX_CAMERA_DISTANCE,
|
||||
MIN_CAMERA_DISTANCE,
|
||||
MIN_LOCAL_CAMERA_DISTANCE,
|
||||
NAV_DISTANCE,
|
||||
} from "./viewerConstants";
|
||||
import { updatePanFromKeyboard } from "./viewerCamera";
|
||||
@@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer";
|
||||
import { LocalLayer } from "./viewerLocalLayer";
|
||||
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
||||
import { describeSelectable } from "./viewerSelection";
|
||||
import { resolveLocalAnchorOffset } from "./viewerWorldPresentation";
|
||||
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
||||
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
||||
@@ -88,6 +93,7 @@ export class ViewerAppController {
|
||||
private selectedItems: Selectable[] = [];
|
||||
private worldSignature = "";
|
||||
private povLevel: PovLevel = "system";
|
||||
private previousPovLevel: PovLevel = "system";
|
||||
private currentDistance = NAV_DISTANCE.system;
|
||||
private desiredDistance = NAV_DISTANCE.system;
|
||||
private orbitYaw = -2.3;
|
||||
@@ -100,6 +106,7 @@ export class ViewerAppController {
|
||||
private marqueeActive = false;
|
||||
private suppressClickSelection = false;
|
||||
private activeSystemId?: string;
|
||||
private cameraFocusedAnchorId?: string;
|
||||
private cameraTargetShipId?: string;
|
||||
private readonly followCameraPosition = new THREE.Vector3();
|
||||
private readonly followCameraFocus = new THREE.Vector3();
|
||||
@@ -262,15 +269,34 @@ export class ViewerAppController {
|
||||
});
|
||||
}
|
||||
|
||||
private computeOrbitOffset(): THREE.Vector3 {
|
||||
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
|
||||
private computeOrbitOffset(cameraDistance: number): THREE.Vector3 {
|
||||
const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch);
|
||||
return new THREE.Vector3(
|
||||
Math.cos(this.orbitYaw) * horizontalDistance,
|
||||
this.currentDistance * Math.sin(this.orbitPitch),
|
||||
cameraDistance * Math.sin(this.orbitPitch),
|
||||
Math.sin(this.orbitYaw) * horizontalDistance,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveLocalOrbitCameraDistance() {
|
||||
const clamped = THREE.MathUtils.clamp(this.currentDistance, MIN_LOCAL_CAMERA_DISTANCE, 650);
|
||||
return THREE.MathUtils.mapLinear(
|
||||
clamped,
|
||||
MIN_LOCAL_CAMERA_DISTANCE,
|
||||
650,
|
||||
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
|
||||
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSystemOrbitCameraDistance() {
|
||||
if (this.povLevel !== "local") {
|
||||
return this.currentDistance;
|
||||
}
|
||||
|
||||
return LOCAL_SYSTEM_BACKDROP_DISTANCE;
|
||||
}
|
||||
|
||||
private updateCamera(delta: number) {
|
||||
const nextState = stepCamera({
|
||||
currentDistance: this.currentDistance,
|
||||
@@ -279,6 +305,7 @@ export class ViewerAppController {
|
||||
delta,
|
||||
});
|
||||
this.currentDistance = nextState.currentDistance;
|
||||
this.previousPovLevel = this.povLevel;
|
||||
this.povLevel = nextState.povLevel;
|
||||
this.orbitPitch = nextState.orbitPitch;
|
||||
if (this.sceneStore.povLevel !== this.povLevel) {
|
||||
@@ -286,27 +313,29 @@ export class ViewerAppController {
|
||||
}
|
||||
this.navigationController.updateActiveSystem();
|
||||
this.navigationController.syncGalaxyAnchorToActiveSystem();
|
||||
this.updateCameraFocusedAnchor();
|
||||
|
||||
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
||||
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
|
||||
// Still update galaxy camera independently.
|
||||
const orbitOffset = this.computeOrbitOffset();
|
||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
||||
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
|
||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePanFromKeyboard(delta);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
||||
|
||||
const orbitOffset = this.computeOrbitOffset();
|
||||
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
|
||||
const localOrbitOffset = this.computeOrbitOffset(this.resolveLocalOrbitCameraDistance());
|
||||
|
||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
||||
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
|
||||
|
||||
if (this.activeSystemId) {
|
||||
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
|
||||
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), systemOrbitOffset);
|
||||
}
|
||||
|
||||
this.localLayer.updateCamera(orbitOffset);
|
||||
this.localLayer.updateCamera(this.systemAnchor, localOrbitOffset, resolveLocalAnchorOffset(this.world, this.resolveFocusedAnchorId()));
|
||||
|
||||
// Update star dot scales in galaxy scene
|
||||
updateSystemStarPresentation(
|
||||
@@ -353,7 +382,48 @@ export class ViewerAppController {
|
||||
}
|
||||
|
||||
private resolveFocusedAnchorId() {
|
||||
return resolveFocusedAnchorId(this.world, this.selectedItems);
|
||||
return this.cameraFocusedAnchorId;
|
||||
}
|
||||
|
||||
private updateCameraFocusedAnchor() {
|
||||
if (!this.world || !this.activeSystemId || this.povLevel === "galaxy") {
|
||||
this.cameraFocusedAnchorId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.povLevel === "system") {
|
||||
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.previousPovLevel !== "local" || !this.cameraFocusedAnchorId) {
|
||||
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus() ?? this.cameraFocusedAnchorId;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveNearestAnchorToSystemFocus() {
|
||||
if (!this.world || !this.activeSystemId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let bestAnchorId: string | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const anchor of this.world.anchors.values()) {
|
||||
if (anchor.systemId !== this.activeSystemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = anchor.systemPosition.x - this.systemAnchor.x;
|
||||
const dy = anchor.systemPosition.y - this.systemAnchor.y;
|
||||
const dz = anchor.systemPosition.z - this.systemAnchor.z;
|
||||
const distanceSquared = (dx * dx) + (dy * dy) + (dz * dz);
|
||||
if (distanceSquared < bestDistance) {
|
||||
bestDistance = distanceSquared;
|
||||
bestAnchorId = anchor.id;
|
||||
}
|
||||
}
|
||||
|
||||
return bestAnchorId;
|
||||
}
|
||||
|
||||
private onResize(width: number, height: number) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import type {
|
||||
ShipDefaultBehaviorCommandRequest,
|
||||
ShipOrderCommandRequest,
|
||||
ShipOrderUpdateCommandRequest,
|
||||
} from "./shipCommands";
|
||||
|
||||
export interface WorldStreamScope {
|
||||
@@ -318,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateShipOrder(shipId: string, orderId: string, request: ShipOrderUpdateCommandRequest) {
|
||||
return fetchJson<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 dockToken = ship.dockedStationId ? "DCK" : "";
|
||||
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
||||
const taskToken = ship.activeSubTasks.length > 0 ? "TSK" : "";
|
||||
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||
const commandToken = ship.commanderId ? "CMD" : "";
|
||||
|
||||
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||
return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||
}
|
||||
|
||||
function stationAiStates(station: StationSnapshot) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import modulesData from "../../../../shared/data/modules.json";
|
||||
import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api";
|
||||
import { removeShipOrder, updateShipDefaultBehavior, updateShipOrder } from "../api";
|
||||
import type { ShipOrderSnapshot } from "../contractsShips";
|
||||
import {
|
||||
formatShipAutomationSupportStatus,
|
||||
getShipBehaviorLabel,
|
||||
@@ -43,16 +44,23 @@ const behaviorForm = reactive({
|
||||
areaSystemId: "",
|
||||
itemId: "ore",
|
||||
});
|
||||
|
||||
const mineOrderForm = reactive({
|
||||
systemId: "",
|
||||
itemId: "ore",
|
||||
});
|
||||
|
||||
const moveOrderSystemId = ref("");
|
||||
const actionBusy = ref(false);
|
||||
const actionStatus = ref("");
|
||||
const actionError = ref("");
|
||||
const expandedDirectOrderId = ref<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>(
|
||||
(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(" · ");
|
||||
}
|
||||
|
||||
function describeOrderFailure(order: {
|
||||
failureReason?: string | null;
|
||||
kind: string;
|
||||
itemId?: string | null;
|
||||
}) {
|
||||
switch (order.failureReason) {
|
||||
case "mine-order-node-missing":
|
||||
return `Cannot find ${order.itemId ?? "resource"} to mine`;
|
||||
case "mine-order-item-missing":
|
||||
return "No mining ware selected";
|
||||
case "mine-order-node-system-mismatch":
|
||||
return "Selected mining target is in the wrong system";
|
||||
case "mine-order-node-item-mismatch":
|
||||
return `Selected mining target does not provide ${order.itemId ?? "the requested ware"}`;
|
||||
case "mine-order-incomplete":
|
||||
case "mine-and-deliver-order-incomplete":
|
||||
return `Cannot complete ${getShipOrderLabel(order.kind).toLowerCase()}`;
|
||||
case "target-ship-missing":
|
||||
return "Target ship no longer exists";
|
||||
case "target-missing":
|
||||
return "Target no longer exists";
|
||||
case "station-missing":
|
||||
return "Station no longer exists";
|
||||
default:
|
||||
return order.failureReason ? titleCase(order.failureReason) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeOrderTarget(order: {
|
||||
itemId?: string | null;
|
||||
targetEntityId?: string | null;
|
||||
@@ -161,11 +197,7 @@ const canDirectControlSelectedShip = computed(() =>
|
||||
);
|
||||
|
||||
const directOrders = computed(() =>
|
||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [],
|
||||
);
|
||||
|
||||
const behaviorOrders = computed(() =>
|
||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
|
||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [],
|
||||
);
|
||||
|
||||
const editableBehaviorDefinitions = computed(() =>
|
||||
@@ -189,6 +221,10 @@ const formBehaviorNotes = computed(() =>
|
||||
getShipBehaviorNotes(behaviorForm.kind),
|
||||
);
|
||||
|
||||
const behaviorGeneratedOrderCount = computed(() =>
|
||||
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior").length ?? 0,
|
||||
);
|
||||
|
||||
const shipStatusRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
@@ -206,9 +242,9 @@ const shipStatusRows = computed(() => {
|
||||
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||
{
|
||||
label: "Plan",
|
||||
value: selectedShip.value.activePlan
|
||||
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
||||
label: "Activity",
|
||||
value: selectedShip.value.activeSubTasks[0]
|
||||
? `${selectedShip.value.activeSubTasks[0].summary || titleCase(selectedShip.value.activeSubTasks[0].kind)} · ${titleCase(selectedShip.value.activeSubTasks[0].status)}`
|
||||
: "none",
|
||||
},
|
||||
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||
@@ -260,67 +296,33 @@ const shipBehaviorRows = computed(() => {
|
||||
const directOrderRows = computed(() =>
|
||||
directOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
kind: order.kind,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
titleCase(order.sourceKind),
|
||||
order.failureReason ?? undefined,
|
||||
describeOrderFailure(order) ?? undefined,
|
||||
]),
|
||||
})),
|
||||
);
|
||||
|
||||
const behaviorOrderRows = computed(() =>
|
||||
behaviorOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
const shipPlanRows = computed(() =>
|
||||
(selectedShip.value?.activeSubTasks ?? []).map((subTask) => ({
|
||||
id: subTask.id,
|
||||
scope: "Task",
|
||||
activity: subTask.summary || titleCase(subTask.kind),
|
||||
status: titleCase(subTask.status),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
||||
getShipOrderNotes(order.kind) ?? undefined,
|
||||
order.failureReason ?? undefined,
|
||||
describeSubTaskTarget(subTask),
|
||||
subTask.blockingReason ?? undefined,
|
||||
`${Math.round(subTask.progress * 100)}%`,
|
||||
]),
|
||||
isSubTask: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const shipPlanRows = computed(() => {
|
||||
if (!selectedShip.value?.activePlan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
||||
const stepRow = {
|
||||
id: step.id,
|
||||
scope: "Step",
|
||||
activity: step.summary || titleCase(step.kind),
|
||||
status: titleCase(step.status),
|
||||
detail: joinDetail([
|
||||
step.blockingReason ?? undefined,
|
||||
`${step.subTasks.length} subtasks`,
|
||||
]),
|
||||
isSubTask: false,
|
||||
};
|
||||
|
||||
const subTaskRows = step.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
scope: "Subtask",
|
||||
activity: subTask.summary || titleCase(subTask.kind),
|
||||
status: titleCase(subTask.status),
|
||||
detail: joinDetail([
|
||||
describeSubTaskTarget(subTask),
|
||||
subTask.blockingReason ?? undefined,
|
||||
`${Math.round(subTask.progress * 100)}%`,
|
||||
]),
|
||||
isSubTask: true,
|
||||
}));
|
||||
|
||||
return [stepRow, ...subTaskRows];
|
||||
});
|
||||
});
|
||||
|
||||
const stationStatusRows = computed(() => {
|
||||
if (!selectedStation.value) {
|
||||
return [];
|
||||
@@ -397,15 +399,116 @@ watch(
|
||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||
mineOrderForm.systemId = ship.systemId ?? "";
|
||||
mineOrderForm.itemId = "ore";
|
||||
moveOrderSystemId.value = ship.systemId ?? "";
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
expandedDirectOrderId.value = null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
|
||||
switch (field) {
|
||||
case "targetSystemId":
|
||||
return kind === "move" || kind === "mine-and-deliver";
|
||||
case "targetEntityId":
|
||||
return kind === "follow-ship" || kind === "attack-target";
|
||||
case "itemId":
|
||||
return kind === "mine-and-deliver";
|
||||
case "waitSeconds":
|
||||
return kind === "hold-position" || kind === "follow-ship";
|
||||
case "radius":
|
||||
return kind === "move" || kind === "follow-ship";
|
||||
case "maxSystemRange":
|
||||
return kind === "mine-and-deliver";
|
||||
case "knownStationsOnly":
|
||||
return kind === "mine-and-deliver";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrderEditor(order: ShipOrderSnapshot) {
|
||||
orderEditForm.label = order.label ?? "";
|
||||
orderEditForm.priority = String(order.priority);
|
||||
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
|
||||
orderEditForm.targetSystemId = order.targetSystemId ?? "";
|
||||
orderEditForm.targetEntityId = order.targetEntityId ?? "";
|
||||
orderEditForm.itemId = order.itemId ?? "ore";
|
||||
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
|
||||
orderEditForm.radius = String(order.radius ?? 0);
|
||||
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
|
||||
orderEditForm.knownStationsOnly = order.knownStationsOnly;
|
||||
}
|
||||
|
||||
function toggleOrderEditor(order: ShipOrderSnapshot) {
|
||||
if (expandedDirectOrderId.value === order.id) {
|
||||
expandedDirectOrderId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loadOrderEditor(order);
|
||||
expandedDirectOrderId.value = order.id;
|
||||
}
|
||||
|
||||
function parseNumber(value: string, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function saveOrder(order: ShipOrderSnapshot) {
|
||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runShipAction(async () => {
|
||||
const ship = await updateShipOrder(selectedShip.value!.id, order.id, {
|
||||
kind: order.kind,
|
||||
priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))),
|
||||
interruptCurrentPlan: orderEditForm.interruptCurrentPlan,
|
||||
label: orderEditForm.label.trim() || null,
|
||||
targetEntityId: supportsOrderField(order.kind, "targetEntityId")
|
||||
? (orderEditForm.targetEntityId.trim() || null)
|
||||
: order.targetEntityId ?? null,
|
||||
targetSystemId: supportsOrderField(order.kind, "targetSystemId")
|
||||
? (orderEditForm.targetSystemId.trim() || null)
|
||||
: order.targetSystemId ?? null,
|
||||
targetPosition: order.targetPosition ?? null,
|
||||
sourceStationId: order.sourceStationId ?? null,
|
||||
destinationStationId: order.destinationStationId ?? null,
|
||||
itemId: supportsOrderField(order.kind, "itemId")
|
||||
? (orderEditForm.itemId.trim() || null)
|
||||
: order.itemId ?? null,
|
||||
anchorId: order.anchorId ?? null,
|
||||
constructionSiteId: order.constructionSiteId ?? null,
|
||||
moduleId: order.moduleId ?? null,
|
||||
waitSeconds: supportsOrderField(order.kind, "waitSeconds")
|
||||
? parseNumber(orderEditForm.waitSeconds, order.waitSeconds)
|
||||
: order.waitSeconds,
|
||||
radius: supportsOrderField(order.kind, "radius")
|
||||
? parseNumber(orderEditForm.radius, order.radius)
|
||||
: order.radius,
|
||||
maxSystemRange: supportsOrderField(order.kind, "maxSystemRange")
|
||||
? parseOptionalInt(orderEditForm.maxSystemRange)
|
||||
: order.maxSystemRange ?? null,
|
||||
knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly")
|
||||
? orderEditForm.knownStationsOnly
|
||||
: order.knownStationsOnly,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
expandedDirectOrderId.value = null;
|
||||
}, "Order updated.");
|
||||
}
|
||||
|
||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||
if (!selectedShip.value) {
|
||||
return;
|
||||
@@ -468,114 +571,6 @@ async function saveBehavior() {
|
||||
}, "Default behavior updated.");
|
||||
}
|
||||
|
||||
async function queueHoldPositionOrder() {
|
||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runShipAction(async () => {
|
||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
||||
kind: "hold-position",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: "Hold position",
|
||||
targetEntityId: null,
|
||||
targetSystemId: null,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId: null,
|
||||
anchorId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
}, "Hold position order queued.");
|
||||
}
|
||||
|
||||
async function queueMoveOrder() {
|
||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSystemId = moveOrderSystemId.value.trim();
|
||||
if (!targetSystemId) {
|
||||
actionError.value = "Select a target system.";
|
||||
actionStatus.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await runShipAction(async () => {
|
||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
||||
kind: "move",
|
||||
priority: 90,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Move to ${targetSystemId}`,
|
||||
targetEntityId: null,
|
||||
targetSystemId,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId: null,
|
||||
anchorId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
}, "Move order queued.");
|
||||
}
|
||||
|
||||
async function queueMineResourceOrder() {
|
||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId;
|
||||
const itemId = mineOrderForm.itemId.trim();
|
||||
if (!targetSystemId) {
|
||||
actionError.value = "Select a mining system.";
|
||||
actionStatus.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
actionError.value = "Select a ware to mine.";
|
||||
actionStatus.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await runShipAction(async () => {
|
||||
const ship = await enqueueShipOrder(selectedShip.value!.id, {
|
||||
kind: "mine-and-deliver",
|
||||
priority: 95,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Mine ${itemId} in ${targetSystemId}`,
|
||||
targetEntityId: null,
|
||||
targetSystemId,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId,
|
||||
anchorId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
}, "Mine Resource order queued.");
|
||||
}
|
||||
|
||||
async function removeOrder(orderId: string) {
|
||||
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
|
||||
return;
|
||||
@@ -632,43 +627,114 @@ async function clearOrders() {
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Cargo</h4>
|
||||
<div class="entity-inspector-capacity-list">
|
||||
<div v-for="row in shipCargoBarRows" :key="row.key" class="entity-inspector-capacity">
|
||||
<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 class="entity-inspector-capacity__scale">
|
||||
<span>0</span>
|
||||
<div class="entity-inspector-capacity__track">
|
||||
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
|
||||
<h4>Order Queue</h4>
|
||||
<div v-if="canDirectControlSelectedShip && directOrders.length > 0" class="entity-inspector-actions-row">
|
||||
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="clearOrders">Clear Orders</button>
|
||||
</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="directOrders.length > 0" class="entity-inspector-order-list">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ware</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||
</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 v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||
<div class="entity-inspector-note">
|
||||
Behavior-generated queue entries are managed from Default Behavior.
|
||||
<span v-if="behaviorGeneratedOrderCount > 0"> Active generated orders: {{ behaviorGeneratedOrderCount }}.</span>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No wares loaded.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Behavior</h4>
|
||||
<h4>Default Behavior</h4>
|
||||
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
||||
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
||||
</div>
|
||||
@@ -715,125 +781,6 @@ async function clearOrders() {
|
||||
Direct behavior editing is only available for player-owned ships or GM users.
|
||||
</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 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 =
|
||||
| "mine-resource"
|
||||
| "fly-to-and-wait"
|
||||
| "fly-to"
|
||||
| "follow"
|
||||
| "attack";
|
||||
|
||||
@@ -105,13 +105,14 @@ const actions = computed<OrderMenuActionEntry[]>(() => {
|
||||
case "station":
|
||||
case "celestial":
|
||||
case "construction-site":
|
||||
case "point":
|
||||
return [{
|
||||
key: "fly-to-and-wait",
|
||||
orderKind: "fly-and-wait",
|
||||
label: getShipOrderLabel("fly-and-wait"),
|
||||
key: "fly-to",
|
||||
orderKind: "move",
|
||||
label: getShipOrderLabel("move"),
|
||||
detail: target.value.label,
|
||||
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
|
||||
notes: getShipOrderNotes("fly-and-wait"),
|
||||
supportStatus: getShipOrderSupportStatusLabel("move"),
|
||||
notes: getShipOrderNotes("move"),
|
||||
}];
|
||||
case "system":
|
||||
return emptyActions();
|
||||
@@ -170,9 +171,9 @@ async function runAction(action: MenuAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "fly-to-and-wait") {
|
||||
if (action === "fly-to") {
|
||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||
kind: "fly-and-wait",
|
||||
kind: "move",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Fly to ${target.value.label}`,
|
||||
@@ -185,7 +186,7 @@ async function runAction(action: MenuAction) {
|
||||
anchorId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 8,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
|
||||
@@ -278,9 +278,7 @@ type ShipRow = {
|
||||
|
||||
const shipRows = computed<ShipRow[]>(() =>
|
||||
gmStore.ships.map((s) => {
|
||||
const topOrder = [...s.orderQueue]
|
||||
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
||||
const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex];
|
||||
const topOrder = s.orderQueue[0];
|
||||
const currentSubTask = s.activeSubTasks[0];
|
||||
return {
|
||||
id: s.id,
|
||||
@@ -293,8 +291,8 @@ const shipRows = computed<ShipRow[]>(() =>
|
||||
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
|
||||
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
|
||||
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
|
||||
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
|
||||
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
|
||||
plan: currentSubTask ? "Task execution" : "—",
|
||||
step: currentSubTask ? titleCaseToken(currentSubTask.kind) : "—",
|
||||
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -691,7 +691,7 @@ async function submitDirectOrder() {
|
||||
<div v-if="selectedShip" class="player-card">
|
||||
<strong>Behavior</strong>
|
||||
<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 v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
|
||||
<span v-if="selectedShip.lastAccessFailureReason">Access {{ selectedShip.lastAccessFailureReason }}</span>
|
||||
|
||||
@@ -114,31 +114,6 @@ export interface ShipSubTaskSnapshot {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -154,8 +129,6 @@ export interface ShipSnapshot {
|
||||
defaultBehavior: DefaultBehaviorSnapshot;
|
||||
assignment?: ShipAssignmentSnapshot | null;
|
||||
skills: ShipSkillProfileSnapshot;
|
||||
activePlan?: ShipPlanSnapshot | null;
|
||||
currentStepId?: string | null;
|
||||
activeSubTasks: ShipSubTaskSnapshot[];
|
||||
controlSourceKind: string;
|
||||
controlSourceId?: string | null;
|
||||
|
||||
@@ -21,6 +21,26 @@ export interface ShipOrderCommandRequest {
|
||||
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 {
|
||||
kind: string;
|
||||
homeSystemId?: string | null;
|
||||
|
||||
@@ -669,112 +669,6 @@ canvas {
|
||||
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-close {
|
||||
border: 1px solid rgba(127, 214, 255, 0.22);
|
||||
@@ -785,64 +679,15 @@ canvas {
|
||||
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-close {
|
||||
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 {
|
||||
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-window {
|
||||
@@ -1859,6 +1704,65 @@ canvas {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1870,6 +1774,10 @@ canvas {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.entity-inspector-field--checkbox {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.entity-inspector-field span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--viewer-muted);
|
||||
@@ -1893,6 +1801,14 @@ canvas {
|
||||
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 {
|
||||
margin-top: 0.9rem;
|
||||
color: var(--viewer-muted);
|
||||
|
||||
@@ -2,8 +2,13 @@ import { defineStore } from "pinia";
|
||||
import type { Vector3Dto } from "../../contractsCommon";
|
||||
import type { Selectable } from "../../viewerTypes";
|
||||
|
||||
export interface ViewerOrderContextMenuPointSelection {
|
||||
kind: "point";
|
||||
id: "local-point";
|
||||
}
|
||||
|
||||
export interface ViewerOrderContextMenuTarget {
|
||||
selection: Selectable;
|
||||
selection: Selectable | ViewerOrderContextMenuPointSelection;
|
||||
label: string;
|
||||
systemId?: string | null;
|
||||
anchorId?: string | null;
|
||||
|
||||
@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
|
||||
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
||||
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
|
||||
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, minimumDistance, 120, 40, 180000);
|
||||
systemAnchor.addScaledVector(pan, speedKilometers * delta);
|
||||
: THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
|
||||
systemAnchor.addScaledVector(pan, panSpeed * delta);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,11 @@ export function applyPanFromScreenDelta(
|
||||
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
|
||||
|
||||
if (activeSystemId) {
|
||||
if (povLevel === "local") {
|
||||
systemAnchor.add(pan);
|
||||
return;
|
||||
}
|
||||
|
||||
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
|
||||
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
|
||||
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PovLevel } from "./viewerTypes";
|
||||
|
||||
export const NAV_DISTANCE: Record<PovLevel, number> = {
|
||||
local: 18,
|
||||
local: 180,
|
||||
system: 3200,
|
||||
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.
|
||||
export const MIN_CAMERA_DISTANCE = 0.00005;
|
||||
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 {
|
||||
localWeight: number;
|
||||
|
||||
@@ -112,6 +112,7 @@ export function createViewerControllers(host: any) {
|
||||
getCameraMode: () => host.cameraMode,
|
||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||
getPovLevel: () => host.povLevel,
|
||||
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||
getCurrentDistance: () => host.currentDistance,
|
||||
@@ -251,6 +252,8 @@ export function createViewerControllers(host: any) {
|
||||
},
|
||||
getFollowCameraPosition: () => host.followCameraPosition,
|
||||
getFollowCameraFocus: () => host.followCameraFocus,
|
||||
getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
|
||||
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
|
||||
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
||||
applyPanDelta: (delta: THREE.Vector2) => {
|
||||
const bounds = host.renderer.domElement.getBoundingClientRect();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { rawObject } from "./viewerScenePrimitives";
|
||||
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
||||
import type { StatsOverlayMode } from "./viewerHudState";
|
||||
import type {
|
||||
CameraMode,
|
||||
PovLevel,
|
||||
Selectable,
|
||||
ShipVisual,
|
||||
SystemVisual,
|
||||
@@ -212,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
|
||||
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 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: {
|
||||
|
||||
@@ -32,43 +32,6 @@ export interface HudProgressBar {
|
||||
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 {
|
||||
id: string;
|
||||
target: Selectable;
|
||||
@@ -111,7 +74,6 @@ export interface ViewerHudState {
|
||||
systemPanel: HudHtmlPanelState;
|
||||
detailPanel: HudHtmlPanelState;
|
||||
error: HudErrorState;
|
||||
opsStrip: OpsStripState;
|
||||
historyWindows: HistoryWindowState[];
|
||||
hoverLabel: HoverLabelState;
|
||||
marquee: MarqueeState;
|
||||
@@ -161,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
|
||||
hidden: true,
|
||||
message: "",
|
||||
},
|
||||
opsStrip: {
|
||||
factions: [],
|
||||
stations: [],
|
||||
ships: [],
|
||||
},
|
||||
historyWindows: [],
|
||||
hoverLabel: {
|
||||
hidden: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
|
||||
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 { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
|
||||
|
||||
@@ -169,13 +169,17 @@ function formatHoverDistance(
|
||||
|| selection.kind === "construction-site";
|
||||
|
||||
if (inActiveSystem && activeSystemId) {
|
||||
if (povLevel === "local") {
|
||||
return formatAdaptiveDistanceFromMeters(displayDistance);
|
||||
}
|
||||
|
||||
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
|
||||
return povLevel === "system"
|
||||
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
|
||||
: formatAdaptiveDistanceFromKilometers(kilometers);
|
||||
}
|
||||
|
||||
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
|
||||
return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
|
||||
}
|
||||
|
||||
export function updateMarqueeBox(
|
||||
|
||||
@@ -20,7 +20,10 @@ import type {
|
||||
WorldState,
|
||||
PovLevel,
|
||||
} from "./viewerTypes";
|
||||
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
|
||||
import type {
|
||||
ViewerOrderContextMenuPointSelection,
|
||||
ViewerOrderContextMenuTarget,
|
||||
} from "./ui/stores/viewerOrderContextMenu";
|
||||
|
||||
export interface ViewerInteractionContext {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
@@ -60,6 +63,8 @@ export interface ViewerInteractionContext {
|
||||
setCameraTargetShipId: (value: string | undefined) => void;
|
||||
getFollowCameraPosition: () => THREE.Vector3;
|
||||
getFollowCameraFocus: () => THREE.Vector3;
|
||||
getLocalRootPosition: () => THREE.Vector3;
|
||||
getFocusedAnchorId: () => string | undefined;
|
||||
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
||||
applyPanDelta: (delta: THREE.Vector2) => void;
|
||||
syncFollowStateFromSelection: () => void;
|
||||
@@ -206,11 +211,9 @@ export class ViewerInteractionController {
|
||||
this.context.closeOrderContextMenu();
|
||||
|
||||
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.buildOrderContextTarget(picked);
|
||||
const target = picked
|
||||
? this.buildOrderContextTarget(picked)
|
||||
: this.buildLocalPointContextTarget(event.clientX, event.clientY);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
@@ -218,71 +221,6 @@ export class ViewerInteractionController {
|
||||
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 onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
|
||||
@@ -316,7 +254,7 @@ export class ViewerInteractionController {
|
||||
|
||||
readonly onWheel = (event: WheelEvent) => {
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -508,4 +446,45 @@ export class ViewerInteractionController {
|
||||
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.
|
||||
*/
|
||||
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 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 stationGroup = new THREE.Group();
|
||||
readonly claimGroup = new THREE.Group();
|
||||
@@ -36,18 +40,24 @@ export class LocalLayer {
|
||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
|
||||
keyLight.position.set(180, 220, 140);
|
||||
this.scene.add(keyLight);
|
||||
this.scene.add(
|
||||
this.localRoot.add(
|
||||
this.fineGrid,
|
||||
this.majorGrid,
|
||||
this.outerGrid,
|
||||
this.nodeGroup,
|
||||
this.stationGroup,
|
||||
this.claimGroup,
|
||||
this.constructionSiteGroup,
|
||||
this.shipGroup,
|
||||
);
|
||||
this.scene.add(this.localRoot);
|
||||
}
|
||||
|
||||
updateCamera(orbitOffset: THREE.Vector3) {
|
||||
this.camera.position.copy(orbitOffset);
|
||||
this.camera.lookAt(LocalLayer.ORIGIN);
|
||||
updateCamera(localFocus: THREE.Vector3, orbitOffset: THREE.Vector3, anchorOffset: THREE.Vector3) {
|
||||
const worldFocus = localFocus.clone().add(anchorOffset);
|
||||
this.localRoot.position.copy(anchorOffset);
|
||||
this.camera.position.copy(worldFocus).add(orbitOffset);
|
||||
this.camera.lookAt(worldFocus);
|
||||
}
|
||||
|
||||
onResize(aspect: number) {
|
||||
@@ -59,3 +69,13 @@ export class LocalLayer {
|
||||
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";
|
||||
|
||||
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_LIGHT_YEAR = 2600;
|
||||
|
||||
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
|
||||
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 {
|
||||
const speed = Math.max(0, ship.travelSpeed);
|
||||
const unit = ship.travelSpeedUnit;
|
||||
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
|
||||
}
|
||||
|
||||
export function computeZoomBlend(distance: number): ZoomBlend {
|
||||
const localToSystem = smoothBand(distance, 1200, 5200);
|
||||
const localToSystem = smoothBand(distance, 120, 650);
|
||||
const systemToUniverse = smoothBand(distance, 9000, 22000);
|
||||
|
||||
return {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class ViewerNavigationController {
|
||||
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) {
|
||||
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 shipOrder = describeShipOrder(ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
|
||||
const orderQueue = ship.orderQueue.length > 0
|
||||
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
|
||||
: "none";
|
||||
@@ -369,7 +368,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
|
||||
<p>State ${shipState}</p>
|
||||
<p>Order ${shipOrder}</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>
|
||||
${ship.lastReplanReason ? `<p>Last replan ${ship.lastReplanReason}</p>` : ""}
|
||||
${ship.lastAccessFailureReason ? `<p>Access ${ship.lastAccessFailureReason}</p>` : ""}
|
||||
@@ -463,7 +462,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
|
||||
<p>${celestial.systemId}</p>
|
||||
<p>Parent ${celestial.parentAnchorId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "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;
|
||||
getCameraTargetShipId: () => string | undefined;
|
||||
getPovLevel: () => any;
|
||||
getFocusedAnchorId: () => string | undefined;
|
||||
getSelectedItems: () => Selectable[];
|
||||
getWorldTimeSyncMs: () => number;
|
||||
getCurrentDistance: () => number;
|
||||
@@ -106,12 +107,52 @@ export class ViewerPresentationController {
|
||||
|
||||
applyZoomPresentation() {
|
||||
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);
|
||||
|
||||
const showPlanetIcons = povLevel !== "local";
|
||||
for (const visual of this.context.planetVisuals) {
|
||||
for (const [planetIndex, visual] of this.context.planetVisuals.entries()) {
|
||||
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) {
|
||||
switch (ship.type) {
|
||||
case "carrier":
|
||||
return 0.018;
|
||||
return 18;
|
||||
case "battleship":
|
||||
return 0.012;
|
||||
return 12;
|
||||
case "destroyer":
|
||||
return 0.009;
|
||||
return 9;
|
||||
case "builder":
|
||||
case "freighter":
|
||||
case "transporter":
|
||||
case "resupplier":
|
||||
case "miner":
|
||||
case "largeminer":
|
||||
return 0.01;
|
||||
return 10;
|
||||
default:
|
||||
return 0.007;
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import {
|
||||
createClaimMesh,
|
||||
createConstructionSiteMesh,
|
||||
createLocalResourceDepositMesh,
|
||||
createLocalResourceNodeMesh,
|
||||
createNodeMesh,
|
||||
createResourceDepositMesh,
|
||||
createShipMesh,
|
||||
@@ -182,7 +184,7 @@ export class ViewerSceneDataController {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mesh = createNodeMesh(node);
|
||||
const mesh = createLocalResourceNodeMesh(node);
|
||||
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
|
||||
const localPosition = new THREE.Vector3(0, 0, 0);
|
||||
mesh.setPosition(localPosition);
|
||||
@@ -201,7 +203,7 @@ export class ViewerSceneDataController {
|
||||
});
|
||||
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
|
||||
for (const deposit of node.deposits) {
|
||||
const depositMesh = createResourceDepositMesh(deposit, node);
|
||||
const depositMesh = createLocalResourceDepositMesh(deposit, node);
|
||||
this.context.localNodeGroup.add(rawObject(depositMesh));
|
||||
}
|
||||
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
|
||||
|
||||
@@ -64,6 +64,47 @@ export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node
|
||||
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 {
|
||||
const color = celestialColor(node.kind);
|
||||
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 {
|
||||
const geometry = new THREE.ConeGeometry(size, length, 7);
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: new THREE.Color(color).multiplyScalar(0.18),
|
||||
}),
|
||||
const root = new THREE.Group();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
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));
|
||||
return createSceneNode(mesh);
|
||||
body.rotation.x = Math.PI / 2;
|
||||
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 {
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
} 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 {
|
||||
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;
|
||||
}
|
||||
if (selected.kind === "planet") {
|
||||
return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
|
||||
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
|
||||
}
|
||||
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;
|
||||
@@ -429,8 +429,7 @@ export function describeShipBehavior(ship: ShipSnapshot): string {
|
||||
}
|
||||
|
||||
export function describeShipOrder(ship: ShipSnapshot): string {
|
||||
const activeOrder = [...ship.orderQueue]
|
||||
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
|
||||
const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
|
||||
if (activeOrder) {
|
||||
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
|
||||
}
|
||||
@@ -439,10 +438,6 @@ export function describeShipOrder(ship: ShipSnapshot): string {
|
||||
return describeShipObjective(ship.assignment.kind);
|
||||
}
|
||||
|
||||
if (ship.activePlan) {
|
||||
return ship.activePlan.summary || ship.activePlan.kind;
|
||||
}
|
||||
|
||||
return getShipBehaviorLabel(ship.defaultBehavior.kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import { buildOpsStripState } from "./viewerOpsStrip";
|
||||
import { useGmStore } from "./ui/stores/gmStore";
|
||||
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
||||
import { viewerPinia } from "./ui/stores/pinia";
|
||||
@@ -193,15 +192,6 @@ export class ViewerWorldLifecycle {
|
||||
}
|
||||
|
||||
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();
|
||||
if (world) {
|
||||
useGmStore(viewerPinia).updateWorld(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DISPLAY_UNITS_PER_KILOMETER,
|
||||
DISPLAY_UNITS_PER_LIGHT_YEAR,
|
||||
KILOMETERS_PER_AU,
|
||||
METERS_PER_KILOMETER,
|
||||
computeMoonLocalPosition,
|
||||
computePlanetLocalPosition,
|
||||
currentWorldTimeSeconds,
|
||||
@@ -137,8 +138,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
}
|
||||
|
||||
const localPosition = getAnimatedShipLocalPosition(visual, now);
|
||||
const displayPosition = context.toDisplayLocalPosition(localPosition);
|
||||
visual.mesh.setPosition(displayPosition);
|
||||
visual.mesh.setPosition(localPosition);
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(renderMode === "local");
|
||||
visual.icon.setVisible(renderMode === "local");
|
||||
@@ -201,29 +201,28 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
}
|
||||
|
||||
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.mesh.setVisible(renderMode === "local");
|
||||
visual.icon.setVisible(renderMode === "local");
|
||||
}
|
||||
|
||||
for (const visual of context.localStationVisuals.values()) {
|
||||
const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
|
||||
visual.mesh.setPosition(displayPosition);
|
||||
visual.mesh.setPosition(visual.localPosition.clone());
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(renderMode === "local");
|
||||
visual.icon.setVisible(renderMode === "local");
|
||||
}
|
||||
|
||||
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.mesh.setVisible(renderMode === "local");
|
||||
visual.icon.setVisible(renderMode === "local");
|
||||
}
|
||||
|
||||
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.mesh.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`
|
||||
: "";
|
||||
// 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`
|
||||
: "";
|
||||
// Local space: position relative to the focused celestial's orbital anchor in km
|
||||
const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
|
||||
const celestialAnchor = focusedAnchorId
|
||||
? (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`
|
||||
// Local space: local focus in meters relative to the focused anchor
|
||||
const locPos = povLevel === "local" && systemAnchor
|
||||
? `loc pos: ${fmtVec(systemAnchor, 0)} m`
|
||||
: "";
|
||||
|
||||
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(
|
||||
context: WorldOrbitalContext,
|
||||
node: ResourceNodeSnapshot | ResourceNodeDelta,
|
||||
@@ -536,6 +558,32 @@ export function resolvePointPosition(context: WorldOrbitalContext, _systemId: st
|
||||
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) {
|
||||
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user