namespace SpaceGame.Api.Factions.AI; internal sealed class FactionObjectivePlanner { private readonly ObjectiveDependencyResolver _dependencyResolver = new(); private readonly ObjectiveStepFactory _stepFactory = new(); internal FactionBlackboardRuntime UpdateBlackboard( SimulationWorld world, CommanderRuntime commander, FactionPlanningState state) { var blackboard = commander.FactionBlackboard ?? new FactionBlackboardRuntime(); var economy = FactionEconomyAnalyzer.Build(world, commander.FactionId); var activeProject = FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId); blackboard.PlanCycle = commander.PlanningCycle; blackboard.UpdatedAtUtc = world.GeneratedAtUtc; blackboard.TargetWarshipCount = FactionPlanningState.ComputeTargetWarships(state); blackboard.HasWarIndustrySupplyChain = state.HasWarIndustrySupplyChain; blackboard.HasShipyard = state.HasShipFactory; blackboard.HasActiveExpansionProject = activeProject is not null; blackboard.ActiveExpansionCommodityId = activeProject?.CommodityId; blackboard.ActiveExpansionModuleId = activeProject?.ModuleId; blackboard.ActiveExpansionSiteId = activeProject?.SiteId; blackboard.ActiveExpansionSystemId = activeProject?.SystemId; blackboard.EnemyFactionCount = state.EnemyFactionCount; blackboard.EnemyShipCount = state.EnemyShipCount; blackboard.EnemyStationCount = state.EnemyStationCount; blackboard.MilitaryShipCount = state.MilitaryShipCount; blackboard.MinerShipCount = state.MinerShipCount; blackboard.TransportShipCount = state.TransportShipCount; blackboard.ConstructorShipCount = state.ConstructorShipCount; blackboard.ControlledSystemCount = state.ControlledSystemCount; blackboard.AvailableShipIds.Clear(); foreach (var ship in world.Ships.Where(ship => ship.Health > 0f && string.Equals(ship.FactionId, commander.FactionId, StringComparison.Ordinal))) { blackboard.AvailableShipIds.Add(ship.Id); } blackboard.CommoditySignals.Clear(); foreach (var commodityId in EnumerateTrackedCommodityIds()) { var commodity = economy.GetCommodity(commodityId); blackboard.CommoditySignals.Add(new FactionCommoditySignalRuntime { ItemId = commodityId, AvailableStock = commodity.AvailableStock, OnHand = commodity.OnHand, ProductionRatePerSecond = commodity.ProductionRatePerSecond, CommittedProductionRatePerSecond = commodity.CommittedProductionRatePerSecond, UsageRatePerSecond = commodity.OperationalUsageRatePerSecond, NetRatePerSecond = commodity.NetRatePerSecond, ProjectedNetRatePerSecond = commodity.ProjectedNetRatePerSecond, LevelSeconds = commodity.LevelSeconds, Level = commodity.Level.ToString().ToLowerInvariant(), ProjectedProductionRatePerSecond = commodity.ProjectedProductionRatePerSecond, BuyBacklog = commodity.BuyBacklog, ReservedForConstruction = commodity.ReservedForConstruction, }); } blackboard.ThreatSignals.Clear(); foreach (var threat in world.Systems .Select(system => new FactionThreatSignalRuntime { ScopeId = system.Definition.Id, ScopeKind = "system", EnemyShipCount = world.Ships.Count(ship => ship.Health > 0f && string.Equals(ship.SystemId, system.Definition.Id, StringComparison.Ordinal) && !string.Equals(ship.FactionId, commander.FactionId, StringComparison.Ordinal)), EnemyStationCount = world.Stations.Count(station => string.Equals(station.SystemId, system.Definition.Id, StringComparison.Ordinal) && !string.Equals(station.FactionId, commander.FactionId, StringComparison.Ordinal)), }) .Where(threat => threat.EnemyShipCount > 0 || threat.EnemyStationCount > 0)) { blackboard.ThreatSignals.Add(threat); } commander.FactionBlackboard = blackboard; return blackboard; } internal void RefreshObjectives( SimulationWorld world, CommanderRuntime commander, FactionPlanningState state, IReadOnlyList<(string GoalName, float Priority)> rankedGoals) { var blackboard = commander.FactionBlackboard ?? throw new InvalidOperationException("Faction blackboard must exist before objectives are refreshed."); var objectiveIndex = commander.Objectives.ToDictionary(objective => objective.MergeKey, StringComparer.Ordinal); var touchedObjectiveIds = new HashSet(StringComparer.Ordinal); foreach (var enemyFaction in world.Factions.Where(faction => !string.Equals(faction.Id, commander.FactionId, StringComparison.Ordinal))) { var priority = rankedGoals.FirstOrDefault(goal => string.Equals(goal.GoalName, "exterminate-rival", StringComparison.Ordinal)).Priority; var objective = GetOrCreateObjective( commander, objectiveIndex, touchedObjectiveIds, mergeKey: $"destroy-faction:{enemyFaction.Id}", kind: FactionObjectiveKind.DestroyFaction, priority: MathF.Max(priority, 1f), configure: current => { current.TargetFactionId = enemyFaction.Id; current.State = enemyFaction.ShipsLost >= 0 ? current.State : FactionObjectiveState.Planned; }); } RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.BootstrapWarIndustry, "bootstrap-war-industry", "ensure-war-industry"); RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.BuildShipyard, "build-shipyard", "ensure-war-industry"); RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.BuildAttackFleet, "build-attack-fleet", "ensure-war-fleet"); RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.EnsureWaterSecurity, "secure-water", "ensure-water-security"); RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.EnsureMiningCapacity, "ensure-mining-capacity", "ensure-mining-capacity"); RefreshSupportObjective( commander, state, rankedGoals, objectiveIndex, touchedObjectiveIds, FactionObjectiveKind.EnsureConstructionCapacity, "ensure-construction-capacity", "ensure-construction-capacity"); for (var index = 0; index < commander.Objectives.Count; index += 1) { var objective = commander.Objectives[index]; if (!touchedObjectiveIds.Contains(objective.Id)) { continue; } _stepFactory.EnsureObjectiveSteps(objective, blackboard, state); _dependencyResolver.ResolveDependencies(world, commander, objective, blackboard, state, objectiveIndex, touchedObjectiveIds); } foreach (var objective in commander.Objectives) { objective.UpdatedAtCycle = commander.PlanningCycle; if (!touchedObjectiveIds.Contains(objective.Id) && objective.State is not FactionObjectiveState.Complete and not FactionObjectiveState.Cancelled) { objective.State = FactionObjectiveState.Cancelled; objective.InvalidationReason = "Objective no longer relevant to the current faction plan."; } } } private void RefreshSupportObjective( CommanderRuntime commander, FactionPlanningState state, IReadOnlyList<(string GoalName, float Priority)> rankedGoals, IDictionary objectiveIndex, ISet touchedObjectiveIds, FactionObjectiveKind kind, string mergeKey, string goalName) { var priority = rankedGoals.FirstOrDefault(goal => string.Equals(goal.GoalName, goalName, StringComparison.Ordinal)).Priority; if (priority <= 0f) { return; } var objective = GetOrCreateObjective( commander, objectiveIndex, touchedObjectiveIds, mergeKey, kind, priority, configure: _ => { }); } private static FactionObjectiveRuntime GetOrCreateObjective( CommanderRuntime commander, IDictionary objectiveIndex, ISet touchedObjectiveIds, string mergeKey, FactionObjectiveKind kind, float priority, Action configure) { if (!objectiveIndex.TryGetValue(mergeKey, out var objective)) { objective = new FactionObjectiveRuntime { Id = $"obj-{commander.FactionId}-{commander.Objectives.Count + 1}", MergeKey = mergeKey, Kind = kind, Priority = priority, CreatedAtCycle = commander.PlanningCycle, UpdatedAtCycle = commander.PlanningCycle, }; commander.Objectives.Add(objective); objectiveIndex[mergeKey] = objective; } objective.Priority = MathF.Max(objective.Priority, priority); if (objective.State is FactionObjectiveState.Cancelled or FactionObjectiveState.Failed) { objective.State = FactionObjectiveState.Planned; objective.InvalidationReason = null; } configure(objective); touchedObjectiveIds.Add(objective.Id); return objective; } private static IEnumerable EnumerateTrackedCommodityIds() { yield return "ore"; yield return "refinedmetals"; yield return "hullparts"; yield return "claytronics"; yield return "water"; yield return "energycells"; } } internal sealed class ObjectiveDependencyResolver { internal void ResolveDependencies( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionBlackboardRuntime blackboard, FactionPlanningState state, IDictionary objectiveIndex, ISet touchedObjectiveIds) { switch (objective.Kind) { case FactionObjectiveKind.DestroyFaction: AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "bootstrap-war-industry", FactionObjectiveKind.BootstrapWarIndustry, objective.Priority - 10f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "build-shipyard", FactionObjectiveKind.BuildShipyard, objective.Priority - 12f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "build-attack-fleet", FactionObjectiveKind.BuildAttackFleet, objective.Priority - 6f); break; case FactionObjectiveKind.BootstrapWarIndustry: EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "refinedmetals", objective.Priority - 2f); EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "hullparts", objective.Priority - 4f); EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "claytronics", objective.Priority - 4f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "secure-water", FactionObjectiveKind.EnsureWaterSecurity, objective.Priority - 15f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "ensure-mining-capacity", FactionObjectiveKind.EnsureMiningCapacity, objective.Priority - 8f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "ensure-construction-capacity", FactionObjectiveKind.EnsureConstructionCapacity, objective.Priority - 9f); break; case FactionObjectiveKind.BuildShipyard: EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "refinedmetals", objective.Priority - 2f); EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "hullparts", objective.Priority - 1f); EnsureCommodityDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "claytronics", objective.Priority - 1f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "ensure-construction-capacity", FactionObjectiveKind.EnsureConstructionCapacity, objective.Priority - 5f); break; case FactionObjectiveKind.BuildAttackFleet: AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "build-shipyard", FactionObjectiveKind.BuildShipyard, objective.Priority - 2f); AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "bootstrap-war-industry", FactionObjectiveKind.BootstrapWarIndustry, objective.Priority - 3f); break; case FactionObjectiveKind.EnsureCommoditySupply: if (string.Equals(objective.CommodityId, "refinedmetals", StringComparison.Ordinal)) { AddDependency(commander, objective, objectiveIndex, touchedObjectiveIds, "ensure-mining-capacity", FactionObjectiveKind.EnsureMiningCapacity, objective.Priority - 2f); } break; } foreach (var step in objective.Steps) { step.DependencyStepIds.Clear(); foreach (var dependencyId in objective.PrerequisiteObjectiveIds) { if (commander.Objectives.FirstOrDefault(candidate => candidate.Id == dependencyId) is not { } dependencyObjective) { continue; } foreach (var dependencyStep in dependencyObjective.Steps) { if (dependencyStep.Status != FactionPlanStepStatus.Complete) { step.DependencyStepIds.Add(dependencyStep.Id); } } } } } private static void EnsureCommodityDependency( CommanderRuntime commander, FactionObjectiveRuntime parent, IDictionary objectiveIndex, ISet touchedObjectiveIds, string commodityId, float priority) { var dependency = AddDependency( commander, parent, objectiveIndex, touchedObjectiveIds, $"ensure-commodity:{commodityId}", FactionObjectiveKind.EnsureCommoditySupply, priority); dependency.CommodityId = commodityId; } private static FactionObjectiveRuntime AddDependency( CommanderRuntime commander, FactionObjectiveRuntime parent, IDictionary objectiveIndex, ISet touchedObjectiveIds, string mergeKey, FactionObjectiveKind kind, float priority) { if (!objectiveIndex.TryGetValue(mergeKey, out var dependency)) { dependency = new FactionObjectiveRuntime { Id = $"obj-{commander.FactionId}-{commander.Objectives.Count + 1}", MergeKey = mergeKey, Kind = kind, Priority = MathF.Max(1f, priority), CreatedAtCycle = commander.PlanningCycle, UpdatedAtCycle = commander.PlanningCycle, ParentObjectiveId = parent.Id, }; commander.Objectives.Add(dependency); objectiveIndex[mergeKey] = dependency; } else { dependency.Priority = MathF.Max(dependency.Priority, priority); } dependency.ParentObjectiveId ??= parent.Id; parent.PrerequisiteObjectiveIds.Add(dependency.Id); touchedObjectiveIds.Add(dependency.Id); return dependency; } } internal sealed class ObjectiveStepFactory { internal void EnsureObjectiveSteps( FactionObjectiveRuntime objective, FactionBlackboardRuntime blackboard, FactionPlanningState state) { if (objective.Steps.Count > 0) { return; } switch (objective.Kind) { case FactionObjectiveKind.DestroyFaction: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.AttackFactionAssets, objective.Priority, targetFactionId: objective.TargetFactionId)); break; case FactionObjectiveKind.BootstrapWarIndustry: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.MonitorExpansionProject, objective.Priority, notes: "Maintain the war-industry support program until the full chain exists.")); break; case FactionObjectiveKind.BuildShipyard: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureShipyardSite, objective.Priority, moduleId: "module_gen_build_l_01")); break; case FactionObjectiveKind.BuildAttackFleet: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.ProduceFleet, objective.Priority)); break; case FactionObjectiveKind.EnsureCommoditySupply: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureCommodityProduction, objective.Priority, commodityId: objective.CommodityId)); break; case FactionObjectiveKind.EnsureWaterSecurity: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureWaterSupply, objective.Priority, commodityId: "water")); break; case FactionObjectiveKind.EnsureMiningCapacity: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureMiningCapacity, objective.Priority)); break; case FactionObjectiveKind.EnsureConstructionCapacity: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureConstructionCapacity, objective.Priority)); break; case FactionObjectiveKind.EnsureTransportCapacity: objective.Steps.Add(CreateStep(objective, FactionPlanStepKind.EnsureTransportCapacity, objective.Priority)); break; } } private static FactionPlanStepRuntime CreateStep( FactionObjectiveRuntime objective, FactionPlanStepKind kind, float priority, string? commodityId = null, string? moduleId = null, string? targetFactionId = null, string? notes = null) { return new FactionPlanStepRuntime { Id = $"{objective.Id}-step-{objective.Steps.Count + 1}", ObjectiveId = objective.Id, Kind = kind, Priority = priority, CommodityId = commodityId, ModuleId = moduleId, TargetFactionId = targetFactionId, Notes = notes, }; } } internal sealed class FactionObjectiveExecutor { internal void Execute( SimulationEngine engine, SimulationWorld world, CommanderRuntime commander, FactionPlanningState state) { var blackboard = commander.FactionBlackboard ?? throw new InvalidOperationException("Faction blackboard must exist before objectives are executed."); commander.ActiveGoalName = null; commander.ActiveActionName = null; ResetRuntimeAssignments(commander); var touchedTaskIds = new HashSet(StringComparer.Ordinal); var assignedAssetIds = new HashSet(StringComparer.Ordinal); foreach (var objective in commander.Objectives .Where(objective => objective.State is not FactionObjectiveState.Cancelled and not FactionObjectiveState.Failed) .OrderByDescending(objective => objective.Priority)) { EvaluateObjective(world, commander, objective); foreach (var step in objective.Steps.OrderByDescending(step => step.Priority)) { EvaluateStep(world, commander, objective, step, blackboard, state); EmitTasks(engine, world, commander, objective, step, blackboard, state, touchedTaskIds, assignedAssetIds); } } ReconcileStaleTasks(commander, touchedTaskIds); } private static void ResetRuntimeAssignments(CommanderRuntime commander) { foreach (var objective in commander.Objectives) { objective.AssignedAssetIds.Clear(); foreach (var step in objective.Steps) { step.AssignedAssetIds.Clear(); step.IssuedTaskIds.Clear(); } } foreach (var task in commander.IssuedTasks) { task.AssignedAssetIds.Clear(); } } private static void EvaluateObjective( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective) { objective.BlockingReason = null; objective.InvalidationReason = null; var incompleteSteps = objective.Steps.Where(step => step.Status != FactionPlanStepStatus.Complete).ToList(); if (incompleteSteps.Count == 0) { objective.State = FactionObjectiveState.Complete; return; } if (incompleteSteps.All(step => step.Status == FactionPlanStepStatus.Blocked)) { objective.State = FactionObjectiveState.Blocked; objective.BlockingReason = incompleteSteps.FirstOrDefault(step => !string.IsNullOrWhiteSpace(step.BlockingReason))?.BlockingReason; return; } objective.State = FactionObjectiveState.Active; } private static void EvaluateStep( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, FactionBlackboardRuntime blackboard, FactionPlanningState state) { step.LastEvaluatedCycle = commander.PlanningCycle; step.BlockingReason = null; if (step.DependencyStepIds.Count > 0 && HasIncompleteDependencies(commander, step)) { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = "Waiting for prerequisite objective steps to complete."; return; } switch (step.Kind) { case FactionPlanStepKind.EnsureCommodityProduction: EvaluateCommodityStep(step, blackboard); break; case FactionPlanStepKind.EnsureShipyardSite: if (state.HasShipFactory) { step.Status = FactionPlanStepStatus.Complete; step.ProducedFacts.Add("shipyard-online"); } else { step.Status = blackboard.HasActiveExpansionProject && string.Equals(blackboard.ActiveExpansionModuleId, step.ModuleId, StringComparison.Ordinal) ? FactionPlanStepStatus.Running : FactionPlanStepStatus.Ready; } break; case FactionPlanStepKind.ProduceFleet: step.Status = state.MilitaryShipCount >= blackboard.TargetWarshipCount ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; break; case FactionPlanStepKind.AttackFactionAssets: if (blackboard.EnemyFactionCount <= 0) { step.Status = FactionPlanStepStatus.Complete; } else if (state.MilitaryShipCount < Math.Max(2, blackboard.TargetWarshipCount / 2)) { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = "Insufficient military strength to commit to a faction attack objective."; } else { step.Status = FactionPlanStepStatus.Running; } break; case FactionPlanStepKind.EnsureWaterSupply: step.Status = IsCommodityOperational(blackboard, "water", 300f) ? FactionPlanStepStatus.Complete : blackboard.HasActiveExpansionProject && string.Equals(blackboard.ActiveExpansionCommodityId, "water", StringComparison.Ordinal) ? FactionPlanStepStatus.Running : FactionPlanStepStatus.Ready; break; case FactionPlanStepKind.EnsureMiningCapacity: step.Status = state.MinerShipCount >= 2 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; break; case FactionPlanStepKind.EnsureConstructionCapacity: step.Status = state.ConstructorShipCount >= 1 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; break; case FactionPlanStepKind.EnsureTransportCapacity: step.Status = state.TransportShipCount >= 1 ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; break; case FactionPlanStepKind.MonitorExpansionProject: step.Status = blackboard.HasWarIndustrySupplyChain ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Running; break; } } private static void EvaluateCommodityStep( FactionPlanStepRuntime step, FactionBlackboardRuntime blackboard) { if (string.IsNullOrWhiteSpace(step.CommodityId)) { step.Status = FactionPlanStepStatus.Failed; step.BlockingReason = "Commodity planning step is missing a target commodity."; return; } var completed = IsCommodityOperational(blackboard, step.CommodityId, 240f); step.Status = completed ? FactionPlanStepStatus.Complete : FactionPlanStepStatus.Ready; if (completed) { step.ProducedFacts.Add($"commodity-online:{step.CommodityId}"); } } private static bool IsCommodityOperational( FactionBlackboardRuntime blackboard, string commodityId, float minimumLevelSeconds) { var signal = blackboard.CommoditySignals.FirstOrDefault(candidate => string.Equals(candidate.ItemId, commodityId, StringComparison.Ordinal)); if (signal is null) { return false; } return signal.ProjectedProductionRatePerSecond > 0.01f && signal.ProjectedNetRatePerSecond >= -0.01f && signal.LevelSeconds >= minimumLevelSeconds && signal.Level is "stable" or "surplus"; } private static void EmitTasks( SimulationEngine engine, SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, FactionBlackboardRuntime blackboard, FactionPlanningState state, ISet touchedTaskIds, ISet assignedAssetIds) { if (step.Status is FactionPlanStepStatus.Complete or FactionPlanStepStatus.Cancelled or FactionPlanStepStatus.Failed) { SyncTerminalTasks(commander, step, touchedTaskIds); return; } commander.ActiveGoalName ??= objective.Kind.ToString(); commander.ActiveActionName ??= step.Kind.ToString(); switch (step.Kind) { case FactionPlanStepKind.EnsureCommodityProduction: if (blackboard.HasActiveExpansionProject) { UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: blackboard.ActiveExpansionCommodityId ?? step.CommodityId, moduleId: blackboard.ActiveExpansionModuleId, targetSystemId: blackboard.ActiveExpansionSystemId, targetSiteId: blackboard.ActiveExpansionSiteId, blockingReason: step.BlockingReason, notes: step.Notes ?? "Expansion project already active for faction."); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); step.Status = FactionPlanStepStatus.Running; return; } if (step.CommodityId is null) { return; } var project = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, step.CommodityId); if (project is not null) { FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); step.TargetSiteId = project.SiteId; step.Status = FactionPlanStepStatus.Running; step.Notes = $"Queued expansion project for {project.CommodityId}."; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: project.CommodityId, moduleId: project.ModuleId, targetSystemId: project.SystemId, targetSiteId: project.SiteId, blockingReason: null, notes: step.Notes); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); } else { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = $"Unable to derive an expansion project for {step.CommodityId}."; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: step.CommodityId, moduleId: step.ModuleId, targetSystemId: null, targetSiteId: null, blockingReason: step.BlockingReason, notes: step.Notes); } break; case FactionPlanStepKind.EnsureShipyardSite: if (blackboard.HasActiveExpansionProject) { UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: blackboard.ActiveExpansionCommodityId, moduleId: blackboard.ActiveExpansionModuleId ?? step.ModuleId, targetSystemId: blackboard.ActiveExpansionSystemId, targetSiteId: blackboard.ActiveExpansionSiteId, blockingReason: step.BlockingReason, notes: step.Notes ?? "Shipyard support project waiting on current expansion site."); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); step.Status = FactionPlanStepStatus.Running; return; } var shipyardProject = FactionIndustryPlanner.CreateShipyardFoundationProject(world, commander.FactionId); if (shipyardProject is not null) { FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, shipyardProject); step.TargetSiteId = shipyardProject.SiteId; step.Status = FactionPlanStepStatus.Running; step.Notes = "Queued shipyard foundation project."; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: shipyardProject.CommodityId, moduleId: shipyardProject.ModuleId, targetSystemId: shipyardProject.SystemId, targetSiteId: shipyardProject.SiteId, blockingReason: null, notes: step.Notes); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); } else { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = "Unable to identify a viable shipyard foundation project."; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: step.CommodityId, moduleId: step.ModuleId, targetSystemId: null, targetSiteId: null, blockingReason: step.BlockingReason, notes: step.Notes); } break; case FactionPlanStepKind.ProduceFleet: if (!blackboard.HasShipyard) { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = "Fleet production requires an online shipyard."; } UpsertShipProductionTask( commander, objective, step, touchedTaskIds, shipRole: "military", blockingReason: step.BlockingReason, notes: step.Notes ?? "Maintain military ship production until war fleet target is satisfied."); AssignShipyardAssets(world, commander, objective, step); break; case FactionPlanStepKind.AttackFactionAssets: UpsertAttackTask(commander, objective, step, touchedTaskIds); AssignCombatAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); break; case FactionPlanStepKind.EnsureWaterSupply: if (blackboard.HasActiveExpansionProject) { UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: blackboard.ActiveExpansionCommodityId, moduleId: blackboard.ActiveExpansionModuleId, targetSystemId: blackboard.ActiveExpansionSystemId, targetSiteId: blackboard.ActiveExpansionSiteId, blockingReason: step.BlockingReason, notes: step.Notes ?? "Water support project waiting on current expansion site."); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); step.Status = FactionPlanStepStatus.Running; return; } var waterProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"); if (waterProject is not null) { FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, waterProject); step.Status = FactionPlanStepStatus.Running; step.Notes = "Queued water expansion project."; step.TargetSiteId = waterProject.SiteId; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: waterProject.CommodityId, moduleId: waterProject.ModuleId, targetSystemId: waterProject.SystemId, targetSiteId: waterProject.SiteId, blockingReason: null, notes: step.Notes); AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); } else { step.Status = FactionPlanStepStatus.Blocked; step.BlockingReason = "Unable to derive an expansion project for water."; UpsertExpansionTask( commander, objective, step, touchedTaskIds, commodityId: "water", moduleId: step.ModuleId, targetSystemId: null, targetSiteId: null, blockingReason: step.BlockingReason, notes: step.Notes); } break; case FactionPlanStepKind.EnsureMiningCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "mining", step.BlockingReason, "Maintain mining ship production until logistical capacity is healthy."); AssignShipyardAssets(world, commander, objective, step); break; case FactionPlanStepKind.EnsureConstructionCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "construction", step.BlockingReason, "Maintain construction ship production until expansion support is healthy."); AssignShipyardAssets(world, commander, objective, step); break; case FactionPlanStepKind.EnsureTransportCapacity: UpsertShipProductionTask(commander, objective, step, touchedTaskIds, "transport", step.BlockingReason, "Maintain transport ship production until logistical throughput is healthy."); AssignShipyardAssets(world, commander, objective, step); break; case FactionPlanStepKind.MonitorExpansionProject: UpsertWarIndustryTask(commander, objective, step, touchedTaskIds); break; } } private static void ReconcileStaleTasks(CommanderRuntime commander, ISet touchedTaskIds) { foreach (var task in commander.IssuedTasks) { task.UpdatedAtCycle = commander.PlanningCycle; if (touchedTaskIds.Contains(task.Id)) { continue; } if (task.State is FactionIssuedTaskState.Complete or FactionIssuedTaskState.Cancelled) { continue; } task.State = FactionIssuedTaskState.Cancelled; task.BlockingReason = "Task no longer backed by an active faction plan step."; task.AssignedAssetIds.Clear(); } } private static void SyncTerminalTasks( CommanderRuntime commander, FactionPlanStepRuntime step, ISet touchedTaskIds) { foreach (var task in commander.IssuedTasks.Where(candidate => string.Equals(candidate.StepId, step.Id, StringComparison.Ordinal))) { task.State = step.Status switch { FactionPlanStepStatus.Complete => FactionIssuedTaskState.Complete, FactionPlanStepStatus.Failed => FactionIssuedTaskState.Cancelled, FactionPlanStepStatus.Cancelled => FactionIssuedTaskState.Cancelled, _ => task.State, }; task.BlockingReason = step.BlockingReason; task.UpdatedAtCycle = commander.PlanningCycle; step.IssuedTaskIds.Add(task.Id); touchedTaskIds.Add(task.Id); } } private static void UpsertExpansionTask( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, ISet touchedTaskIds, string? commodityId, string? moduleId, string? targetSystemId, string? targetSiteId, string? blockingReason, string? notes) { var task = GetOrCreateTask( commander, objective, step, touchedTaskIds, $"expand-industry:{objective.Id}:{step.Id}:{commodityId ?? moduleId ?? "general"}", FactionIssuedTaskKind.ExpandIndustry); task.CommodityId = commodityId; task.ModuleId = moduleId; task.TargetSystemId = targetSystemId; task.TargetSiteId = targetSiteId; task.Notes = notes; task.BlockingReason = blockingReason; task.State = MapStepStatus(step.Status); } private static void UpsertShipProductionTask( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, ISet touchedTaskIds, string shipRole, string? blockingReason, string? notes) { var task = GetOrCreateTask( commander, objective, step, touchedTaskIds, $"produce-ships:{shipRole}", FactionIssuedTaskKind.ProduceShips); task.ShipRole = shipRole; task.BlockingReason = blockingReason; task.Notes = notes; task.State = MapStepStatus(step.Status); } private static void UpsertAttackTask( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, ISet touchedTaskIds) { var task = GetOrCreateTask( commander, objective, step, touchedTaskIds, $"attack-faction:{step.TargetFactionId ?? objective.TargetFactionId ?? "unknown"}", FactionIssuedTaskKind.AttackFactionAssets); task.TargetFactionId = step.TargetFactionId ?? objective.TargetFactionId; task.Notes = step.Notes ?? "Commit combat ships against the hostile faction."; task.BlockingReason = step.BlockingReason; task.State = MapStepStatus(step.Status); } private static void UpsertWarIndustryTask( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, ISet touchedTaskIds) { var task = GetOrCreateTask( commander, objective, step, touchedTaskIds, "sustain-war-industry", FactionIssuedTaskKind.SustainWarIndustry); task.Notes = step.Notes ?? "Maintain the faction war-industry bootstrap program."; task.BlockingReason = step.BlockingReason; task.State = MapStepStatus(step.Status); } private static FactionIssuedTaskRuntime GetOrCreateTask( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, ISet touchedTaskIds, string mergeKey, FactionIssuedTaskKind kind) { var task = commander.IssuedTasks.FirstOrDefault(candidate => string.Equals(candidate.MergeKey, mergeKey, StringComparison.Ordinal)); if (task is null) { task = new FactionIssuedTaskRuntime { Id = $"task-{commander.FactionId}-{commander.IssuedTasks.Count + 1}", MergeKey = mergeKey, Kind = kind, ObjectiveId = objective.Id, StepId = step.Id, Priority = step.Priority, CreatedAtCycle = commander.PlanningCycle, UpdatedAtCycle = commander.PlanningCycle, }; commander.IssuedTasks.Add(task); } task.Priority = MathF.Max(task.Priority, step.Priority); task.UpdatedAtCycle = commander.PlanningCycle; task.BlockingReason = null; task.Notes = step.Notes; step.IssuedTaskIds.Add(task.Id); touchedTaskIds.Add(task.Id); return task; } private static FactionIssuedTaskState MapStepStatus(FactionPlanStepStatus status) => status switch { FactionPlanStepStatus.Planned => FactionIssuedTaskState.Planned, FactionPlanStepStatus.Ready => FactionIssuedTaskState.Active, FactionPlanStepStatus.Running => FactionIssuedTaskState.Active, FactionPlanStepStatus.Blocked => FactionIssuedTaskState.Blocked, FactionPlanStepStatus.Complete => FactionIssuedTaskState.Complete, _ => FactionIssuedTaskState.Cancelled, }; private static void AssignCombatAssets( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, IReadOnlyCollection taskIds, ISet assignedAssetIds) { var availableShips = world.Ships .Where(ship => ship.Health > 0f && string.Equals(ship.FactionId, commander.FactionId, StringComparison.Ordinal) && string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal) && assignedAssetIds.Add(ship.Id)) .OrderBy(ship => ship.SystemId, StringComparer.Ordinal) .ThenBy(ship => ship.Id, StringComparer.Ordinal) .Take(4) .ToList(); ApplyAssignments(commander, objective, step, taskIds, availableShips.Select(ship => ship.Id)); } private static void AssignConstructionAssets( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, IReadOnlyCollection taskIds, ISet assignedAssetIds) { var availableShips = world.Ships .Where(ship => ship.Health > 0f && string.Equals(ship.FactionId, commander.FactionId, StringComparison.Ordinal) && string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && assignedAssetIds.Add(ship.Id)) .OrderBy(ship => ship.SystemId, StringComparer.Ordinal) .ThenBy(ship => ship.Id, StringComparer.Ordinal) .Take(2) .ToList(); ApplyAssignments(commander, objective, step, taskIds, availableShips.Select(ship => ship.Id)); } private static void AssignShipyardAssets( SimulationWorld world, CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step) { var stationIds = world.Stations .Where(station => string.Equals(station.FactionId, commander.FactionId, StringComparison.Ordinal) && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)) .Select(station => station.Id) .OrderBy(id => id, StringComparer.Ordinal) .ToList(); foreach (var stationId in stationIds) { objective.AssignedAssetIds.Add(stationId); step.AssignedAssetIds.Add(stationId); } foreach (var taskId in step.IssuedTaskIds) { if (commander.IssuedTasks.FirstOrDefault(candidate => candidate.Id == taskId) is not { } task) { continue; } foreach (var stationId in stationIds) { task.AssignedAssetIds.Add(stationId); } } } private static void ApplyAssignments( CommanderRuntime commander, FactionObjectiveRuntime objective, FactionPlanStepRuntime step, IReadOnlyCollection taskIds, IEnumerable assetIds) { var materializedAssetIds = assetIds.ToList(); foreach (var assetId in materializedAssetIds) { objective.AssignedAssetIds.Add(assetId); step.AssignedAssetIds.Add(assetId); } foreach (var taskId in taskIds) { if (commander.IssuedTasks.FirstOrDefault(candidate => candidate.Id == taskId) is not { } task) { continue; } foreach (var assetId in materializedAssetIds) { task.AssignedAssetIds.Add(assetId); } } } private static bool HasIncompleteDependencies(CommanderRuntime commander, FactionPlanStepRuntime step) => step.DependencyStepIds .Select(dependencyId => commander.Objectives .SelectMany(objective => objective.Steps) .FirstOrDefault(candidate => candidate.Id == dependencyId)) .Any(dependency => dependency is not null && dependency.Status != FactionPlanStepStatus.Complete); }