using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Factions.AI; internal sealed class CommanderPlanningService { private const float FactionCommanderReplanInterval = 10f; private const float ShipCommanderReplanInterval = 5f; private readonly FactionObjectivePlanner _objectivePlanner = new(); private readonly FactionObjectiveExecutor _objectiveExecutor = new(); private static readonly GoapPlanner _shipPlanner = new(s => s.Clone()); private static readonly IReadOnlyList> _factionGoals = [ new ExterminateRivalGoal(), new EnsureWarIndustryGoal(), new ExpandTerritoryGoal(), new EnsureWarFleetGoal(), new EnsureWaterSecurityGoal(), new EnsureMiningCapacityGoal(), new EnsureConstructionCapacityGoal(), ]; private static readonly IReadOnlyList> _shipActions = [ new SetAttackObjectiveAction(), new SetMiningObjectiveAction(), new SetPatrolObjectiveAction(), new SetConstructionObjectiveAction(), new SetTradeObjectiveAction(), new SetIdleObjectiveAction(), ]; private static readonly GoapGoal _shipGoal = new AssignObjectiveGoal(); internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection events) { // Faction commanders run first so their directives are available to ship commanders in the same tick. foreach (var commander in world.Commanders) { if (!commander.IsAlive || commander.Kind != CommanderKind.Faction) { continue; } TickCommander(commander, deltaSeconds); UpdateFactionCommander(engine, world, commander); } foreach (var commander in world.Commanders) { if (!commander.IsAlive || commander.Kind != CommanderKind.Ship) { continue; } TickCommander(commander, deltaSeconds); UpdateShipCommander(engine, world, commander); } } private static void TickCommander(CommanderRuntime commander, float deltaSeconds) { if (commander.ReplanTimer > 0f) { commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds); } } private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) { if (commander.ReplanTimer > 0f && !commander.NeedsReplan) { return; } commander.ReplanTimer = FactionCommanderReplanInterval; commander.NeedsReplan = false; commander.PlanningCycle += 1; var state = BuildFactionPlanningState(world, commander.FactionId); var rankedGoals = _factionGoals .Select(g => (goal: g, priority: g.ComputePriority(state, world, commander))) .Where(x => x.priority > 0f) .OrderByDescending(x => x.priority) .ToList(); commander.LastStrategicAssessment = state; commander.LastStrategicPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList(); _objectivePlanner.UpdateBlackboard(world, commander, state); _objectivePlanner.RefreshObjectives( world, commander, state, rankedGoals.Select(entry => (entry.goal.Name, entry.priority)).ToList()); _objectiveExecutor.Execute(engine, world, commander, state); } private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) { if (commander.ReplanTimer > 0f && !commander.NeedsReplan) { return; } commander.ReplanTimer = ShipCommanderReplanInterval; commander.NeedsReplan = false; var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); if (ship is null) { return; } var state = BuildShipPlanningState(world, ship, commander); var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions); if (plan?.CurrentAction is { } action) { commander.ActiveGoalName = _shipGoal.Name; commander.ActiveActionName = action.Name; action.Execute(engine, world, commander); } } internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId) { var stations = world.Stations.Where(s => s.FactionId == factionId).ToList(); var economy = FactionEconomyAnalyzer.Build(world, factionId); var refinedMetals = economy.GetCommodity("refinedmetals"); var hullparts = economy.GetCommodity("hullparts"); var claytronics = economy.GetCommodity("claytronics"); var water = economy.GetCommodity("water"); return new FactionPlanningState { EnemyFactionCount = world.Factions.Count(f => f.Id != factionId), EnemyShipCount = world.Ships.Count(s => s.Health > 0f && !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), EnemyStationCount = world.Stations.Count(s => !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), MilitaryShipCount = world.Ships.Count(s => s.FactionId == factionId && string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)), MinerShipCount = world.Ships.Count(s => s.FactionId == factionId && string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)), TransportShipCount = world.Ships.Count(s => s.FactionId == factionId && string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)), ConstructorShipCount = world.Ships.Count(s => s.FactionId == factionId && string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)), ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId), TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)), HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), OreStockpile = economy.GetCommodity("ore").OnHand, RefinedMetalsAvailableStock = refinedMetals.AvailableStock, RefinedMetalsUsageRate = refinedMetals.OperationalUsageRatePerSecond, RefinedMetalsProjectedProductionRate = refinedMetals.ProjectedProductionRatePerSecond, RefinedMetalsProjectedNetRate = refinedMetals.ProjectedNetRatePerSecond, RefinedMetalsLevelSeconds = refinedMetals.LevelSeconds, RefinedMetalsLevel = refinedMetals.Level.ToString().ToLowerInvariant(), HullpartsAvailableStock = hullparts.AvailableStock, HullpartsUsageRate = hullparts.OperationalUsageRatePerSecond, HullpartsProjectedProductionRate = hullparts.ProjectedProductionRatePerSecond, HullpartsProjectedNetRate = hullparts.ProjectedNetRatePerSecond, HullpartsLevelSeconds = hullparts.LevelSeconds, HullpartsLevel = hullparts.Level.ToString().ToLowerInvariant(), ClaytronicsAvailableStock = claytronics.AvailableStock, ClaytronicsUsageRate = claytronics.OperationalUsageRatePerSecond, ClaytronicsProjectedProductionRate = claytronics.ProjectedProductionRatePerSecond, ClaytronicsProjectedNetRate = claytronics.ProjectedNetRatePerSecond, ClaytronicsLevelSeconds = claytronics.LevelSeconds, ClaytronicsLevel = claytronics.Level.ToString().ToLowerInvariant(), WaterAvailableStock = water.AvailableStock, WaterUsageRate = water.OperationalUsageRatePerSecond, WaterProjectedProductionRate = water.ProjectedProductionRatePerSecond, WaterProjectedNetRate = water.ProjectedNetRatePerSecond, WaterLevelSeconds = water.LevelSeconds, WaterLevel = water.Level.ToString().ToLowerInvariant(), }; } private static ShipPlanningState BuildShipPlanningState( SimulationWorld world, ShipRuntime ship, CommanderRuntime commander) { var factionCommander = FindFactionCommander(world, commander.FactionId); var enemyTarget = SelectEnemyTarget(world, ship); var tradeRoute = SelectTradeRoute(world, ship.FactionId); var expansionTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ExpandIndustry); var attackTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.AttackFactionAssets); var shipyardExpansionTask = factionCommander?.IssuedTasks .Where(task => task.Kind == FactionIssuedTaskKind.ExpandIndustry && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active && string.Equals(task.ModuleId, "module_gen_build_l_01", StringComparison.Ordinal)) .OrderByDescending(task => task.Priority) .FirstOrDefault(); var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId); if (commander.ActiveBehavior is not null) { commander.ActiveBehavior.AreaSystemId = attackTask?.TargetSystemId ?? expansionTask?.TargetSystemId ?? enemyTarget?.SystemId; commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId; if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) { commander.ActiveBehavior.ItemId = tradeRoute?.ItemId; commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId; commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId; } else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && (expansionTask is not null || expansionProject is not null)) { commander.ActiveBehavior.StationId = expansionProject?.SupportStationId; commander.ActiveBehavior.TargetEntityId = expansionTask?.TargetSiteId ?? expansionProject?.SiteId; commander.ActiveBehavior.ModuleId = expansionTask?.ModuleId ?? expansionProject?.ModuleId; commander.ActiveBehavior.AreaSystemId = expansionTask?.TargetSystemId ?? expansionProject?.SystemId; } else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) { commander.ActiveBehavior.TargetEntityId = null; commander.ActiveBehavior.ModuleId = null; commander.ActiveBehavior.AreaSystemId = ship.SystemId; } } return new ShipPlanningState { ShipKind = ship.Definition.Kind, HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"), FactionWantsOre = true, FactionWantsCombat = attackTask is not null, FactionWantsExpansion = expansionTask is not null, FactionNeedsShipyard = shipyardExpansionTask is not null && !world.Stations.Any(station => string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal) && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), TargetEnemySystemId = attackTask?.TargetSystemId ?? enemyTarget?.SystemId, TargetEnemyEntityId = enemyTarget?.EntityId, TradeItemId = tradeRoute?.ItemId, TradeSourceStationId = tradeRoute?.SourceStationId, TradeDestinationStationId = tradeRoute?.DestinationStationId, }; } internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => world.Commanders.FirstOrDefault(c => c.FactionId == factionId && string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); internal static bool FactionCommanderHasIssuedTask( SimulationWorld world, string factionId, FactionIssuedTaskKind kind, string? shipRole = null) => FindFactionCommander(world, factionId)? .IssuedTasks.Any(task => task.Kind == kind && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked && (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) ?? false; internal static FactionIssuedTaskRuntime? GetHighestPriorityIssuedTask( CommanderRuntime? factionCommander, FactionIssuedTaskKind kind, string? shipRole = null) => factionCommander?.IssuedTasks .Where(task => task.Kind == kind && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked && (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) .OrderByDescending(task => task.Priority) .FirstOrDefault(); private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship) { var hostileShip = world.Ships .Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) .Select(candidate => (candidate.Id, candidate.SystemId)) .FirstOrDefault(); if (hostileShip != default) { return hostileShip; } var hostileStation = world.Stations .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) .Select(candidate => (candidate.Id, candidate.SystemId)) .FirstOrDefault(); return hostileStation == default ? null : hostileStation; } private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId) { var stationsById = world.Stations .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) .ToDictionary(station => station.Id, StringComparer.Ordinal); foreach (var demand in world.MarketOrders .Where(order => string.Equals(order.FactionId, factionId, StringComparison.Ordinal) && order.Kind == MarketOrderKinds.Buy && order.RemainingAmount > 0.01f && order.StationId is not null) .OrderByDescending(order => order.Valuation)) { if (!stationsById.TryGetValue(demand.StationId!, out var destination)) { continue; } if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId)) { continue; } var source = stationsById.Values .Where(station => station.Id != destination.Id && GetInventoryAmount(station.Inventory, demand.ItemId) > 1f) .OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId)) .FirstOrDefault(); if (source is not null) { return (demand.ItemId, source.Id, destination.Id); } } return null; } private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId) { if (!world.ItemDefinitions.TryGetValue(itemId, out var definition)) { return false; } var requiredModule = GetStorageRequirement(definition.CargoKind); if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) { return false; } var capacity = GetStationStorageCapacity(station, definition.CargoKind); if (capacity <= 0.01f) { return false; } var used = station.Inventory .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal)) .Sum(entry => entry.Value); return used <= capacity - 1f; } }