Refactor backend into domain-first slices

This commit is contained in:
2026-03-19 18:15:44 -04:00
parent 07a3142316
commit 9a5040cf1f
53 changed files with 94 additions and 140 deletions

View File

@@ -0,0 +1,11 @@
namespace SpaceGame.Api.Ships.AI;
internal interface IShipBehaviorState
{
string Kind { get; }
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
}

View File

@@ -0,0 +1,39 @@
namespace SpaceGame.Api.Ships.AI;
internal sealed class ShipBehaviorStateMachine
{
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
private readonly IShipBehaviorState fallbackState;
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
{
this.states = states;
this.fallbackState = fallbackState;
}
public static ShipBehaviorStateMachine CreateDefault()
{
var idleState = new IdleShipBehaviorState();
var knownStates = new IShipBehaviorState[]
{
idleState,
new PatrolShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};
return new ShipBehaviorStateMachine(
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
idleState);
}
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
private IShipBehaviorState Resolve(string kind) =>
states.TryGetValue(kind, out var state) ? state : fallbackState;
}

View File

@@ -0,0 +1,128 @@
namespace SpaceGame.Api.Ships.AI;
internal sealed class IdleShipBehaviorState : IShipBehaviorState
{
public string Kind => "idle";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
}
}
internal sealed class PatrolShipBehaviorState : IShipBehaviorState
{
public string Kind => "patrol";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
{
ship.DefaultBehavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
return;
}
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
}
}
}
internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
{
private readonly string resourceItemId;
private readonly string requiredModule;
public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule)
{
Kind = kind;
this.resourceItemId = resourceItemId;
this.requiredModule = requiredModule;
}
public string Kind { get; }
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-node", "arrived"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
break;
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("extract", "node-depleted"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
}
}
}
internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
{
public string Kind => "construct-station";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanStationConstruction(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "deliver-to-site";
break;
case ("deliver-to-site", "construction-delivered"):
ship.DefaultBehavior.Phase = "build-site";
break;
case ("construct-module", "module-constructed"):
case ("build-site", "site-constructed"):
ship.DefaultBehavior.Phase = "travel-to-station";
ship.DefaultBehavior.ModuleId = null;
break;
}
}
}

View File

@@ -0,0 +1,155 @@
namespace SpaceGame.Api.Ships.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class ShipPlanningState
{
public string ShipKind { get; set; } = string.Empty;
public bool HasMiningCapability { get; set; }
public bool FactionWantsOre { get; set; }
public bool FactionWantsExpansion { get; set; }
public string? CurrentObjective { get; set; }
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
// A ship should always have an assigned objective. The planner picks the best one.
public sealed class AssignObjectiveGoal : GoapGoal<ShipPlanningState>
{
public override string Name => "assign-objective";
public override bool IsSatisfied(ShipPlanningState state) => state.CurrentObjective is not null;
public override float ComputePriority(ShipPlanningState state, SimulationWorld world, CommanderRuntime commander) =>
100f;
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class SetMiningObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-mining-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
state.HasMiningCapability && state.FactionWantsOre;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "auto-mine";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "auto-mine", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "auto-mine";
ship.DefaultBehavior.Phase = null;
ship.DefaultBehavior.NodeId = null;
}
}
public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-patrol-objective";
public override float Cost => 2f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "military", StringComparison.Ordinal);
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "patrol";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "patrol", StringComparison.Ordinal))
{
return;
}
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
{
var station = world.Stations.FirstOrDefault(s =>
s.FactionId == ship.FactionId &&
string.Equals(s.SystemId, ship.SystemId, StringComparison.Ordinal));
if (station is not null)
{
var radius = station.Radius + 90f;
ship.DefaultBehavior.PatrolPoints.AddRange(
[
new Vector3(station.Position.X + radius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + radius),
new Vector3(station.Position.X - radius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - radius),
]);
}
}
ship.DefaultBehavior.Kind = "patrol";
}
}
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-construction-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) && state.FactionWantsExpansion;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "construct-station";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "construct-station", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "construct-station";
ship.DefaultBehavior.Phase = null;
}
}
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-idle-objective";
public override float Cost => 10f;
public override bool CheckPreconditions(ShipPlanningState state) => true;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "idle";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "idle", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "idle";
}
}

View File

