Refactor runtime bootstrap and ship control flows
This commit is contained in:
784
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal file
784
apps/backend/Ships/AI/ShipAiService.BehaviorQueue.cs
Normal file
@@ -0,0 +1,784 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) =>
|
||||
ship.OrderQueue
|
||||
.Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active)
|
||||
.OrderByDescending(GetOrderSourcePriority)
|
||||
.ThenByDescending(order => order.Priority)
|
||||
.ThenBy(order => order.CreatedAtUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static int GetOrderSourcePriority(ShipOrderRuntime order) => order.SourceKind switch
|
||||
{
|
||||
ShipOrderSourceKind.Player => 300,
|
||||
ShipOrderSourceKind.Commander => 200,
|
||||
ShipOrderSourceKind.Behavior => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private void SyncBehaviorOrders(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var desiredOrder = BuildManagedBehaviorOrder(world, ship);
|
||||
ship.OrderQueue.RemoveAll(order =>
|
||||
order.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)));
|
||||
|
||||
if (desiredOrder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
|
||||
if (existing is null)
|
||||
{
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ManagedOrdersEqual(existing, desiredOrder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.OrderQueue.Remove(existing);
|
||||
ship.OrderQueue.Add(desiredOrder);
|
||||
}
|
||||
|
||||
private ShipOrderRuntime? BuildManagedBehaviorOrder(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
var behaviorKind = assignment?.BehaviorKind ?? ship.DefaultBehavior.Kind;
|
||||
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
|
||||
if (string.Equals(behaviorKind, HoldPosition, StringComparison.Ordinal))
|
||||
{
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-hold-position",
|
||||
Kind = ShipOrderKinds.HoldPosition,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Hold position",
|
||||
TargetSystemId = systemId,
|
||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, DockAndWait, StringComparison.Ordinal))
|
||||
{
|
||||
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-dock-and-wait",
|
||||
Kind = ShipOrderKinds.DockAndWait,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Dock and wait at {station.Label}",
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FlyAndWait, StringComparison.Ordinal))
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-fly-and-wait",
|
||||
Kind = ShipOrderKinds.FlyAndWait,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Fly and wait",
|
||||
TargetSystemId = systemId,
|
||||
TargetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FollowShip, StringComparison.Ordinal))
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate =>
|
||||
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
|
||||
&& candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "target-ship-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-follow-ship",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Follow {targetShip.Definition.Name}",
|
||||
TargetEntityId = targetShip.Id,
|
||||
TargetSystemId = targetShip.SystemId,
|
||||
WaitSeconds = MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = MathF.Max(16f, ship.DefaultBehavior.Radius),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, FlyToObject, StringComparison.Ordinal))
|
||||
{
|
||||
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
var target = ResolveObjectTarget(world, targetEntityId);
|
||||
if (target is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-fly-to-object",
|
||||
Kind = ShipOrderKinds.FlyToObject,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Fly to object",
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = target.Value.SystemId,
|
||||
TargetPosition = target.Value.Position,
|
||||
WaitSeconds = MathF.Max(1f, ship.DefaultBehavior.WaitSeconds),
|
||||
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, Patrol, StringComparison.Ordinal))
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AttackTarget, StringComparison.Ordinal))
|
||||
{
|
||||
var targetEntityId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId;
|
||||
if (string.IsNullOrWhiteSpace(targetEntityId))
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, behaviorKind);
|
||||
}
|
||||
|
||||
var target = ResolveObjectTarget(world, targetEntityId);
|
||||
ship.LastAccessFailureReason = target is null ? "target-missing" : null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-attack-target",
|
||||
Kind = ShipOrderKinds.AttackTarget,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = "Attack target",
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = target?.SystemId ?? assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId,
|
||||
TargetPosition = target?.Position ?? ship.Position,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 26f,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ConstructStation, StringComparison.Ordinal))
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId))
|
||||
?? world.ConstructionSites
|
||||
.Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned)
|
||||
.OrderBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (site is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-construction-site";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ResolveSupportStation(world, ship, site) is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "support-station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-construct-station",
|
||||
Kind = ShipOrderKinds.BuildAtSite,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Build {site.BlueprintId}",
|
||||
TargetEntityId = site.Id,
|
||||
TargetSystemId = site.SystemId,
|
||||
ConstructionSiteId = site.Id,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AdvancedAutoMine, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (homeStation is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-home-station";
|
||||
return null;
|
||||
}
|
||||
|
||||
var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind);
|
||||
if (opportunity is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-mineable-node";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-mine-and-deliver",
|
||||
Kind = ShipOrderKinds.MineAndDeliverRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = opportunity.Summary,
|
||||
TargetEntityId = opportunity.Node.Id,
|
||||
TargetSystemId = opportunity.Node.SystemId,
|
||||
DestinationStationId = opportunity.DropOffStation.Id,
|
||||
ItemId = opportunity.Node.ItemId,
|
||||
NodeId = opportunity.Node.Id,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectPosition, StringComparison.Ordinal))
|
||||
{
|
||||
var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
|
||||
var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius));
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, "Protect position", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
"Protect position",
|
||||
targetSystemId,
|
||||
targetPosition,
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectShip, StringComparison.Ordinal))
|
||||
{
|
||||
var guardTarget = world.Ships.FirstOrDefault(candidate =>
|
||||
candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId)
|
||||
&& candidate.Health > 0f);
|
||||
if (guardTarget is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
var threat = SelectThreatTarget(
|
||||
world,
|
||||
ship,
|
||||
guardTarget.SystemId,
|
||||
guardTarget.Position,
|
||||
MathF.Max(90f, ship.DefaultBehavior.Radius),
|
||||
excludeEntityId: guardTarget.Id);
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {guardTarget.Definition.Name}", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFollowShipOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
$"Escort {guardTarget.Definition.Name}",
|
||||
guardTarget,
|
||||
MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f),
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, ProtectStation, StringComparison.Ordinal))
|
||||
{
|
||||
var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius));
|
||||
if (threat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, behaviorKind, $"Protect {station.Label}", threat.EntityId, threat.SystemId, threat.Position);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(
|
||||
ship,
|
||||
behaviorKind,
|
||||
$"Guard {station.Label}",
|
||||
station.SystemId,
|
||||
GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)),
|
||||
MathF.Max(2f, ship.DefaultBehavior.WaitSeconds),
|
||||
MathF.Max(6f, ship.DefaultBehavior.Radius));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, Police, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var policeSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId;
|
||||
var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position;
|
||||
var contact = SelectPoliceContact(world, ship, policeSystemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius));
|
||||
if (contact is null)
|
||||
{
|
||||
return BuildManagedPatrolOrder(world, ship, assignment, Patrol);
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return contact.Engage
|
||||
? CreateManagedAttackOrder(ship, behaviorKind, "Police engage", contact.EntityId, contact.SystemId, contact.Position)
|
||||
: CreateManagedFollowTargetOrder(ship, behaviorKind, "Police inspect", contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds));
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, LocalAutoTrade, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, AdvancedAutoTrade, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal)
|
||||
|| string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly);
|
||||
if (route is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedTradeRouteOrder(ship, behaviorKind, route);
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal)
|
||||
&& SelectKnownStationVisit(world, ship, homeStation) is { } visitStation)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedDockAndWaitOrder(ship, behaviorKind, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}");
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = "no-trade-route";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, SupplyFleet, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var plan = SelectFleetSupplyPlan(world, ship, homeStation);
|
||||
if (plan is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-fleet-to-supply";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-supply-fleet",
|
||||
Kind = ShipOrderKinds.SupplyFleetRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = plan.Summary,
|
||||
TargetEntityId = plan.TargetShip.Id,
|
||||
TargetSystemId = plan.TargetShip.SystemId,
|
||||
SourceStationId = plan.SourceStation.Id,
|
||||
ItemId = plan.ItemId,
|
||||
Radius = plan.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, AutoSalvage, StringComparison.Ordinal))
|
||||
{
|
||||
var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (homeStation is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-home-station";
|
||||
return null;
|
||||
}
|
||||
|
||||
var salvage = SelectSalvageOpportunity(world, ship, homeStation);
|
||||
if (salvage is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-salvage-target";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-auto-salvage",
|
||||
Kind = ShipOrderKinds.SalvageRun,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = salvage.Summary,
|
||||
TargetEntityId = salvage.Wreck.Id,
|
||||
TargetSystemId = salvage.Wreck.SystemId,
|
||||
TargetPosition = salvage.Wreck.Position,
|
||||
SourceStationId = homeStation.Id,
|
||||
ItemId = salvage.Wreck.ItemId,
|
||||
Radius = MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f),
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(behaviorKind, RepeatOrders, StringComparison.Ordinal))
|
||||
{
|
||||
if (ship.DefaultBehavior.RepeatOrders.Count == 0)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-repeat-orders";
|
||||
return null;
|
||||
}
|
||||
|
||||
var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count];
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-repeat-{ship.DefaultBehavior.RepeatIndex}",
|
||||
Kind = template.Kind,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = template.Label,
|
||||
TargetEntityId = template.TargetEntityId,
|
||||
TargetSystemId = template.TargetSystemId,
|
||||
TargetPosition = template.TargetPosition,
|
||||
SourceStationId = template.SourceStationId,
|
||||
DestinationStationId = template.DestinationStationId,
|
||||
ItemId = template.ItemId,
|
||||
NodeId = template.NodeId,
|
||||
ConstructionSiteId = template.ConstructionSiteId,
|
||||
ModuleId = template.ModuleId,
|
||||
WaitSeconds = template.WaitSeconds,
|
||||
Radius = template.Radius,
|
||||
MaxSystemRange = template.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = template.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
ship.LastAccessFailureReason = "missing-item";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
|
||||
{
|
||||
var buyer = SelectLocalAutoMineBuyer(world, ship, systemId, itemId);
|
||||
if (buyer is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-suitable-buyer";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-local-auto-mine-sell",
|
||||
Kind = ShipOrderKinds.SellMinedCargo,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Sell {itemId} in {systemId}",
|
||||
TargetEntityId = buyer.Id,
|
||||
TargetSystemId = buyer.SystemId,
|
||||
DestinationStationId = buyer.Id,
|
||||
ItemId = itemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 0f,
|
||||
MaxSystemRange = 0,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
var node = SelectLocalMiningNode(world, ship, systemId, itemId);
|
||||
if (node is null)
|
||||
{
|
||||
ship.LastAccessFailureReason = "no-mineable-node";
|
||||
return null;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return new ShipOrderRuntime
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-local-auto-mine-mine",
|
||||
Kind = ShipOrderKinds.MineLocal,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = $"Mine {itemId} in {systemId}",
|
||||
TargetSystemId = node.SystemId,
|
||||
NodeId = node.Id,
|
||||
ItemId = node.ItemId,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 0f,
|
||||
MaxSystemRange = 0,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ManagedOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) =>
|
||||
string.Equals(left.Id, right.Id, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Kind, right.Kind, StringComparison.Ordinal)
|
||||
&& left.SourceKind == right.SourceKind
|
||||
&& string.Equals(left.SourceId, right.SourceId, StringComparison.Ordinal)
|
||||
&& left.Priority == right.Priority
|
||||
&& left.InterruptCurrentPlan == right.InterruptCurrentPlan
|
||||
&& string.Equals(left.Label, right.Label, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal)
|
||||
&& left.TargetPosition == right.TargetPosition
|
||||
&& string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal)
|
||||
&& string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal)
|
||||
&& left.WaitSeconds.Equals(right.WaitSeconds)
|
||||
&& left.Radius.Equals(right.Radius)
|
||||
&& left.MaxSystemRange == right.MaxSystemRange
|
||||
&& left.KnownStationsOnly == right.KnownStationsOnly;
|
||||
|
||||
private ShipOrderRuntime BuildManagedPatrolOrder(SimulationWorld world, ShipRuntime ship, CommanderAssignmentRuntime? assignment, string sourceKind)
|
||||
{
|
||||
var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position;
|
||||
var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius));
|
||||
if (patrolThreat is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedAttackOrder(ship, sourceKind, "Patrol intercept", patrolThreat.EntityId, patrolThreat.SystemId, patrolThreat.Position, orderIdSuffix: "patrol-attack");
|
||||
}
|
||||
|
||||
Vector3 targetPosition;
|
||||
string targetSystemId;
|
||||
if (ship.DefaultBehavior.PatrolPoints.Count > 0)
|
||||
{
|
||||
var index = ship.DefaultBehavior.PatrolIndex % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
targetPosition = ship.DefaultBehavior.PatrolPoints[index];
|
||||
ship.DefaultBehavior.PatrolIndex = (index + 1) % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
}
|
||||
else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? assignment?.HomeStationId) is { } homeStation)
|
||||
{
|
||||
var patrolRadius = homeStation.Radius + 90f;
|
||||
targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z);
|
||||
targetSystemId = homeStation.SystemId;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetPosition = ship.Position;
|
||||
targetSystemId = ship.SystemId;
|
||||
}
|
||||
|
||||
ship.LastAccessFailureReason = null;
|
||||
return CreateManagedFlyAndWaitOrder(ship, sourceKind, "Patrol waypoint", targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), MathF.Max(6f, ship.DefaultBehavior.Radius), orderIdSuffix: "patrol-fly-and-wait");
|
||||
}
|
||||
|
||||
private static ShipOrderRuntime CreateManagedAttackOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetEntityId,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? orderIdSuffix = null) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||
Kind = ShipOrderKinds.AttackTarget,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = 0f,
|
||||
Radius = 26f,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedTradeRouteOrder(ShipRuntime ship, string behaviorKind, TradeRoutePlan route) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-trade-route",
|
||||
Kind = ShipOrderKinds.TradeRoute,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = route.Summary,
|
||||
SourceStationId = route.SourceStation.Id,
|
||||
DestinationStationId = route.DestinationStation.Id,
|
||||
ItemId = route.ItemId,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedDockAndWaitOrder(ShipRuntime ship, string behaviorKind, StationRuntime station, float waitSeconds, string label) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}-dock-and-wait",
|
||||
Kind = ShipOrderKinds.DockAndWait,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
DestinationStationId = station.Id,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = ship.DefaultBehavior.Radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFlyAndWaitOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
float waitSeconds,
|
||||
float radius,
|
||||
string? orderIdSuffix = null) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{orderIdSuffix ?? behaviorKind}",
|
||||
Kind = ShipOrderKinds.FlyAndWait,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFollowShipOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
ShipRuntime targetShip,
|
||||
float radius,
|
||||
float waitSeconds) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetShip.Id,
|
||||
TargetSystemId = targetShip.SystemId,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
|
||||
private static ShipOrderRuntime CreateManagedFollowTargetOrder(
|
||||
ShipRuntime ship,
|
||||
string behaviorKind,
|
||||
string label,
|
||||
string targetEntityId,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
float radius,
|
||||
float waitSeconds) =>
|
||||
new()
|
||||
{
|
||||
Id = $"behavior-{ship.Id}-{behaviorKind}",
|
||||
Kind = ShipOrderKinds.FollowShip,
|
||||
SourceKind = ShipOrderSourceKind.Behavior,
|
||||
SourceId = behaviorKind,
|
||||
Priority = 0,
|
||||
InterruptCurrentPlan = false,
|
||||
Label = label,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
WaitSeconds = waitSeconds,
|
||||
Radius = radius,
|
||||
MaxSystemRange = ship.DefaultBehavior.MaxSystemRange,
|
||||
KnownStationsOnly = ship.DefaultBehavior.KnownStationsOnly,
|
||||
};
|
||||
}
|
||||
54
apps/backend/Ships/AI/ShipAiService.Data.cs
Normal file
54
apps/backend/Ships/AI/ShipAiService.Data.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private enum SubTaskOutcome
|
||||
{
|
||||
Active,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
private sealed record TradeRoutePlan(
|
||||
StationRuntime SourceStation,
|
||||
StationRuntime DestinationStation,
|
||||
string ItemId,
|
||||
float Score,
|
||||
string Summary);
|
||||
|
||||
private sealed record MiningOpportunity(
|
||||
ResourceNodeRuntime Node,
|
||||
StationRuntime DropOffStation,
|
||||
float Score,
|
||||
string Summary);
|
||||
|
||||
private sealed record FleetSupplyPlan(
|
||||
StationRuntime SourceStation,
|
||||
ShipRuntime TargetShip,
|
||||
string ItemId,
|
||||
float Amount,
|
||||
float Radius,
|
||||
string Summary);
|
||||
|
||||
private sealed record LocalMiningBuyerCandidate(
|
||||
StationRuntime Station,
|
||||
float Score);
|
||||
|
||||
private sealed record ThreatTargetCandidate(
|
||||
string EntityId,
|
||||
string SystemId,
|
||||
Vector3 Position,
|
||||
float Score);
|
||||
|
||||
private sealed record PoliceContactCandidate(
|
||||
string EntityId,
|
||||
string SystemId,
|
||||
Vector3 Position,
|
||||
bool Engage,
|
||||
float Score);
|
||||
|
||||
private sealed record SalvageOpportunity(
|
||||
WreckRuntime Wreck,
|
||||
float Score,
|
||||
string Summary);
|
||||
}
|
||||
770
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal file
770
apps/backend/Ships/AI/ShipAiService.Execution.cs
Normal file
@@ -0,0 +1,770 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
return subTask.Kind switch
|
||||
{
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds),
|
||||
var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds),
|
||||
_ => SubTaskOutcome.Failed,
|
||||
};
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
ship.State = ShipState.HoldingPosition;
|
||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||
ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition)));
|
||||
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
subTask.BlockingReason = "follow-target-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f));
|
||||
subTask.TargetSystemId = targetShip.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
subTask.BlockingReason = null;
|
||||
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.HoldingPosition;
|
||||
ship.TargetPosition = desiredPosition;
|
||||
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
|
||||
return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival)
|
||||
{
|
||||
if (subTask.TargetPosition is null || subTask.TargetSystemId is null)
|
||||
{
|
||||
subTask.BlockingReason = "travel-target-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var targetPosition = ResolveCurrentTargetPosition(world, subTask);
|
||||
var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition);
|
||||
ship.TargetPosition = targetPosition;
|
||||
|
||||
if (ship.SystemId != subTask.TargetSystemId)
|
||||
{
|
||||
if (!CanFtl(ship.Definition))
|
||||
{
|
||||
subTask.BlockingReason = "ftl-unavailable";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId);
|
||||
var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition;
|
||||
return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition);
|
||||
}
|
||||
|
||||
var currentCelestial = ResolveCurrentCelestial(world, ship);
|
||||
if (targetCelestial is not null
|
||||
&& currentCelestial is not null
|
||||
&& !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
|
||||
{
|
||||
if (!CanWarp(ship.Definition))
|
||||
{
|
||||
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival);
|
||||
}
|
||||
|
||||
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival);
|
||||
}
|
||||
|
||||
if (targetCelestial is not null
|
||||
&& ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers
|
||||
&& CanWarp(ship.Definition))
|
||||
{
|
||||
return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival);
|
||||
}
|
||||
|
||||
return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival);
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
var hostileStation = hostileShip is null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId)
|
||||
: null;
|
||||
if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId)
|
||||
|| (hostileStation is not null && hostileStation.FactionId == ship.FactionId))
|
||||
{
|
||||
subTask.BlockingReason = "friendly-target";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
if (hostileShip is null && hostileStation is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId;
|
||||
var targetPosition = hostileShip?.Position ?? hostileStation!.Position;
|
||||
var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f;
|
||||
subTask.TargetSystemId = targetSystemId;
|
||||
subTask.TargetPosition = targetPosition;
|
||||
subTask.Threshold = attackRange;
|
||||
|
||||
if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange)
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.EngagingTarget;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f));
|
||||
var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat);
|
||||
subTask.Progress = 1f;
|
||||
|
||||
if (hostileShip is not null)
|
||||
{
|
||||
hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage);
|
||||
return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f));
|
||||
return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId);
|
||||
if (node is null || !CanExtractNode(ship, node, world))
|
||||
{
|
||||
subTask.BlockingReason = "node-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
ship.TargetPosition = targetPosition;
|
||||
if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f))
|
||||
{
|
||||
ship.State = ShipState.MiningApproach;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount >= ship.Definition.GetTotalCargoCapacity() - 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Mining;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - cargoAmount);
|
||||
var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity);
|
||||
mined = MathF.Min(mined, node.OreRemaining);
|
||||
if (mined <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
AddInventory(ship.Inventory, node.ItemId, mined);
|
||||
node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined);
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f || node.OreRemaining <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var station = ResolveStation(world, subTask.TargetEntityId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "dock-target-missing";
|
||||
ship.State = ShipState.Blocked;
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
|
||||
if (padIndex is null)
|
||||
{
|
||||
ship.State = ShipState.AwaitingDock;
|
||||
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
|
||||
if (ship.Position.DistanceTo(ship.TargetPosition) > 4f)
|
||||
{
|
||||
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = "waiting-for-pad";
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Active;
|
||||
subTask.BlockingReason = null;
|
||||
ship.AssignedDockingPadIndex = padIndex;
|
||||
var padPosition = GetDockingPadPosition(station, padIndex.Value);
|
||||
ship.TargetPosition = padPosition;
|
||||
if (ship.Position.DistanceTo(padPosition) > 4f)
|
||||
{
|
||||
ship.State = ShipState.DockingApproach;
|
||||
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Docking;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.Docked;
|
||||
ship.DockedStationId = station.Id;
|
||||
station.DockedShipIds.Add(ship.Id);
|
||||
ship.KnownStationIds.Add(station.Id);
|
||||
ship.Position = padPosition;
|
||||
ship.TargetPosition = padPosition;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance);
|
||||
ship.TargetPosition = undockTarget;
|
||||
ship.State = ShipState.Undocking;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration))
|
||||
{
|
||||
ship.Position = GetShipDockedPosition(ship, station);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance);
|
||||
if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
station.DockedShipIds.Remove(ship.Id);
|
||||
ReleaseDockingPad(station, ship.Id);
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
subTask.BlockingReason = "not-docked";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "station-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.State = ShipState.Loading;
|
||||
var itemId = subTask.ItemId;
|
||||
if (itemId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.GetTotalCargoCapacity();
|
||||
var availableCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade);
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId)));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(station.Inventory, itemId, moved);
|
||||
AddInventory(ship.Inventory, itemId, moved);
|
||||
}
|
||||
|
||||
var loadedAmount = GetInventoryAmount(ship.Inventory, itemId);
|
||||
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f);
|
||||
return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
subTask.BlockingReason = "not-docked";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
subTask.BlockingReason = "station-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.State = ShipState.Transferring;
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining));
|
||||
|
||||
if (subTask.ItemId is not null)
|
||||
{
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId));
|
||||
var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved);
|
||||
RemoveInventory(ship.Inventory, subTask.ItemId, accepted);
|
||||
subTask.Progress = subTask.Amount <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f);
|
||||
return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var moved = MathF.Min(amount, transferRate * deltaSeconds);
|
||||
var accepted = TryAddStationInventory(world, station, itemId, moved);
|
||||
RemoveInventory(ship.Inventory, itemId, accepted);
|
||||
if (accepted > 0.01f)
|
||||
{
|
||||
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
}
|
||||
|
||||
return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
subTask.BlockingReason = "target-ship-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f));
|
||||
subTask.TargetSystemId = targetShip.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f))
|
||||
{
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.Transferring;
|
||||
ship.TargetPosition = desiredPosition;
|
||||
ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition)));
|
||||
if (subTask.ItemId is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var targetCapacity = MathF.Max(0f, targetShip.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(targetShip));
|
||||
if (targetCapacity <= 0.01f)
|
||||
{
|
||||
subTask.BlockingReason = "target-cargo-full";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation));
|
||||
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId);
|
||||
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId)));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(ship.Inventory, subTask.ItemId, moved);
|
||||
AddInventory(targetShip.Inventory, subTask.ItemId, moved);
|
||||
}
|
||||
|
||||
var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId);
|
||||
subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f);
|
||||
return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.GetTotalCargoCapacity() - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
||||
if (wreck is null)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f);
|
||||
ship.TargetPosition = desiredPosition;
|
||||
if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f))
|
||||
{
|
||||
subTask.TargetSystemId = wreck.SystemId;
|
||||
subTask.TargetPosition = desiredPosition;
|
||||
return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false);
|
||||
}
|
||||
|
||||
ship.State = ShipState.Transferring;
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.GetTotalCargoCapacity() - GetShipCargoAmount(ship));
|
||||
if (remainingCapacity <= 0.01f)
|
||||
{
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f)))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade));
|
||||
var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount));
|
||||
if (recovered > 0.01f)
|
||||
{
|
||||
AddInventory(ship.Inventory, wreck.ItemId, recovered);
|
||||
wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered);
|
||||
}
|
||||
|
||||
if (wreck.RemainingAmount <= 0.01f)
|
||||
{
|
||||
world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id);
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.GetTotalCargoCapacity() - 0.01f
|
||||
? SubTaskOutcome.Completed
|
||||
: SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
var station = site is null ? null : ResolveSupportStation(world, ship, site);
|
||||
if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
|
||||
{
|
||||
subTask.BlockingReason = "construction-target-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var supportPosition = ResolveSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = supportPosition;
|
||||
ship.State = ShipState.DeliveringConstruction;
|
||||
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction);
|
||||
foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
|
||||
var remaining = MathF.Max(0f, required.Value - delivered);
|
||||
if (remaining <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key));
|
||||
var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds));
|
||||
if (moved <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoveInventory(station.Inventory, required.Key, moved);
|
||||
AddInventory(site.Inventory, required.Key, moved);
|
||||
AddInventory(site.DeliveredItems, required.Key, moved);
|
||||
break;
|
||||
}
|
||||
|
||||
subTask.Progress = site.RequiredItems.Count == 0
|
||||
? 1f
|
||||
: site.RequiredItems.Sum(required =>
|
||||
required.Value <= 0.01f
|
||||
? 1f
|
||||
: Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count;
|
||||
return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
var station = site is null ? null : ResolveSupportStation(world, ship, site);
|
||||
if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed)
|
||||
{
|
||||
subTask.BlockingReason = "construction-site-missing";
|
||||
return SubTaskOutcome.Failed;
|
||||
}
|
||||
|
||||
var supportPosition = ResolveSupportPosition(ship, station, site, world);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold)))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = "waiting-materials";
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
subTask.Status = WorkStatus.Active;
|
||||
subTask.BlockingReason = null;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = supportPosition;
|
||||
ship.State = ShipState.Constructing;
|
||||
site.AssignedConstructorShipIds.Add(ship.Id);
|
||||
site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction);
|
||||
subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
|
||||
if (site.Progress < recipe.Duration)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
if (site.StationId is null)
|
||||
{
|
||||
CompleteStationFoundation(world, station, site);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddStationModule(world, station, site.BlueprintId);
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
}
|
||||
|
||||
site.State = ConstructionSiteStateKinds.Completed;
|
||||
return SubTaskOutcome.Completed;
|
||||
}
|
||||
|
||||
private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds)
|
||||
{
|
||||
subTask.TotalSeconds = requiredSeconds;
|
||||
subTask.ElapsedSeconds += deltaSeconds;
|
||||
subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f);
|
||||
if (subTask.ElapsedSeconds < requiredSeconds)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateLocalTravel(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
CelestialRuntime? targetCelestial,
|
||||
bool completeOnArrival)
|
||||
{
|
||||
var distance = ship.Position.DistanceTo(targetPosition);
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
|
||||
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
|
||||
|
||||
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
|
||||
{
|
||||
ship.Position = targetPosition;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateWarpTransit(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
Vector3 targetPosition,
|
||||
CelestialRuntime targetCelestial,
|
||||
bool completeOnArrival)
|
||||
{
|
||||
var transit = ship.SpatialState.Transit;
|
||||
if (transit is null || transit.Regime != MovementRegimeKind.Warp || transit.DestinationNodeId != targetCelestial.Id)
|
||||
{
|
||||
transit = new ShipTransitRuntime
|
||||
{
|
||||
Regime = MovementRegimeKind.Warp,
|
||||
OriginNodeId = ship.SpatialState.CurrentCelestialId,
|
||||
DestinationNodeId = targetCelestial.Id,
|
||||
StartedAtUtc = world.GeneratedAtUtc,
|
||||
};
|
||||
ship.SpatialState.Transit = transit;
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
}
|
||||
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.SystemSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.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)
|
||||
{
|
||||
ship.State = ShipState.SpoolingWarp;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
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));
|
||||
subTask.Progress = transit.Progress;
|
||||
if (ship.Position.DistanceTo(targetPosition) > 18f)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival);
|
||||
}
|
||||
|
||||
private SubTaskOutcome UpdateFtlTransit(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
ShipSubTaskRuntime subTask,
|
||||
float deltaSeconds,
|
||||
string targetSystemId,
|
||||
Vector3 entryPosition,
|
||||
CelestialRuntime? targetCelestial,
|
||||
bool completeOnArrival,
|
||||
Vector3 finalTargetPosition)
|
||||
{
|
||||
var destinationNodeId = targetCelestial?.Id;
|
||||
var transit = ship.SpatialState.Transit;
|
||||
if (transit is null || transit.Regime != MovementRegimeKind.FtlTransit || transit.DestinationNodeId != destinationNodeId)
|
||||
{
|
||||
transit = new ShipTransitRuntime
|
||||
{
|
||||
Regime = MovementRegimeKind.FtlTransit,
|
||||
OriginNodeId = ship.SpatialState.CurrentCelestialId,
|
||||
DestinationNodeId = destinationNodeId,
|
||||
StartedAtUtc = world.GeneratedAtUtc,
|
||||
};
|
||||
ship.SpatialState.Transit = transit;
|
||||
subTask.ElapsedSeconds = 0f;
|
||||
}
|
||||
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.GalaxySpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.FtlTransit;
|
||||
ship.SpatialState.CurrentCelestialId = null;
|
||||
ship.SpatialState.DestinationNodeId = destinationNodeId;
|
||||
|
||||
if (ship.State != ShipState.Ftl)
|
||||
{
|
||||
ship.State = ShipState.SpoolingFtl;
|
||||
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f)))
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
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 * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance));
|
||||
subTask.Progress = transit.Progress;
|
||||
if (transit.Progress < 0.999f)
|
||||
{
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
ship.Position = entryPosition;
|
||||
ship.TargetPosition = finalTargetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
|
||||
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
|
||||
ship.State = ShipState.Arriving;
|
||||
|
||||
// Cross-system travel is only complete once the ship finishes the
|
||||
// destination-system local leg to the actual target.
|
||||
return SubTaskOutcome.Active;
|
||||
}
|
||||
|
||||
private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival)
|
||||
{
|
||||
ship.Position = targetPosition;
|
||||
ship.TargetPosition = targetPosition;
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentSystemId = targetSystemId;
|
||||
ship.SpatialState.Transit = null;
|
||||
ship.SpatialState.SpaceLayer = SpaceLayerKind.LocalSpace;
|
||||
ship.SpatialState.MovementRegime = MovementRegimeKind.LocalFlight;
|
||||
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
|
||||
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
|
||||
ship.State = ShipState.Arriving;
|
||||
return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active;
|
||||
}
|
||||
}
|
||||
947
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal file
947
apps/backend/Ships/AI/ShipAiService.Helpers.cs
Normal file
@@ -0,0 +1,947 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
|
||||
{
|
||||
var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
if (ship is not null)
|
||||
{
|
||||
return ship.Position;
|
||||
}
|
||||
|
||||
var station = ResolveStation(world, subTask.TargetEntityId);
|
||||
if (station is not null)
|
||||
{
|
||||
return station.Position;
|
||||
}
|
||||
|
||||
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
if (celestial is not null)
|
||||
{
|
||||
return celestial.Position;
|
||||
}
|
||||
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
if (wreck is not null)
|
||||
{
|
||||
return wreck.Position;
|
||||
}
|
||||
}
|
||||
|
||||
return subTask.TargetPosition ?? Vector3.Zero;
|
||||
}
|
||||
|
||||
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
|
||||
{
|
||||
var station = ResolveStation(world, subTask.TargetEntityId);
|
||||
if (station?.CelestialId is not null)
|
||||
{
|
||||
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
|
||||
}
|
||||
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
if (site?.CelestialId is not null)
|
||||
{
|
||||
return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
|
||||
}
|
||||
|
||||
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
|
||||
if (celestial is not null)
|
||||
{
|
||||
return celestial;
|
||||
}
|
||||
|
||||
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck)
|
||||
{
|
||||
return world.Celestials
|
||||
.Where(candidate => candidate.SystemId == wreck.SystemId)
|
||||
.OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return world.Celestials
|
||||
.Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.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 static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
|
||||
world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero;
|
||||
|
||||
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
|
||||
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation);
|
||||
|
||||
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
|
||||
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation);
|
||||
|
||||
private static float GetSkillFactor(int skillLevel) =>
|
||||
Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f);
|
||||
|
||||
private static int GetEffectiveSkillLevel(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
Func<ShipSkillProfileRuntime, int> captainSelector,
|
||||
Func<CommanderSkillProfileRuntime, int> managerSelector)
|
||||
{
|
||||
var captainLevel = captainSelector(ship.Skills);
|
||||
if (ship.CommanderId is null)
|
||||
{
|
||||
return captainLevel;
|
||||
}
|
||||
|
||||
var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId);
|
||||
var manager = shipCommander?.ParentCommanderId is null
|
||||
? shipCommander
|
||||
: world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander;
|
||||
return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5);
|
||||
}
|
||||
|
||||
private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange)
|
||||
{
|
||||
if (explicitRange > 0)
|
||||
{
|
||||
return explicitRange;
|
||||
}
|
||||
|
||||
var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination);
|
||||
var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
|
||||
var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy);
|
||||
return behaviorKind switch
|
||||
{
|
||||
LocalAutoMine or LocalAutoTrade => 0,
|
||||
AdvancedAutoMine => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3),
|
||||
AdvancedAutoTrade => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3),
|
||||
ExpertAutoMine => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)),
|
||||
FillShortages or FindBuildTasks or RevisitKnownStations or SupplyFleet => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)),
|
||||
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)),
|
||||
_ => Math.Max(world.Systems.Count - 1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId)
|
||||
{
|
||||
if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var originPosition = ResolveSystemGalaxyPosition(world, originSystemId);
|
||||
return world.Systems
|
||||
.OrderBy(system => system.Position.DistanceTo(originPosition))
|
||||
.ThenBy(system => system.Definition.Id, StringComparer.Ordinal)
|
||||
.Select(system => system.Definition.Id)
|
||||
.TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal))
|
||||
.Count();
|
||||
}
|
||||
|
||||
private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) =>
|
||||
maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange;
|
||||
|
||||
private static float GetShipDamagePerSecond(ShipRuntime ship) =>
|
||||
ship.Definition.Type switch
|
||||
{
|
||||
ShipType.Frigate => FrigateDps,
|
||||
ShipType.Destroyer => DestroyerDps,
|
||||
ShipType.Battleship => CruiserDps,
|
||||
ShipType.Carrier => CapitalDps,
|
||||
_ => 4f,
|
||||
};
|
||||
|
||||
private static MiningOpportunity? SelectMiningOpportunity(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
StationRuntime homeStation,
|
||||
CommanderAssignmentRuntime? assignment,
|
||||
string behaviorKind)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId;
|
||||
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
|
||||
var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination);
|
||||
string? deniedReason = null;
|
||||
var opportunity = world.Nodes
|
||||
.Where(node =>
|
||||
{
|
||||
if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason))
|
||||
{
|
||||
deniedReason ??= reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget);
|
||||
})
|
||||
.Select(node =>
|
||||
{
|
||||
var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind);
|
||||
var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId);
|
||||
var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f;
|
||||
var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f;
|
||||
var score = (node.SystemId == homeStation.SystemId ? 55f : 0f)
|
||||
+ (node.OreRemaining * 0.025f)
|
||||
+ (demandScore * (string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal) ? 22f : 12f))
|
||||
+ (effectiveMiningSkill * 10f)
|
||||
- distancePenalty
|
||||
- routeRiskPenalty
|
||||
- node.Position.DistanceTo(ship.Position);
|
||||
return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}");
|
||||
})
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (opportunity is null && deniedReason is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = deniedReason;
|
||||
}
|
||||
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
private static TradeRoutePlan? SelectTradeRoute(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
StationRuntime? homeStation,
|
||||
string behaviorKind,
|
||||
bool knownStationsOnly)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
var stationsById = world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.ToDictionary(station => station.Id, StringComparer.Ordinal);
|
||||
var originSystemId = homeStation?.SystemId ?? ship.SystemId;
|
||||
var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange);
|
||||
var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination);
|
||||
var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal);
|
||||
string? deniedReason = null;
|
||||
|
||||
var route = world.MarketOrders
|
||||
.Where(order =>
|
||||
order.FactionId == ship.FactionId &&
|
||||
order.Kind == MarketOrderKinds.Buy &&
|
||||
order.RemainingAmount > 0.01f)
|
||||
.Select(order =>
|
||||
{
|
||||
StationRuntime? destination = null;
|
||||
ConstructionSiteRuntime? destinationSite = null;
|
||||
if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation))
|
||||
{
|
||||
destination = destinationStation;
|
||||
}
|
||||
else if (order.ConstructionSiteId is not null)
|
||||
{
|
||||
destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId);
|
||||
if (destinationSite is not null)
|
||||
{
|
||||
destination = ResolveSupportStation(world, ship, destinationSite);
|
||||
}
|
||||
}
|
||||
|
||||
if (destination is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason))
|
||||
{
|
||||
deniedReason ??= destinationDeniedReason;
|
||||
return null;
|
||||
}
|
||||
if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (requireKnownStations
|
||||
&& ship.KnownStationIds.Count > 0
|
||||
&& !ship.KnownStationIds.Contains(destination.Id)
|
||||
&& (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(behaviorKind, FindBuildTasks, StringComparison.Ordinal) && destinationSite is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var source = stationsById.Values
|
||||
.Where(station =>
|
||||
{
|
||||
if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason))
|
||||
{
|
||||
deniedReason ??= sourceDeniedReason;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !requireKnownStations
|
||||
|| ship.KnownStationIds.Count == 0
|
||||
|| ship.KnownStationIds.Contains(station.Id)
|
||||
|| (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal));
|
||||
})
|
||||
.OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId))
|
||||
.ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
|
||||
.ThenBy(station => station.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (source is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shortageBias = string.Equals(behaviorKind, FillShortages, StringComparison.Ordinal)
|
||||
? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f
|
||||
: 0f;
|
||||
var buildBias = destinationSite is null ? 0f : 65f;
|
||||
var revisitBias = string.Equals(behaviorKind, RevisitKnownStations, StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id)
|
||||
? 28f
|
||||
: 0f;
|
||||
var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f;
|
||||
var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f;
|
||||
var riskPenalty =
|
||||
(GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId)
|
||||
+ GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f;
|
||||
var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position);
|
||||
var score = (order.Valuation * 50f)
|
||||
+ shortageBias
|
||||
+ buildBias
|
||||
+ revisitBias
|
||||
+ regionalNeedBias
|
||||
+ (effectiveTradeSkill * 12f)
|
||||
- systemRangePenalty
|
||||
- riskPenalty
|
||||
- distanceScore;
|
||||
var summary = destinationSite is null
|
||||
? $"{order.ItemId}: {source.Label} -> {destination.Label}"
|
||||
: $"{order.ItemId}: {source.Label} -> build support {destination.Label}";
|
||||
return new TradeRoutePlan(source, destination, order.ItemId, score, summary);
|
||||
})
|
||||
.Where(route => route is not null)
|
||||
.Cast<TradeRoutePlan>()
|
||||
.OrderByDescending(route => route.Score)
|
||||
.ThenBy(route => route.ItemId, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (route is null && deniedReason is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = deniedReason;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
var targetCandidates = world.Ships
|
||||
.Where(candidate =>
|
||||
candidate.Id != ship.Id &&
|
||||
candidate.FactionId == ship.FactionId &&
|
||||
candidate.Definition.GetTotalCargoCapacity() > 0.01f &&
|
||||
(assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal)))
|
||||
.OrderByDescending(candidate => IsMilitaryShip(candidate.Definition) ? 1 : 0)
|
||||
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (targetCandidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sourceStations = world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
|
||||
.ThenBy(station => station.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var target in targetCandidates)
|
||||
{
|
||||
var itemId = assignment?.ItemId
|
||||
?? sourceStations
|
||||
.SelectMany(station => station.Inventory)
|
||||
.Where(entry => entry.Value > 2f)
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.Ordinal)
|
||||
.Select(entry => entry.Key)
|
||||
.FirstOrDefault();
|
||||
if (itemId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f);
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var amount = MathF.Min(MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f), GetInventoryAmount(source.Inventory, itemId));
|
||||
return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Name} with {itemId}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
|
||||
{
|
||||
var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null
|
||||
? [homeStation.Id]
|
||||
: ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||
return candidateIds
|
||||
.Select(id => ResolveStation(world, id))
|
||||
.Where(station => station is not null && station.FactionId == ship.FactionId)
|
||||
.Cast<StationRuntime>()
|
||||
.OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0)
|
||||
.ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1)
|
||||
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind)
|
||||
{
|
||||
if (!string.Equals(behaviorKind, ExpertAutoMine, StringComparison.Ordinal))
|
||||
{
|
||||
return homeStation;
|
||||
}
|
||||
|
||||
return world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f))
|
||||
.ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1)
|
||||
.ThenBy(station => station.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault()
|
||||
?? homeStation;
|
||||
}
|
||||
|
||||
private static ResourceNodeRuntime? SelectLocalMiningNode(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
string? deniedReason = null;
|
||||
var node = world.Nodes
|
||||
.Where(candidate =>
|
||||
{
|
||||
if (!string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)
|
||||
|| !string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)
|
||||
|| candidate.OreRemaining <= 0.01f
|
||||
|| !CanExtractNode(ship, candidate, world))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, candidate.SystemId, "military", out var reason))
|
||||
{
|
||||
deniedReason ??= reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
||||
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (node is null && deniedReason is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = deniedReason;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static StationRuntime? SelectLocalAutoMineBuyer(SimulationWorld world, ShipRuntime ship, string systemId, string itemId)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
var stationsById = world.Stations.ToDictionary(station => station.Id, StringComparer.Ordinal);
|
||||
string? deniedReason = null;
|
||||
var buyer = world.MarketOrders
|
||||
.Where(order =>
|
||||
order.Kind == MarketOrderKinds.Buy
|
||||
&& string.Equals(order.ItemId, itemId, StringComparison.Ordinal)
|
||||
&& order.RemainingAmount > 0.01f)
|
||||
.Select(order =>
|
||||
{
|
||||
StationRuntime? destination = null;
|
||||
if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var station))
|
||||
{
|
||||
destination = station;
|
||||
}
|
||||
else if (order.ConstructionSiteId is not null)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == order.ConstructionSiteId);
|
||||
if (site is not null)
|
||||
{
|
||||
destination = ResolveSupportStation(world, ship, site);
|
||||
}
|
||||
}
|
||||
|
||||
if (destination is null || !string.Equals(destination.SystemId, systemId, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var reason))
|
||||
{
|
||||
deniedReason ??= reason;
|
||||
return null;
|
||||
}
|
||||
|
||||
var score = (order.Valuation * 20f)
|
||||
+ MathF.Min(order.RemainingAmount, ship.Definition.GetTotalCargoCapacity())
|
||||
- destination.Position.DistanceTo(ship.Position);
|
||||
return new LocalMiningBuyerCandidate(destination, score);
|
||||
})
|
||||
.Where(candidate => candidate is not null)
|
||||
.Cast<LocalMiningBuyerCandidate>()
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Station.Id, StringComparer.Ordinal)
|
||||
.Select(candidate => candidate.Station)
|
||||
.FirstOrDefault();
|
||||
if (buyer is null && deniedReason is not null)
|
||||
{
|
||||
ship.LastAccessFailureReason = deniedReason;
|
||||
}
|
||||
|
||||
return buyer;
|
||||
}
|
||||
|
||||
private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId)
|
||||
{
|
||||
var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)?
|
||||
.CommoditySignals
|
||||
.FirstOrDefault(candidate => candidate.ItemId == itemId);
|
||||
var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks
|
||||
.Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal))
|
||||
.Join(
|
||||
world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)),
|
||||
bottleneck => bottleneck.RegionId,
|
||||
region => region.Id,
|
||||
(bottleneck, _) => bottleneck.Severity)
|
||||
.DefaultIfEmpty()
|
||||
.Max() ?? 0f;
|
||||
if (signal is null)
|
||||
{
|
||||
return regionalBottleneckScore * 8f;
|
||||
}
|
||||
|
||||
return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f));
|
||||
}
|
||||
|
||||
private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId)
|
||||
{
|
||||
var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId);
|
||||
if (region is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)
|
||||
&& string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal));
|
||||
var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal));
|
||||
return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f);
|
||||
}
|
||||
|
||||
private static ThreatTargetCandidate? SelectThreatTarget(
|
||||
SimulationWorld world,
|
||||
ShipRuntime ship,
|
||||
string targetSystemId,
|
||||
Vector3 anchorPosition,
|
||||
float radius,
|
||||
string? excludeEntityId = null)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
return world.Ships
|
||||
.Where(candidate =>
|
||||
candidate.Id != excludeEntityId &&
|
||||
candidate.Health > 0f &&
|
||||
candidate.FactionId != ship.FactionId &&
|
||||
string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) &&
|
||||
candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f)
|
||||
.Select(candidate => new ThreatTargetCandidate(
|
||||
candidate.Id,
|
||||
candidate.SystemId,
|
||||
candidate.Position,
|
||||
100f
|
||||
+ (IsMilitaryShip(candidate.Definition) ? 30f : 0f)
|
||||
- candidate.Position.DistanceTo(anchorPosition)
|
||||
- candidate.Position.DistanceTo(ship.Position)
|
||||
+ (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f)))
|
||||
.Concat(world.Stations
|
||||
.Where(candidate =>
|
||||
candidate.Id != excludeEntityId &&
|
||||
candidate.FactionId != ship.FactionId &&
|
||||
string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) &&
|
||||
candidate.Position.DistanceTo(anchorPosition) <= radius * 2f)
|
||||
.Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f)))
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
return world.Ships
|
||||
.Where(candidate =>
|
||||
candidate.Id != ship.Id &&
|
||||
candidate.Health > 0f &&
|
||||
candidate.FactionId != ship.FactionId &&
|
||||
string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) &&
|
||||
candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f)
|
||||
.Select(candidate =>
|
||||
{
|
||||
var engage = IsMilitaryShip(candidate.Definition)
|
||||
|| string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase);
|
||||
var score = (engage ? 80f : 40f)
|
||||
- candidate.Position.DistanceTo(anchorPosition)
|
||||
- candidate.Position.DistanceTo(ship.Position)
|
||||
+ (IsTransportShip(candidate.Definition) ? 8f : 0f);
|
||||
return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score);
|
||||
})
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation)
|
||||
{
|
||||
if (homeStation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rangeBudget = ResolveBehaviorSystemRange(world, ship, AutoSalvage, ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1);
|
||||
return world.Wrecks
|
||||
.Where(wreck =>
|
||||
wreck.RemainingAmount > 0.01f &&
|
||||
IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget))
|
||||
.Select(wreck => new SalvageOpportunity(
|
||||
wreck,
|
||||
(wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f),
|
||||
$"Salvage {wreck.ItemId} from {wreck.SourceEntityId}"))
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId)
|
||||
{
|
||||
if (entityId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship)
|
||||
{
|
||||
return (ship.SystemId, ship.Position);
|
||||
}
|
||||
|
||||
if (ResolveStation(world, entityId) is { } station)
|
||||
{
|
||||
return (station.SystemId, station.Position);
|
||||
}
|
||||
|
||||
if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial)
|
||||
{
|
||||
return (celestial.SystemId, celestial.Position);
|
||||
}
|
||||
|
||||
if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site)
|
||||
{
|
||||
var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero;
|
||||
return (site.SystemId, position);
|
||||
}
|
||||
|
||||
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck)
|
||||
{
|
||||
return (wreck.SystemId, wreck.Position);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius)
|
||||
{
|
||||
var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
return new Vector3(
|
||||
anchorPosition.X + (MathF.Cos(angle) * radius),
|
||||
anchorPosition.Y,
|
||||
anchorPosition.Z + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
|
||||
private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId)
|
||||
{
|
||||
var source = ResolveStation(world, sourceStationId);
|
||||
var destination = ResolveStation(world, destinationStationId);
|
||||
return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}");
|
||||
}
|
||||
|
||||
private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) =>
|
||||
stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId);
|
||||
|
||||
private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) =>
|
||||
nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId);
|
||||
|
||||
private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) =>
|
||||
policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId);
|
||||
|
||||
private static bool IsSystemAllowed(
|
||||
SimulationWorld world,
|
||||
PolicySetRuntime? policy,
|
||||
string factionId,
|
||||
string systemId,
|
||||
string accessKind) =>
|
||||
TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _);
|
||||
|
||||
private static bool TryCheckSystemAllowed(
|
||||
SimulationWorld world,
|
||||
PolicySetRuntime? policy,
|
||||
string factionId,
|
||||
string systemId,
|
||||
string accessKind,
|
||||
out string? denialReason)
|
||||
{
|
||||
denialReason = null;
|
||||
if (policy?.BlacklistedSystemIds.Contains(systemId) == true)
|
||||
{
|
||||
denialReason = $"blacklisted:{systemId}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId);
|
||||
var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId;
|
||||
if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal)
|
||||
? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId)
|
||||
: GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId);
|
||||
if (!hasAccess)
|
||||
{
|
||||
denialReason = $"{accessKind}-access-denied:{authorityFactionId}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (policy?.AvoidHostileSystems != true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId))
|
||||
{
|
||||
denialReason = $"hostile-authority:{authorityFactionId}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate =>
|
||||
!string.Equals(candidate, factionId, StringComparison.Ordinal)
|
||||
&& GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate));
|
||||
if (hostileInfluencer is not null)
|
||||
{
|
||||
denialReason = $"hostile-influence:{hostileInfluencer}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) =>
|
||||
ship.CommanderId is null
|
||||
? null
|
||||
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment;
|
||||
|
||||
private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) =>
|
||||
plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex];
|
||||
|
||||
private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site)
|
||||
{
|
||||
return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId)
|
||||
?? world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0)
|
||||
.ThenBy(station => station.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world)
|
||||
{
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
return GetShipDockedPosition(ship, station);
|
||||
}
|
||||
|
||||
if (site?.StationId is null && site is not null)
|
||||
{
|
||||
var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position;
|
||||
return GetResourceHoldPosition(anchorPosition, ship.Id, 78f);
|
||||
}
|
||||
|
||||
return GetConstructionHoldPosition(station, ship.Id);
|
||||
}
|
||||
|
||||
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
|
||||
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
||||
|
||||
private static void TrackHistory(ShipRuntime ship)
|
||||
{
|
||||
var plan = ship.ActivePlan;
|
||||
var step = GetCurrentStep(plan);
|
||||
var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex];
|
||||
var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}";
|
||||
if (ship.LastSignature == signature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.LastSignature = signature;
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}");
|
||||
if (ship.History.Count > 24)
|
||||
{
|
||||
ship.History.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var currentPlanId = ship.ActivePlan?.Id;
|
||||
var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
||||
var occurredAtUtc = DateTimeOffset.UtcNow;
|
||||
if (previousState != ship.State)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Name} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal))
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Name} switched active plan.", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal))
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Name} advanced plan step.", occurredAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site)
|
||||
{
|
||||
var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId);
|
||||
if (anchor is null || site.BlueprintId is null)
|
||||
{
|
||||
site.State = ConstructionSiteStateKinds.Destroyed;
|
||||
return;
|
||||
}
|
||||
|
||||
var station = new StationRuntime
|
||||
{
|
||||
Id = $"station-{world.Stations.Count + 1}",
|
||||
SystemId = site.SystemId,
|
||||
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
|
||||
Category = "station",
|
||||
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
|
||||
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
|
||||
Position = anchor.Position,
|
||||
FactionId = site.FactionId,
|
||||
CelestialId = site.CelestialId,
|
||||
Health = 600f,
|
||||
MaxHealth = 600f,
|
||||
};
|
||||
|
||||
foreach (var moduleId in GetFoundationModules(world, site.BlueprintId))
|
||||
{
|
||||
AddStationModule(world, station, moduleId);
|
||||
}
|
||||
|
||||
world.Stations.Add(station);
|
||||
StationLifecycleService.EnsureStationCommander(world, station);
|
||||
anchor.OccupyingStructureId = station.Id;
|
||||
site.StationId = station.Id;
|
||||
PrepareNextConstructionSiteStep(world, station, site);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetFoundationModules(SimulationWorld world, string primaryModuleId)
|
||||
{
|
||||
var modules = new List<string> { "module_arg_dock_m_01_lowtech" };
|
||||
foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, []))
|
||||
{
|
||||
if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||
{
|
||||
var storageModule = GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind);
|
||||
if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add(storageModule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal))
|
||||
{
|
||||
modules.Add("module_arg_stor_container_m_01");
|
||||
}
|
||||
|
||||
if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
|
||||
{
|
||||
modules.Add("module_gen_prod_energycells_01");
|
||||
}
|
||||
|
||||
modules.Add(primaryModuleId);
|
||||
return modules.Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string DetermineFoundationObjective(string commodityId) =>
|
||||
commodityId switch
|
||||
{
|
||||
"energycells" => "power",
|
||||
"water" => "water",
|
||||
"refinedmetals" => "refinery",
|
||||
"hullparts" => "hullparts",
|
||||
"claytronics" => "claytronics",
|
||||
"shipyard" => "shipyard",
|
||||
_ => "general",
|
||||
};
|
||||
|
||||
private static string BuildFoundedStationLabel(string commodityId) =>
|
||||
$"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry";
|
||||
}
|
||||
319
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal file
319
apps/backend/Ships/AI/ShipAiService.Planning.Behaviors.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private ShipPlanRuntime BuildBehaviorFallbackPlan(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship);
|
||||
var failureReason = ship.LastAccessFailureReason;
|
||||
if (string.Equals(behaviorKind, Idle, StringComparison.Ordinal))
|
||||
{
|
||||
return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle");
|
||||
}
|
||||
|
||||
if (IsBehaviorBlockingFailure(behaviorKind, failureReason))
|
||||
{
|
||||
return CreateBlockedPlan(
|
||||
ship,
|
||||
AiPlanSourceKind.DefaultBehavior,
|
||||
sourceId,
|
||||
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason),
|
||||
failureReason!);
|
||||
}
|
||||
|
||||
return CreateIdlePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.DefaultBehavior,
|
||||
sourceId,
|
||||
DescribeBehaviorFallbackSummary(world, ship, behaviorKind, failureReason));
|
||||
}
|
||||
|
||||
private static bool IsBehaviorBlockingFailure(string behaviorKind, string? failureReason) => failureReason switch
|
||||
{
|
||||
"missing-item" => true,
|
||||
"no-suitable-buyer" => true,
|
||||
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static string DescribeBehaviorFallbackSummary(SimulationWorld world, ShipRuntime ship, string behaviorKind, string? failureReason)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId;
|
||||
var itemId = assignment?.ItemId ?? ship.DefaultBehavior.ItemId ?? "resource";
|
||||
|
||||
return failureReason switch
|
||||
{
|
||||
"missing-item" => "No mining ware configured",
|
||||
"no-suitable-buyer" => $"No buyer for {itemId} in {systemId}",
|
||||
"no-mineable-node" when string.Equals(behaviorKind, LocalAutoMine, StringComparison.Ordinal) => $"No {itemId} to mine in {systemId}",
|
||||
"no-mineable-node" => "No mineable node",
|
||||
"no-home-station" => "No home station",
|
||||
"no-trade-route" => "No trade route",
|
||||
"no-fleet-to-supply" => "No fleet to supply",
|
||||
"station-missing" => "No station to dock",
|
||||
"target-ship-missing" => "No ship to follow",
|
||||
"target-missing" => "No object target",
|
||||
"no-salvage-target" => "No salvage target",
|
||||
"no-repeat-orders" => "No repeat orders",
|
||||
"no-construction-site" => "No construction site",
|
||||
"support-station-missing" => "No support station",
|
||||
_ => "Idle",
|
||||
};
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.TradeRoute,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
]),
|
||||
CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: route.ItemId),
|
||||
CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
SupplyFleet,
|
||||
plan.Summary,
|
||||
[
|
||||
CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId),
|
||||
CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f),
|
||||
]),
|
||||
CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Name}",
|
||||
[
|
||||
CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Name}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f),
|
||||
CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary)
|
||||
{
|
||||
var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position;
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
"construction-support",
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}",
|
||||
[
|
||||
CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f),
|
||||
CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
|
||||
]),
|
||||
CreateStep("step-construction-build", "build-site", $"Build {site.Id}",
|
||||
[
|
||||
CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.AttackTarget,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-attack", ShipOrderKinds.AttackTarget, summary,
|
||||
[
|
||||
CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.DockAndWait,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}",
|
||||
[
|
||||
CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f),
|
||||
CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FlyAndWait,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-fly-wait", ShipOrderKinds.FlyAndWait, summary,
|
||||
[
|
||||
CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f),
|
||||
CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FlyToObject,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-fly-object", ShipOrderKinds.FlyToObject, summary,
|
||||
[
|
||||
CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f),
|
||||
CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary)
|
||||
{
|
||||
return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.FollowShip,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-follow", "follow-target", summary,
|
||||
[
|
||||
CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
Idle,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-idle", ShipOrderKinds.HoldPosition, summary,
|
||||
[
|
||||
CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime CreateBlockedPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary, string blockingReason)
|
||||
{
|
||||
var subTask = CreateSubTask("sub-blocked", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 0f);
|
||||
subTask.Status = WorkStatus.Blocked;
|
||||
subTask.BlockingReason = blockingReason;
|
||||
|
||||
var step = CreateStep("step-blocked", "blocked", summary, [subTask]);
|
||||
step.Status = AiPlanStepStatus.Blocked;
|
||||
step.BlockingReason = blockingReason;
|
||||
|
||||
var plan = CreatePlan(ship, sourceKind, sourceId, "blocked", summary, [step]);
|
||||
plan.Status = AiPlanStatus.Blocked;
|
||||
plan.FailureReason = blockingReason;
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static ShipPlanRuntime CreatePlan(
|
||||
ShipRuntime ship,
|
||||
AiPlanSourceKind sourceKind,
|
||||
string sourceId,
|
||||
string kind,
|
||||
string summary,
|
||||
IReadOnlyList<ShipPlanStepRuntime> steps)
|
||||
{
|
||||
var plan = new ShipPlanRuntime
|
||||
{
|
||||
Id = $"plan-{ship.Id}-{Guid.NewGuid():N}",
|
||||
SourceKind = sourceKind,
|
||||
SourceId = sourceId,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
};
|
||||
plan.Steps.AddRange(steps);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList<ShipSubTaskRuntime> subTasks)
|
||||
{
|
||||
var step = new ShipPlanStepRuntime
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
};
|
||||
step.SubTasks.AddRange(subTasks);
|
||||
return step;
|
||||
}
|
||||
|
||||
private static ShipSubTaskRuntime CreateSubTask(
|
||||
string id,
|
||||
string kind,
|
||||
string summary,
|
||||
string targetSystemId,
|
||||
Vector3 targetPosition,
|
||||
string? targetEntityId,
|
||||
float threshold,
|
||||
float amount,
|
||||
string? itemId = null,
|
||||
string? moduleId = null,
|
||||
string? targetNodeId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Summary = summary,
|
||||
TargetSystemId = targetSystemId,
|
||||
TargetPosition = targetPosition,
|
||||
TargetEntityId = targetEntityId,
|
||||
TargetNodeId = targetNodeId,
|
||||
ItemId = itemId,
|
||||
ModuleId = moduleId,
|
||||
Threshold = threshold,
|
||||
Amount = amount,
|
||||
};
|
||||
}
|
||||
461
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal file
461
apps/backend/Ships/AI/ShipAiService.Planning.Orders.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
|
||||
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var policy = ResolvePolicy(world, ship.PolicySetId);
|
||||
if (policy is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hullRatio = ship.Definition.Hull <= 0.01f ? 1f : ship.Health / ship.Definition.Hull;
|
||||
if (hullRatio > policy.FleeHullRatio)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hostileNearby = world.Ships.Any(candidate =>
|
||||
candidate.Health > 0f &&
|
||||
candidate.FactionId != ship.FactionId &&
|
||||
candidate.SystemId == ship.SystemId &&
|
||||
candidate.Position.DistanceTo(ship.Position) <= 200f);
|
||||
if (!hostileNearby)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var safeStation = world.Stations
|
||||
.Where(station => station.FactionId == ship.FactionId)
|
||||
.OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0)
|
||||
.ThenBy(station => station.Position.DistanceTo(ship.Position))
|
||||
.FirstOrDefault();
|
||||
|
||||
var plan = new ShipPlanRuntime
|
||||
{
|
||||
Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}",
|
||||
SourceKind = AiPlanSourceKind.Rule,
|
||||
SourceId = ShipOrderKinds.Flee,
|
||||
Kind = "safety-flee",
|
||||
Summary = "Emergency retreat",
|
||||
};
|
||||
|
||||
if (safeStation is null)
|
||||
{
|
||||
plan.Steps.Add(CreateStep("step-flee-hold", ShipOrderKinds.HoldPosition, "Hold position away from hostiles",
|
||||
[
|
||||
CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f)
|
||||
]));
|
||||
return plan;
|
||||
}
|
||||
|
||||
plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station",
|
||||
[
|
||||
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
|
||||
]));
|
||||
plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station",
|
||||
[
|
||||
CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f)
|
||||
]));
|
||||
return plan;
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return order.Kind switch
|
||||
{
|
||||
var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineLocal, StringComparison.Ordinal) => BuildMineLocalOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliverRun, StringComparison.Ordinal) => BuildMineAndDeliverRunOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SellMinedCargo, StringComparison.Ordinal) => BuildSellMinedCargoOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SupplyFleetRun, StringComparison.Ordinal) => BuildSupplyFleetOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.SalvageRun, StringComparison.Ordinal) => BuildAutoSalvageOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order),
|
||||
var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var assignment = ResolveAssignment(world, ship);
|
||||
return assignment is null
|
||||
? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind)
|
||||
: (assignment.BehaviorKind, assignment.ObjectiveId);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetSystemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
ShipOrderKinds.Move,
|
||||
order.Label ?? "Move order",
|
||||
[
|
||||
CreateStep("step-move", "travel", order.Label ?? "Travel",
|
||||
[
|
||||
CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId);
|
||||
if (station is null)
|
||||
{
|
||||
order.FailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
"dock-at-station",
|
||||
order.Label ?? $"Dock at {station.Label}",
|
||||
[
|
||||
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
|
||||
[
|
||||
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
|
||||
]),
|
||||
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
|
||||
[
|
||||
CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null)
|
||||
{
|
||||
order.FailureReason = "trade-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId);
|
||||
if (route is null)
|
||||
{
|
||||
order.FailureReason = "trade-route-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var systemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var itemId = order.ItemId;
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
order.FailureReason = "mine-order-item-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var node = ResolveNode(world, order.NodeId);
|
||||
if (node is not null)
|
||||
{
|
||||
if (!string.Equals(node.SystemId, systemId, StringComparison.Ordinal))
|
||||
{
|
||||
order.FailureReason = "mine-order-node-system-mismatch";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
order.FailureReason = "mine-order-node-item-mismatch";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
node = SelectLocalMiningNode(world, ship, systemId, itemId);
|
||||
}
|
||||
|
||||
if (node is null)
|
||||
{
|
||||
order.FailureReason = "mine-order-node-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {itemId} in {systemId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineLocalOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var node = ResolveNode(world, order.NodeId);
|
||||
if (node is null)
|
||||
{
|
||||
order.FailureReason = "mine-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, order.Label ?? $"Mine {node.ItemId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildMineAndDeliverRunOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var node = ResolveNode(world, order.NodeId);
|
||||
var buyer = ResolveStation(world, order.DestinationStationId);
|
||||
if (node is null || buyer is null)
|
||||
{
|
||||
order.FailureReason = "mine-and-deliver-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, buyer, order.Label ?? $"Mine {node.ItemId} for {buyer.Label}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildSellMinedCargoOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var buyer = ResolveStation(world, order.DestinationStationId ?? order.TargetEntityId);
|
||||
if (buyer is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||
{
|
||||
order.FailureReason = "sell-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildLocalMiningDeliveryPlan(ship, AiPlanSourceKind.Order, order.Id, buyer, order.ItemId, order.Label ?? $"Sell {order.ItemId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildAutoSalvageOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var homeStation = ResolveStation(world, order.SourceStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.RemainingAmount > 0.01f);
|
||||
if (homeStation is null || wreck is null)
|
||||
{
|
||||
order.FailureReason = "salvage-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var approach = GetFormationPosition(wreck.Position, ship.Id, MathF.Max(8f, order.Radius > 0f ? order.Radius : ship.DefaultBehavior.Radius * 0.25f));
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
AutoSalvage,
|
||||
order.Label ?? $"Salvage {wreck.ItemId}",
|
||||
[
|
||||
CreateStep("step-salvage-collect", "salvage", $"Salvage {wreck.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {wreck.Id}", wreck.SystemId, approach, wreck.Id, 8f, 0f),
|
||||
CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {wreck.ItemId}", wreck.SystemId, approach, wreck.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
]),
|
||||
CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity(), itemId: wreck.ItemId),
|
||||
CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildSupplyFleetOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var sourceStation = ResolveStation(world, order.SourceStationId);
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
if (sourceStation is null || targetShip is null || string.IsNullOrWhiteSpace(order.ItemId))
|
||||
{
|
||||
order.FailureReason = "supply-fleet-order-incomplete";
|
||||
return null;
|
||||
}
|
||||
|
||||
var amount = MathF.Min(
|
||||
MathF.Max(10f, ship.Definition.GetTotalCargoCapacity() * 0.5f),
|
||||
GetInventoryAmount(sourceStation.Inventory, order.ItemId));
|
||||
if (amount <= 0.01f)
|
||||
{
|
||||
order.FailureReason = "supply-item-unavailable";
|
||||
return null;
|
||||
}
|
||||
|
||||
var plan = new FleetSupplyPlan(
|
||||
sourceStation,
|
||||
targetShip,
|
||||
order.ItemId,
|
||||
amount,
|
||||
MathF.Max(16f, order.Radius),
|
||||
order.Label ?? $"Supply {targetShip.Definition.Name} with {order.ItemId}");
|
||||
return BuildFleetSupplyPlan(ship, AiPlanSourceKind.Order, order.Id, plan);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId));
|
||||
if (site is null)
|
||||
{
|
||||
order.FailureReason = "construction-site-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var supportStation = ResolveSupportStation(world, ship, site);
|
||||
if (supportStation is null)
|
||||
{
|
||||
order.FailureReason = "support-station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetId = order.TargetEntityId;
|
||||
if (targetId is null)
|
||||
{
|
||||
order.FailureReason = "attack-target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
return CreatePlan(
|
||||
ship,
|
||||
AiPlanSourceKind.Order,
|
||||
order.Id,
|
||||
ShipOrderKinds.HoldPosition,
|
||||
order.Label ?? "Hold position",
|
||||
[
|
||||
CreateStep("step-hold", ShipOrderKinds.HoldPosition, order.Label ?? "Hold position",
|
||||
[
|
||||
CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId);
|
||||
if (station is null)
|
||||
{
|
||||
order.FailureReason = "station-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var systemId = order.TargetSystemId ?? ship.SystemId;
|
||||
var targetPosition = order.TargetPosition ?? ship.Position;
|
||||
return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetEntityId = order.TargetEntityId;
|
||||
if (targetEntityId is null)
|
||||
{
|
||||
order.FailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var objectTarget = ResolveObjectTarget(world, targetEntityId);
|
||||
if (objectTarget is null)
|
||||
{
|
||||
order.FailureReason = "target-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order)
|
||||
{
|
||||
var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f);
|
||||
if (targetShip is null)
|
||||
{
|
||||
order.FailureReason = "target-ship-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Name}");
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary)
|
||||
{
|
||||
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.MineAndDeliver,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity())
|
||||
]),
|
||||
CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}",
|
||||
[
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.GetTotalCargoCapacity()),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildLocalMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, string summary)
|
||||
{
|
||||
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.MineLocal,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-mine", "mine", $"Mine {node.ItemId}",
|
||||
[
|
||||
CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f),
|
||||
CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.GetTotalCargoCapacity(), itemId: node.ItemId)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private ShipPlanRuntime BuildLocalMiningDeliveryPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime buyer, string itemId, string summary)
|
||||
{
|
||||
var amount = MathF.Max(1f, GetInventoryAmount(ship.Inventory, itemId));
|
||||
return CreatePlan(
|
||||
ship,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
ShipOrderKinds.SellMinedCargo,
|
||||
summary,
|
||||
[
|
||||
CreateStep("step-deliver", "deliver", $"Deliver {itemId} to {buyer.Label}",
|
||||
[
|
||||
CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(buyer.Radius + 12f, 12f), 0f),
|
||||
CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 4f, 0f),
|
||||
CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload {itemId} at {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, 0f, amount, itemId: itemId),
|
||||
CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {buyer.Label}", buyer.SystemId, buyer.Position, buyer.Id, MathF.Max(4f, 12f), 0f)
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
216
apps/backend/Ships/AI/ShipAiService.cs
Normal file
216
apps/backend/Ships/AI/ShipAiService.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
public sealed partial class ShipAiService
|
||||
{
|
||||
private const float WarpEngageDistanceKilometers = 250_000f;
|
||||
private const float FrigateDps = 7f;
|
||||
private const float DestroyerDps = 12f;
|
||||
private const float CruiserDps = 18f;
|
||||
private const float CapitalDps = 26f;
|
||||
|
||||
private readonly IBalanceService balance;
|
||||
|
||||
public ShipAiService(IBalanceService balance)
|
||||
{
|
||||
this.balance = balance;
|
||||
}
|
||||
|
||||
internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (ship.ReplanCooldownSeconds > 0f)
|
||||
{
|
||||
ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds);
|
||||
}
|
||||
|
||||
var previousState = ship.State;
|
||||
var previousPlanId = ship.ActivePlan?.Id;
|
||||
var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id;
|
||||
|
||||
EnsurePlan(world, ship, events);
|
||||
ExecutePlan(world, ship, deltaSeconds, events);
|
||||
TrackHistory(ship);
|
||||
EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events);
|
||||
}
|
||||
|
||||
private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var emergencyPlan = BuildEmergencyPlan(world, ship);
|
||||
if (emergencyPlan is not null)
|
||||
{
|
||||
ship.LastReplanReason = "rule-safety";
|
||||
ReplacePlan(ship, emergencyPlan, "rule-safety", events);
|
||||
return;
|
||||
}
|
||||
|
||||
SyncBehaviorOrders(world, ship);
|
||||
var topOrder = GetTopOrder(ship);
|
||||
if (topOrder is not null && topOrder.Status == OrderStatus.Queued)
|
||||
{
|
||||
topOrder.Status = OrderStatus.Active;
|
||||
}
|
||||
|
||||
var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order;
|
||||
var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId;
|
||||
var currentPlan = ship.ActivePlan;
|
||||
|
||||
if (currentPlan is not null
|
||||
&& currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted
|
||||
&& currentPlan.SourceKind == desiredSourceKind
|
||||
&& string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal)
|
||||
&& !ship.NeedsReplan)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.ReplanCooldownSeconds > 0f && currentPlan is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order
|
||||
? BuildOrderPlan(world, ship, topOrder!)
|
||||
: BuildBehaviorFallbackPlan(world, ship);
|
||||
|
||||
if (nextPlan is null)
|
||||
{
|
||||
nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan");
|
||||
}
|
||||
|
||||
if (nextPlan.Kind != Idle)
|
||||
{
|
||||
ship.LastAccessFailureReason = null;
|
||||
}
|
||||
|
||||
ReplacePlan(ship, nextPlan, "replanned", events);
|
||||
}
|
||||
|
||||
private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var plan = ship.ActivePlan;
|
||||
if (plan is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
||||
{
|
||||
CompletePlan(ship, plan, events);
|
||||
return;
|
||||
}
|
||||
|
||||
plan.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
var step = plan.Steps[plan.CurrentStepIndex];
|
||||
if (step.Status == AiPlanStepStatus.Planned)
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Running;
|
||||
}
|
||||
|
||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
||||
{
|
||||
CompleteStep(plan, step);
|
||||
return;
|
||||
}
|
||||
|
||||
var subTask = step.SubTasks[step.CurrentSubTaskIndex];
|
||||
if (subTask.Status == WorkStatus.Pending)
|
||||
{
|
||||
subTask.Status = WorkStatus.Active;
|
||||
}
|
||||
else if (subTask.Status == WorkStatus.Blocked)
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Blocked;
|
||||
step.BlockingReason = subTask.BlockingReason;
|
||||
plan.Status = AiPlanStatus.Blocked;
|
||||
ship.State = ShipState.Blocked;
|
||||
ship.TargetPosition = subTask.TargetPosition ?? ship.Position;
|
||||
return;
|
||||
}
|
||||
|
||||
plan.Status = AiPlanStatus.Running;
|
||||
|
||||
var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds);
|
||||
switch (outcome)
|
||||
{
|
||||
case SubTaskOutcome.Active:
|
||||
step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running;
|
||||
plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running;
|
||||
return;
|
||||
case SubTaskOutcome.Completed:
|
||||
subTask.Status = WorkStatus.Completed;
|
||||
subTask.Progress = 1f;
|
||||
step.CurrentSubTaskIndex += 1;
|
||||
step.BlockingReason = null;
|
||||
if (step.CurrentSubTaskIndex >= step.SubTasks.Count)
|
||||
{
|
||||
CompleteStep(plan, step);
|
||||
}
|
||||
|
||||
return;
|
||||
case SubTaskOutcome.Failed:
|
||||
subTask.Status = WorkStatus.Failed;
|
||||
step.Status = AiPlanStepStatus.Failed;
|
||||
plan.Status = AiPlanStatus.Failed;
|
||||
plan.FailureReason = subTask.BlockingReason ?? "subtask-failed";
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.5f;
|
||||
ship.LastReplanReason = plan.FailureReason;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step)
|
||||
{
|
||||
step.Status = AiPlanStepStatus.Completed;
|
||||
step.BlockingReason = null;
|
||||
plan.CurrentStepIndex += 1;
|
||||
if (plan.CurrentStepIndex >= plan.Steps.Count)
|
||||
{
|
||||
plan.Status = AiPlanStatus.Completed;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
plan.Status = AiPlanStatus.Completed;
|
||||
var completedOrder = plan.SourceKind == AiPlanSourceKind.Order
|
||||
? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId)
|
||||
: null;
|
||||
if (completedOrder is not null)
|
||||
{
|
||||
completedOrder.Status = OrderStatus.Completed;
|
||||
ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id);
|
||||
if (completedOrder.SourceKind == ShipOrderSourceKind.Behavior
|
||||
&& string.Equals(completedOrder.SourceId, RepeatOrders, StringComparison.Ordinal)
|
||||
&& ship.DefaultBehavior.RepeatOrders.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count;
|
||||
}
|
||||
}
|
||||
ship.ActivePlan = null;
|
||||
ship.NeedsReplan = true;
|
||||
ship.ReplanCooldownSeconds = 0.25f;
|
||||
ship.LastReplanReason = "plan-completed";
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Name} completed {plan.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed)
|
||||
{
|
||||
ship.ActivePlan.Status = AiPlanStatus.Interrupted;
|
||||
ship.ActivePlan.InterruptReason = reason;
|
||||
}
|
||||
|
||||
ship.ActivePlan = nextPlan;
|
||||
ship.NeedsReplan = false;
|
||||
ship.ReplanCooldownSeconds = 0f;
|
||||
ship.LastReplanReason = reason;
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Name} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
31
apps/backend/Ships/AI/ShipBootstrapPolicy.cs
Normal file
31
apps/backend/Ships/AI/ShipBootstrapPolicy.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
||||
|
||||
namespace SpaceGame.Api.Ships.AI;
|
||||
|
||||
internal static class ShipBootstrapPolicy
|
||||
{
|
||||
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
|
||||
{
|
||||
if (IsTransportShip(definition))
|
||||
{
|
||||
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 };
|
||||
}
|
||||
|
||||
if (IsConstructionShip(definition))
|
||||
{
|
||||
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 };
|
||||
}
|
||||
|
||||
if (IsMilitaryShip(definition))
|
||||
{
|
||||
return new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 };
|
||||
}
|
||||
|
||||
if (IsMiningShip(definition))
|
||||
{
|
||||
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 };
|
||||
}
|
||||
|
||||
return new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user