Files
space-game/apps/backend/Ships/Simulation/ShipControlService.cs

873 lines
32 KiB
C#

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.TargetEntityId = commander.ActiveBehavior.TargetEntityId;
ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId;
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.TargetEntityId = ship.DefaultBehavior.TargetEntityId;
commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId;
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 PlanAttackTarget(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var target = ResolveAttackTarget(ship, world);
if (target is null)
{
behavior.Kind = "idle";
behavior.TargetEntityId = null;
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.TargetEntityId = target.EntityId;
behavior.AreaSystemId = target.SystemId;
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.AttackTarget,
TargetEntityId = target.EntityId,
TargetSystemId = target.SystemId,
TargetPosition = target.Position,
Threshold = target.AttackRange,
};
}
internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId);
if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var carryingCargo = GetShipCargoAmount(ship) > 0.01f;
if (carryingCargo)
{
if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "unload";
}
else if (ship.DockedStationId is not null)
{
behavior.Phase = "undock-from-source";
}
else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload")
{
behavior.Phase = "travel-to-destination";
}
}
else
{
if (ship.DockedStationId == sourceStation.Id)
{
var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId);
behavior.Phase = available > 0.01f ? "load" : "wait-source";
}
else if (ship.DockedStationId == destinationStation.Id)
{
behavior.Phase = "undock-from-destination";
}
else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load")
{
behavior.Phase = "travel-to-source";
}
}
ship.ControllerTask = behavior.Phase switch
{
"travel-to-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = sourceStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = sourceStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-source" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = sourceStation.Id,
TargetSystemId = sourceStation.SystemId,
TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
"travel-to-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 8f,
ItemId = behavior.ItemId,
},
"dock-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = destinationStation.Radius + 4f,
ItemId = behavior.ItemId,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = destinationStation.Position,
Threshold = 0f,
ItemId = behavior.ItemId,
},
"undock-from-destination" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = destinationStation.Id,
TargetSystemId = destinationStation.SystemId,
TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z),
Threshold = 8f,
ItemId = behavior.ItemId,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var cargoItemId = ship.Inventory.Keys.FirstOrDefault();
var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId);
if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal))
{
behavior.ItemId = targetResourceItemId;
behavior.NodeId = null;
}
var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
candidate.ItemId == targetResourceItemId &&
candidate.OreRemaining > 0.01f &&
CanShipMineItem(world, ship, candidate.ItemId))
.OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0)
.ThenByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate =>
candidate.Id == behavior.NodeId &&
string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) &&
candidate.OreRemaining > 0.01f);
if (node is not null)
{
behavior.AreaSystemId = node.SystemId;
}
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
{
if (refinery is null && GetShipCargoAmount(ship) > 0.01f)
{
ship.Inventory.Clear();
}
behavior.Phase = null;
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;
}
}
private static string SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string fallbackItemId)
{
var candidateItemId = world.MarketOrders
.Where(order =>
string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal)
&& order.Kind == MarketOrderKinds.Buy
&& order.RemainingAmount > 0.01f)
.SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId)
.Select(itemId => new
{
ItemId = itemId,
Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation),
}))
.Where(entry =>
CanShipMineItem(world, ship, entry.ItemId)
&& world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
.GroupBy(entry => entry.ItemId, StringComparer.Ordinal)
.Select(group => new
{
ItemId = group.Key,
Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f),
})
.OrderByDescending(entry => entry.Score)
.Select(entry => entry.ItemId)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(candidateItemId))
{
return candidateItemId;
}
if (CanShipMineItem(world, ship, fallbackItemId)
&& world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f))
{
return fallbackItemId;
}
return world.Nodes
.Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId))
.OrderByDescending(node => node.OreRemaining)
.Select(node => node.ItemId)
.FirstOrDefault() ?? fallbackItemId;
}
private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) =>
world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)
&& string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal)
&& HasShipCapabilities(ship.Definition, "mining");
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 && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal))
.Where(entry => CanStationReceiveItem(world, entry.Station!, itemId))
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null);
}
private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId)
{
if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal);
}
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 = !string.IsNullOrWhiteSpace(behavior.TargetEntityId)
? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId)
: station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId))
{
behavior.TargetEntityId = null;
behavior.ModuleId = null;
site = GetConstructionSiteForStation(world, station.Id);
}
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 = ResolveConstructionHoldPosition(ship, station, site, world);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world);
var targetSystemId = site?.SystemId ?? station.SystemId;
var targetCelestialId = site?.CelestialId ?? station.CelestialId;
var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId)
&& string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal);
var isAtConstructionHold = ship.SystemId == targetSystemId
&& (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial);
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 = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = site?.Id ?? station.Id,
TargetSystemId = targetSystemId,
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,
"attack-target" => ControllerTaskKind.AttackTarget,
"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,
};
}
private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
{
if (site is null || site.StationId is not null)
{
return GetConstructionHoldPosition(station, ship.Id);
}
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
var anchorPosition = anchor?.Position ?? station.Position;
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
}
private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world)
{
if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId))
{
var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!);
if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal))
{
return direct;
}
}
var hostileShips = world.Ships
.Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f))
.ToList();
var hostileStations = world.Stations
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
.Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f))
.ToList();
var preferredSystemId = ship.DefaultBehavior.AreaSystemId;
return hostileShips
.Concat(hostileStations)
.OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1)
.ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId)
{
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f);
if (ship is not null)
{
return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f);
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId);
return station is null
? null
: new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f);
}
private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange);
}