@@ -0,0 +1,83 @@
namespace SpaceGame.Api.Ships.Contracts;
public sealed record ShipSnapshot(
string Id,
string Label,
string Kind,
string Class,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string? BehaviorPhase,
string ControllerTaskKind,
string? CommanderObjective,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipDelta(
string Id,
string Label,
string Kind,
string Class,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string? BehaviorPhase,
string ControllerTaskKind,
string? CommanderObjective,
string? CelestialId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipActionProgressSnapshot(
string Label,
float Progress);
public sealed record ShipSpatialStateSnapshot(
string SpaceLayer,
string CurrentSystemId,
string? CurrentCelestialId,
Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition,
string MovementRegime,
string? DestinationNodeId,
ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot(
string Regime,
string? OriginNodeId,
string? DestinationNodeId,
DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc,
float Progress);

View File

@@ -0,0 +1,65 @@
namespace SpaceGame.Api.Ships.Runtime;
public sealed class ShipRuntime
{
public required string Id { get; init; }
public required string SystemId { get; set; }
public required ShipDefinition Definition { get; init; }
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle;
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public float Health { get; set; }
public string? TrackedActionKey { get; set; }
public float TrackedActionTotal { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
}
public sealed class ControllerTaskRuntime
{
public required ControllerTaskKind Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
public string? ItemId { get; set; }
}

View File

@@ -0,0 +1,566 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed class ShipControlService
{
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship);
private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander)
{
if (commander.ActiveBehavior is not null)
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
}
if (commander.ActiveOrder is null)
{
ship.Order = null;
}
else
{
ship.Order = new ShipOrderRuntime
{
Kind = commander.ActiveOrder.Kind,
Status = commander.ActiveOrder.Status,
DestinationSystemId = commander.ActiveOrder.DestinationSystemId,
DestinationPosition = commander.ActiveOrder.DestinationPosition,
};
}
if (commander.ActiveTask is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
TargetNodeId = commander.ActiveTask.TargetNodeId,
TargetPosition = commander.ActiveTask.TargetPosition,
TargetSystemId = commander.ActiveTask.TargetSystemId,
Threshold = commander.ActiveTask.Threshold,
};
}
}
private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander)
{
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex;
commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId;
if (ship.Order is null)
{
commander.ActiveOrder = null;
}
else
{
commander.ActiveOrder ??= new CommanderOrderRuntime
{
Kind = ship.Order.Kind,
DestinationSystemId = ship.Order.DestinationSystemId,
DestinationPosition = ship.Order.DestinationPosition,
};
commander.ActiveOrder.Status = ship.Order.Status;
commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition;
commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId;
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
{
SyncCommanderToShip(ship, commander);
}
if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued)
{
ship.Order.Status = OrderStatus.Accepted;
if (commander?.ActiveOrder is not null)
{
commander.ActiveOrder.Status = ship.Order.Status;
}
}
if (commander is not null)
{
SyncShipToCommander(ship, commander);
}
}
internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
TargetNodeId = ship.SpatialState.DestinationNodeId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
SyncCommanderTask(commander, ship.ControllerTask);
return;
}
_shipBehaviorStateMachine.Plan(engine, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
candidate.ItemId == resourceItemId &&
candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.NodeId ??= node.Id;
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
&& behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (ship.DockedStationId == refinery.Id)
{
if (GetShipCargoAmount(ship) > 0.01f)
{
behavior.Phase = "unload";
}
else if (behavior.Phase is "dock" or "unload")
{
behavior.Phase = "undock";
}
}
else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = extractionPosition,
Threshold = 5f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null)
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
phase switch
{
"dock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"undock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
Threshold = 8f,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (ship.DockedStationId is not null)
{
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
dockedStation.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(dockedStation, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
var isAtConstructionHold = ship.SystemId == station.SystemId
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
if (isAtConstructionHold)
{
if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
{
behavior.Phase = "deliver-to-site";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{
behavior.Phase = "build-site";
}
else if (site is not null)
{
behavior.Phase = "wait-for-materials";
}
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
{
behavior.Phase = "construct-module";
}
else
{
behavior.Phase = "wait-for-materials";
}
}
else if (behavior.Phase != "travel-to-station")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
behavior.Phase = "travel-to-station";
break;
}
}
internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not null)
{
commander.ActiveOrder = null;
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Completed,
TargetSystemId = ship.SystemId,
Threshold = 0f,
};
}
return;
}
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
if (commander.ActiveTask is not null)
{
commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed;
}
}
}
internal void TrackHistory(ShipRuntime ship, string controllerEvent)
{
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
var target = ship.ControllerTask.TargetEntityId
?? ship.ControllerTask.TargetSystemId
?? "none";
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
internal void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
internal static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = ControllerTaskKind.Idle,
Threshold = threshold,
};
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
{
"travel" => ControllerTaskKind.Travel,
"extract" => ControllerTaskKind.Extract,
"dock" => ControllerTaskKind.Dock,
"load" => ControllerTaskKind.Load,
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
_ => ControllerTaskKind.Idle,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{
if (commander is null)
{
return;
}
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}
}

