Refactor runtime bootstrap and ship control flows
This commit is contained in:
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";
|
||||
}
|
||||
Reference in New Issue
Block a user