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 static readonly GoapPlanner _factionPlanner = new(s => s.Clone()); 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; var state = BuildFactionPlanningState(world, commander.FactionId); var actions = BuildFactionActions(world); // Clear stale directives — actions will re-assert what is still needed. commander.ActiveDirectives.Clear(); var rankedGoals = _factionGoals .Select(g => (goal: g, priority: g.ComputePriority(state, world, commander))) .Where(x => x.priority > 0f) .OrderByDescending(x => x.priority) .ToList(); commander.LastPlanningState = state; commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList(); // Execute the first action of each active goal's plan (top 3 to avoid conflicts). foreach (var (goal, _) in rankedGoals.Take(3)) { var plan = _factionPlanner.Plan(state, goal, actions); plan?.CurrentAction?.Execute(engine, world, commander); } if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null) { if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal))) { TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId)); } else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal))) { TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water")); } } } 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, RefinedMetalsStockpile = refinedMetals.OnHand, RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond, RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds, HullpartsStockpile = hullparts.OnHand, HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond, HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds, ClaytronicsStockpile = claytronics.OnHand, ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond, ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds, WaterStockpile = water.OnHand, WaterProductionRate = water.ProjectedProductionRatePerSecond, WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds, }; } private static ShipPlanningState BuildShipPlanningState( SimulationWorld world, ShipRuntime ship, CommanderRuntime commander) { var factionCommander = world.Commanders.FirstOrDefault(c => c.FactionId == commander.FactionId && string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); var enemyTarget = SelectEnemyTarget(world, ship); var tradeRoute = SelectTradeRoute(world, ship.FactionId); var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId); if (commander.ActiveBehavior is not null) { commander.ActiveBehavior.AreaSystemId = 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) && expansionProject is not null) { commander.ActiveBehavior.StationId = expansionProject.SupportStationId; commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId; commander.ActiveBehavior.ModuleId = expansionProject.ModuleId; commander.ActiveBehavior.AreaSystemId = 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 = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false, FactionWantsExpansion = factionCommander?.ActiveDirectives .Contains("expand-territory", StringComparer.Ordinal) ?? false, FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false) ? false : !world.Stations.Any(station => string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal) && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), TargetEnemySystemId = enemyTarget?.SystemId, TargetEnemyEntityId = enemyTarget?.EntityId, TradeItemId = tradeRoute?.ItemId, TradeSourceStationId = tradeRoute?.SourceStationId, TradeDestinationStationId = tradeRoute?.DestinationStationId, }; } private static IReadOnlyList> BuildFactionActions(SimulationWorld world) { var actions = new List>(); actions.Add(new PlanWarIndustryAction()); actions.Add(new PlanCommoditySupplyAction("water")); foreach (var (shipId, def) in world.ShipDefinitions) { actions.Add(new OrderShipProductionAction(def.Kind, shipId)); } actions.Add(new LaunchExterminationCampaignAction()); actions.Add(new ExpandToSystemAction()); return actions; } internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) => world.Commanders.FirstOrDefault(c => c.FactionId == factionId && string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)) ?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false; private static void TryQueueFactionExpansionProject( SimulationWorld world, CommanderRuntime commander, IndustryExpansionProject? project) { if (project is null) { return; } FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); } private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject( SimulationWorld world, FactionPlanningState state, string factionId) { if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f) { return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals"); } if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f) { return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts"); } if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f) { return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics"); } if (!state.HasShipFactory) { return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId); } return null; } 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; } }