View File

@@ -0,0 +1,456 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < requiredSeconds)
{
return false;
}
ship.ActionTimer = 0f;
return true;
}
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
{
if (ship.TrackedActionKey == actionKey)
{
return;
}
ship.TrackedActionKey = actionKey;
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
}
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node, world))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return "cargo-full";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.ActionTimer = 0f;
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
ship.ActionTimer = 0f;
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
}
AddInventory(ship.Inventory, node.ItemId, mined);
node.OreRemaining -= mined;
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f)
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
return "none";
}
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
var distance = ship.Position.DistanceTo(padPosition);
if (distance > 4f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
foreach (var (itemId, amount) in ship.Inventory.ToList())
{
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
{
faction.OreMined += accepted;
faction.Credits += accepted * 0.4f;
}
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Loading;
var itemId = ship.ControllerTask.ItemId;
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = itemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, itemId));
if (itemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, itemId, moved);
AddInventory(ship.Inventory, itemId, moved);
}
return itemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, itemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null || ship.DefaultBehavior.ModuleId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{
return "none";
}
AddStationModule(world, station, station.ActiveConstruction.ModuleId);
station.ActiveConstruction = null;
return "module-constructed";
}
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.DeliveringConstruction;
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
if (site.StationId is not null)
{
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
{
return "none";
}
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
return "site-constructed";
}
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
ship.DockedStationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
ship.DockedStationId is not null
? GetShipDockedPosition(ship, station)
: GetConstructionHoldPosition(station, ship.Id);
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var undockTarget = station is null
? task.TargetPosition.Value
: GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
{
ship.Position = GetShipDockedPosition(ship, station);
}
return "none";
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > task.Threshold)
{
return "none";
}
if (station is not null)
{
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return "undocked";
}
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
}

View File

@@ -0,0 +1,312 @@
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Ships.Simulation;
internal sealed partial class ShipTaskExecutionService
{
private const float WarpEngageDistanceKilometers = 250_000f;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
{
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds),
};
}
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
// Resolve live position each frame — entities like stations orbit celestials and move every tick
var targetPosition = ResolveCurrentTargetPosition(world, task);
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
var distance = ship.Position.DistanceTo(targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
{
if (!HasShipCapabilities(ship.Definition, "ftl"))
{
ship.State = ShipState.Idle;
return "none";
}
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, task.TargetSystemId);
var destinationEntryPosition = destinationEntryCelestial?.Position ?? Vector3.Zero;
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryCelestial);
}
var currentCelestial = ResolveCurrentCelestial(world, ship);
if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
{
if (!HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
if (targetCelestial is not null
&& distance > WarpEngageDistanceKilometers
&& HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is not null)
{
return station.Position;
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial.Position;
}
}
return task.TargetPosition!.Value;
}
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial;
}
}
return world.Celestials
.Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
CelestialRuntime? targetCelestial,
float threshold)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
if (distance <= threshold)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = ship.Position;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
ship.ActionTimer = 0f;
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = targetCelestial.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping)
{
if (ship.State != ShipState.SpoolingWarp)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = ShipState.Warping;
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetCelestial.SystemId, targetPosition, targetCelestial)
: "none";
}
private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
var destinationNodeId = targetCelestial?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != ShipState.Ftl)
{
if (ship.State != ShipState.SpoolingFtl)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = ShipState.Ftl;
}
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance));
return transit.Progress >= 0.999f
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetCelestial)
: "none";
}
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "none";
}
}