Files
space-game/apps/backend/Ships/AI/ShipAiService.Helpers.cs

1153 lines
50 KiB
C#

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.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is not null)
{
return subTask.TargetPosition ?? Vector3.Zero;
}
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?.AnchorId is not null)
{
return ResolveAnchorBackedCelestial(world, station.AnchorId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.AnchorId is not null)
{
return ResolveAnchorBackedCelestial(world, site.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } anchorBackedCelestialTarget)
{
return ResolveAnchorBackedCelestial(world, anchorBackedCelestialTarget.Id);
}
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 AnchorRuntime? ResolveTravelTargetAnchor(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(subTask.TargetAnchorId) && ResolveAnchor(world, subTask.TargetAnchorId) is { } explicitTargetAnchor)
{
return explicitTargetAnchor;
}
if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId))
{
var station = ResolveStation(world, subTask.TargetEntityId);
if (station?.AnchorId is not null)
{
return ResolveAnchor(world, station.AnchorId);
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId);
if (site?.AnchorId is not null)
{
return ResolveAnchor(world, site.AnchorId);
}
var node = ResolveNode(world, subTask.TargetEntityId);
if (node is not null)
{
return ResolveAnchor(world, node.AnchorId);
}
if (ResolveAnchor(world, subTask.TargetEntityId) is { } directAnchor)
{
return directAnchor;
}
if (world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } celestial)
{
return ResolveAnchor(world, celestial.Id);
}
if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck)
{
return world.Anchors
.Where(candidate => candidate.SystemId == wreck.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position))
.FirstOrDefault();
}
}
return world.Anchors
.Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static AnchorRuntime? ResolveCurrentAnchor(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchor(world, ship.SpatialState.CurrentAnchorId) is { } explicitAnchor)
{
return explicitAnchor;
}
if (ship.DockedStationId is not null && ResolveStation(world, ship.DockedStationId)?.AnchorId is { } dockAnchorId)
{
return ResolveAnchor(world, dockAnchorId);
}
return world.Anchors
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentAnchorId is not null && ResolveAnchorBackedCelestial(world, ship.SpatialState.CurrentAnchorId) is { } currentAnchorCelestial)
{
return currentAnchorCelestial;
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ResolveShipSystemPosition(world, ship)))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star);
private static AnchorRuntime? ResolveSystemEntryAnchor(SimulationWorld world, string systemId) =>
world.Anchors.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 Vector3 ResolveAnchorPosition(SimulationWorld world, string? anchorId, Vector3 fallbackPosition) =>
ResolveAnchor(world, anchorId)?.Position ?? fallbackPosition;
private static Vector3 ResolveStationSystemPosition(SimulationWorld world, StationRuntime station)
{
if (station.AnchorId is not null && ResolveAnchor(world, station.AnchorId) is { } anchor)
{
return new Vector3(
anchor.Position.X + station.Position.X,
anchor.Position.Y + station.Position.Y,
anchor.Position.Z + station.Position.Z);
}
return station.Position;
}
private static Vector3 ResolveNodeSystemPosition(SimulationWorld world, ResourceNodeRuntime node)
{
if (ResolveAnchor(world, node.AnchorId) is { } anchor)
{
return new Vector3(
anchor.Position.X + node.Position.X,
anchor.Position.Y + node.Position.Y,
anchor.Position.Z + node.Position.Z);
}
return node.Position;
}
private static Vector3 ResolveShipSystemPosition(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.SystemPosition is { } systemPosition)
{
return systemPosition;
}
if (ResolveCurrentAnchor(world, ship) is { } anchor)
{
return new Vector3(
anchor.Position.X + ship.Position.X,
anchor.Position.Y + ship.Position.Y,
anchor.Position.Z + ship.Position.Z);
}
return ship.Position;
}
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 preferredAnchorId = ship.DefaultBehavior.PreferredAnchorId;
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 (preferredAnchorId is not null && !string.Equals(node.AnchorId, preferredAnchorId, 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
- ResolveNodeSystemPosition(world, node).DistanceTo(ResolveShipSystemPosition(world, ship));
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, string? anchorId = null)
{
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 (anchorId is not null && !string.Equals(candidate.AnchorId, anchorId, StringComparison.Ordinal))
{
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 ResourceDepositRuntime? ResolveResourceDeposit(SimulationWorld world, string? depositId)
{
if (string.IsNullOrWhiteSpace(depositId))
{
return null;
}
foreach (var node in world.Nodes)
{
var deposit = node.Deposits.FirstOrDefault(candidate => string.Equals(candidate.Id, depositId, StringComparison.Ordinal));
if (deposit is not null)
{
return deposit;
}
}
return null;
}
private static ResourceDepositRuntime? SelectMiningDeposit(ResourceNodeRuntime node, string shipId)
{
return node.Deposits
.Where(candidate => candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static void SyncNodeOreTotals(ResourceNodeRuntime node)
{
node.OreRemaining = node.Deposits.Sum(candidate => candidate.OreRemaining);
}
private static AnchorRuntime? ResolveMiningAnchor(SimulationWorld world, string? anchorId, string? nodeId)
{
if (anchorId is not null)
{
return ResolveAnchor(world, anchorId);
}
if (nodeId is not null && ResolveNode(world, nodeId) is { } node)
{
return ResolveAnchor(world, node.AnchorId);
}
return null;
}
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 (ResolveAnchor(world, entityId) is { } anchor)
{
return (anchor.SystemId, anchor.Position);
}
if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site)
{
var position = ResolveAnchor(world, site.AnchorId)?.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 AnchorRuntime? ResolveAnchor(SimulationWorld world, string? anchorId) =>
anchorId is null ? null : world.Anchors.FirstOrDefault(candidate => candidate.Id == anchorId);
private static CelestialRuntime? ResolveAnchorBackedCelestial(SimulationWorld world, string? anchorId)
{
var anchor = ResolveAnchor(world, anchorId);
var celestialId = SpatialBuilder.ResolveCompatibleCelestialId(anchor);
return celestialId is null ? null : world.Celestials.FirstOrDefault(candidate => candidate.Id == celestialId);
}
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 = ResolveAnchor(world, site.AnchorId)?.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 = ResolveAnchor(world, site.AnchorId);
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,
AnchorId = site.AnchorId,
Label = BuildFoundedStationLabel(site.TargetDefinitionId),
Category = "station",
Objective = DetermineFoundationObjective(site.TargetDefinitionId),
Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color,
Position = Vector3.Zero,
FactionId = site.FactionId,
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";
}