1153 lines
50 KiB
C#
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";
|
|
}
|