Refine ship orders and viewer controls

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

View File

@@ -0,0 +1,153 @@
using FluentAssertions;
using SpaceGame.Api.Definitions;
using SpaceGame.Api.Industry.Planning;
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Ships.AI;
using SpaceGame.Api.Ships.Runtime;
using SpaceGame.Api.Universe.Contracts;
using SpaceGame.Api.Universe.Runtime;
using SpaceGame.Api.Universe.Simulation;
using Xunit;
namespace SpaceGame.Api.Tests;
public sealed class ShipAiServiceExecutionTests
{
[Fact]
public void MoveOrder_CompletesAndIsRemovedAfterArrival()
{
var ship = new ShipRuntime
{
Id = "ship-1",
SystemId = "sys-1",
Definition = CreateShipDefinition(),
FactionId = "player",
Position = Vector3.Zero,
TargetPosition = Vector3.Zero,
SpatialState = new ShipSpatialStateRuntime
{
CurrentSystemId = "sys-1",
CurrentAnchorId = null,
LocalPosition = Vector3.Zero,
SystemPosition = Vector3.Zero,
},
DefaultBehavior = new DefaultBehaviorRuntime
{
Kind = ShipBehaviorKinds.Idle,
},
Skills = new ShipSkillProfileRuntime
{
Navigation = 3,
Trade = 3,
Mining = 3,
Combat = 3,
Construction = 3,
},
Health = 100f,
};
ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
{
Id = "move-1",
Kind = ShipOrderKinds.Move,
SourceKind = ShipOrderSourceKind.Player,
SourceId = "test",
Label = "Fly to point",
TargetSystemId = "sys-1",
TargetPosition = new Vector3(5f, 0f, 0f),
});
var world = CreateWorld(ship);
var service = new ShipAiService(new TestBalanceService());
var events = new List<SimulationEventRecord>();
service.UpdateShip(world, ship, 1f, events);
service.UpdateShip(world, ship, 0.01f, events);
ship.Position.Should().Be(new Vector3(5f, 0f, 0f));
ship.OrderQueue.Should().BeEmpty();
ship.ActiveOrderId.Should().BeNull();
}
private static SimulationWorld CreateWorld(ShipRuntime ship)
{
return new SimulationWorld
{
Label = "test",
Seed = 1,
Systems =
[
new SystemRuntime
{
Definition = new SolarSystemDefinition
{
Id = "sys-1",
Label = "System 1",
Position = [0f, 0f, 0f],
Stars = [],
AsteroidField = new AsteroidFieldDefinition(),
ResourceNodes = [],
Planets = [],
},
Position = Vector3.Zero,
}
],
Anchors = [],
Nodes = [],
Celestials = [],
Wrecks = [],
Stations = [],
Ships = [ship],
Factions = [],
Commanders = [],
Claims = [],
ConstructionSites = [],
MarketOrders = [],
Policies = [],
ShipDefinitions = new Dictionary<string, ShipDefinition>(StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(StringComparer.Ordinal),
ProductionGraph = new ProductionGraph
{
Commodities = new Dictionary<string, ProductionCommodityNode>(StringComparer.Ordinal),
Processes = new Dictionary<string, ProductionProcessNode>(StringComparer.Ordinal),
ProcessesByOutputId = new Dictionary<string, IReadOnlyList<ProductionProcessNode>>(StringComparer.Ordinal),
ProcessesByInputId = new Dictionary<string, IReadOnlyList<ProductionProcessNode>>(StringComparer.Ordinal),
OutputsByModuleId = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal),
},
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private static ShipDefinition CreateShipDefinition()
{
return new ShipDefinition
{
Id = "ship-def",
Name = "Test Ship",
Size = "small",
Hull = 100f,
Purpose = ShipPurpose.Trade,
Type = ShipType.Courier,
};
}
private sealed class TestBalanceService : IBalanceService
{
public float SimulationSpeedMultiplier => 1f;
public float YPlane => 0f;
public float ArrivalThreshold => 1f;
public float MiningRate => 10f;
public float MiningCycleSeconds => 1f;
public float TransferRate => 10f;
public float DockingDuration => 0.1f;
public float UndockingDuration => 0.1f;
public float UndockDistance => 10f;
public BalanceOptions GetCurrent() => new();
public BalanceOptions Update(BalanceOptions candidate) => candidate;
}
}

View File

@@ -0,0 +1,140 @@
using FluentAssertions;
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Ships.Runtime;
using Xunit;
namespace SpaceGame.Api.Tests;
public sealed class ShipOrderQueueTests
{
[Fact]
public void Enqueue_Throws_WhenQueueIsFull()
{
var queue = new ShipOrderQueue();
foreach (var index in Enumerable.Range(0, ShipOrderQueue.MaxOrders))
{
queue.Enqueue(CreateOrder($"order-{index}", ShipOrderSourceKind.Player, priority: index));
}
var act = () => queue.Enqueue(CreateOrder("overflow", ShipOrderSourceKind.Player));
act.Should().Throw<InvalidOperationException>()
.WithMessage("Order queue is full.");
}
[Fact]
public void GetCurrentOrder_UsesQueueOrder()
{
var queue = new ShipOrderQueue();
queue.EnqueuePlayerOrder(CreateOrder("player-first", ShipOrderSourceKind.Player));
queue.EnqueueManagedOrder(CreateOrder("commander-second", ShipOrderSourceKind.Commander, priority: 100));
queue.EnqueueManagedOrder(CreateOrder("behavior-third", ShipOrderSourceKind.Behavior, priority: 999));
var currentOrder = queue.GetCurrentOrder();
currentOrder.Should().NotBeNull();
currentOrder!.Id.Should().Be("player-first");
}
[Fact]
public void GetCurrentOrder_IgnoresTerminalStatuses()
{
var queue = new ShipOrderQueue();
queue.EnqueuePlayerOrder(CreateOrder("completed-player", ShipOrderSourceKind.Player, priority: 100, status: OrderStatus.Completed));
queue.EnqueueManagedOrder(CreateOrder("active-commander", ShipOrderSourceKind.Commander, priority: 1, status: OrderStatus.Active));
var currentOrder = queue.GetCurrentOrder();
currentOrder.Should().NotBeNull();
currentOrder!.Id.Should().Be("active-commander");
}
[Fact]
public void EnqueuePlayerOrder_InsertsBeforeManagedOrders()
{
var queue = new ShipOrderQueue();
queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
queue.EnqueueManagedOrder(CreateOrder("behavior-2", ShipOrderSourceKind.Commander));
queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
queue.Select(order => order.Id).Should().Equal("player-1", "player-2", "behavior-1", "behavior-2");
queue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)!.Id.Should().Be("player-1");
}
[Fact]
public void AddOrReplaceManagedOrder_ReplacesMatchingOrderWithoutGrowingQueue()
{
var queue = new ShipOrderQueue();
queue.EnqueueManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Initial label"));
queue.AddOrReplaceManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Updated label", priority: 7));
queue.Count.Should().Be(1);
queue.FindById("order-1").Should().NotBeNull();
queue.FindById("order-1")!.Label.Should().Be("Updated label");
queue.FindById("order-1")!.Priority.Should().Be(7);
}
[Fact]
public void RemoveById_RemovesMatchingOrder()
{
var queue = new ShipOrderQueue();
queue.EnqueuePlayerOrder(CreateOrder("remove-me", ShipOrderSourceKind.Player));
queue.EnqueuePlayerOrder(CreateOrder("keep-me", ShipOrderSourceKind.Player));
var removed = queue.RemoveById("remove-me");
removed.Should().BeTrue();
queue.Select(order => order.Id).Should().ContainSingle().Which.Should().Be("keep-me");
}
[Fact]
public void TryMovePlayerOrder_ReordersWithinPlayerSegment()
{
var queue = new ShipOrderQueue();
queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
var moved = queue.TryMovePlayerOrder("player-2", 0);
moved.Should().BeTrue();
queue.Select(order => order.Id).Should().Equal("player-2", "player-1", "behavior-1");
}
[Fact]
public void TryCompleteOrder_RemovesCompletedDirectOrderFromQueue()
{
var queue = new ShipOrderQueue();
queue.EnqueuePlayerOrder(CreateOrder("direct-order", ShipOrderSourceKind.Player, status: OrderStatus.Active));
queue.EnqueueManagedOrder(CreateOrder("behavior-order", ShipOrderSourceKind.Behavior));
var completed = queue.TryCompleteOrder("direct-order");
completed.Should().BeTrue();
queue.FindById("direct-order").Should().BeNull();
queue.GetCurrentOrder()!.Id.Should().Be("behavior-order");
}
private static ShipOrderRuntime CreateOrder(
string id,
ShipOrderSourceKind sourceKind,
int priority = 0,
OrderStatus status = OrderStatus.Queued,
string? label = null,
DateTimeOffset? createdAtUtc = null)
{
return new ShipOrderRuntime
{
Id = id,
Kind = "test-order",
SourceKind = sourceKind,
SourceId = $"{sourceKind}-source",
Priority = priority,
Status = status,
Label = label,
CreatedAtUtc = createdAtUtc ?? new DateTimeOffset(2026, 4, 8, 12, 0, 0, TimeSpan.Zero),
};
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.1.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../apps/backend/SpaceGame.Api.csproj" />
</ItemGroup>
</Project>