diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 5565fcc..363f70e 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -6,8 +6,9 @@ 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 _factionPlanner = new(s => s.Clone()); private static readonly GoapPlanner _shipPlanner = new(s => s.Clone()); private static readonly IReadOnlyList> _factionGoals = @@ -76,12 +77,9 @@ internal sealed class CommanderPlanningService commander.ReplanTimer = FactionCommanderReplanInterval; commander.NeedsReplan = false; + commander.PlanningCycle += 1; 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))) @@ -89,28 +87,15 @@ internal sealed class CommanderPlanningService .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")); - } - } - + 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) @@ -172,18 +157,30 @@ internal sealed class CommanderPlanningService 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, + 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(), }; } @@ -192,16 +189,23 @@ internal sealed class CommanderPlanningService ShipRuntime ship, CommanderRuntime commander) { - var factionCommander = world.Commanders.FirstOrDefault(c => - c.FactionId == commander.FactionId && - string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); + 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 = enemyTarget?.SystemId; + commander.ActiveBehavior.AreaSystemId = attackTask?.TargetSystemId ?? expansionTask?.TargetSystemId ?? enemyTarget?.SystemId; commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId; if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) { @@ -209,12 +213,12 @@ internal sealed class CommanderPlanningService commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId; commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId; } - else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null) + 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 = expansionProject.SiteId; - commander.ActiveBehavior.ModuleId = expansionProject.ModuleId; - commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId; + 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)) { @@ -229,15 +233,13 @@ internal sealed class CommanderPlanningService 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, + 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, @@ -245,70 +247,33 @@ internal sealed class CommanderPlanningService }; } - private static IReadOnlyList> BuildFactionActions(SimulationWorld world) - { - var actions = new List>(); + internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => + world.Commanders.FirstOrDefault(c => + c.FactionId == factionId && + string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); - actions.Add(new PlanWarIndustryAction()); - actions.Add(new PlanCommoditySupplyAction("water")); + 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; - 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; - } + 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) { diff --git a/apps/backend/Factions/AI/FactionController.cs b/apps/backend/Factions/AI/FactionController.cs index e9cd1cc..526b3c1 100644 --- a/apps/backend/Factions/AI/FactionController.cs +++ b/apps/backend/Factions/AI/FactionController.cs @@ -16,26 +16,40 @@ public sealed class FactionPlanningState public int EnemyShipCount { get; set; } public int EnemyStationCount { get; set; } public float OreStockpile { get; set; } - public float RefinedMetalsStockpile { get; set; } - public float RefinedMetalsProductionRate { get; set; } - public float RefinedMetalsShortageHorizonSeconds { get; set; } - public float HullpartsStockpile { get; set; } - public float HullpartsProductionRate { get; set; } - public float HullpartsShortageHorizonSeconds { get; set; } - public float ClaytronicsStockpile { get; set; } - public float ClaytronicsProductionRate { get; set; } - public float ClaytronicsShortageHorizonSeconds { get; set; } - public float WaterStockpile { get; set; } - public float WaterProductionRate { get; set; } - public float WaterShortageHorizonSeconds { get; set; } + public float RefinedMetalsAvailableStock { get; set; } + public float RefinedMetalsUsageRate { get; set; } + public float RefinedMetalsProjectedProductionRate { get; set; } + public float RefinedMetalsProjectedNetRate { get; set; } + public float RefinedMetalsLevelSeconds { get; set; } + public string RefinedMetalsLevel { get; set; } = "unknown"; + public float HullpartsAvailableStock { get; set; } + public float HullpartsUsageRate { get; set; } + public float HullpartsProjectedProductionRate { get; set; } + public float HullpartsProjectedNetRate { get; set; } + public float HullpartsLevelSeconds { get; set; } + public string HullpartsLevel { get; set; } = "unknown"; + public float ClaytronicsAvailableStock { get; set; } + public float ClaytronicsUsageRate { get; set; } + public float ClaytronicsProjectedProductionRate { get; set; } + public float ClaytronicsProjectedNetRate { get; set; } + public float ClaytronicsLevelSeconds { get; set; } + public string ClaytronicsLevel { get; set; } = "unknown"; + public float WaterAvailableStock { get; set; } + public float WaterUsageRate { get; set; } + public float WaterProjectedProductionRate { get; set; } + public float WaterProjectedNetRate { get; set; } + public float WaterLevelSeconds { get; set; } + public string WaterLevel { get; set; } = "unknown"; - public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f; - public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f; - public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f; - public bool HasWaterProduction => WaterProductionRate > 0.01f; + public bool HasRefinedMetalsProduction => RefinedMetalsProjectedProductionRate > 0.01f; + public bool HasHullpartsProduction => HullpartsProjectedProductionRate > 0.01f; + public bool HasClaytronicsProduction => ClaytronicsProjectedProductionRate > 0.01f; + public bool HasWaterProduction => WaterProjectedProductionRate > 0.01f; public bool HasWarIndustrySupplyChain => - HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction; + IsCommodityOperational(RefinedMetalsProjectedProductionRate, RefinedMetalsProjectedNetRate, RefinedMetalsLevelSeconds, RefinedMetalsLevel, 240f) + && IsCommodityOperational(HullpartsProjectedProductionRate, HullpartsProjectedNetRate, HullpartsLevelSeconds, HullpartsLevel, 240f) + && IsCommodityOperational(ClaytronicsProjectedProductionRate, ClaytronicsProjectedNetRate, ClaytronicsLevelSeconds, ClaytronicsLevel, 240f); public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone(); @@ -44,6 +58,39 @@ public sealed class FactionPlanningState var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount); return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount)); } + + internal static bool IsCommodityOperational( + float projectedProductionRate, + float projectedNetRate, + float levelSeconds, + string level, + float targetLevelSeconds) => + projectedProductionRate > 0.01f + && projectedNetRate >= -0.01f + && levelSeconds >= targetLevelSeconds + && (string.Equals(level, "stable", StringComparison.OrdinalIgnoreCase) + || string.Equals(level, "surplus", StringComparison.OrdinalIgnoreCase)); + + internal static float ComputeCommodityNeed( + float projectedProductionRate, + float usageRate, + float projectedNetRate, + float levelSeconds, + string level, + float targetLevelSeconds) + { + var levelWeight = level switch + { + "critical" => 140f, + "low" => 80f, + "stable" => 20f, + _ => 0f, + }; + var rateDeficit = MathF.Max(0f, usageRate - projectedProductionRate); + var levelDeficit = MathF.Max(0f, targetLevelSeconds - levelSeconds) / MathF.Max(targetLevelSeconds, 1f); + var instability = projectedNetRate < 0f ? MathF.Abs(projectedNetRate) * 80f : 0f; + return levelWeight + (rateDeficit * 140f) + (levelDeficit * 120f) + instability; + } } // ─── Goals ───────────────────────────────────────────────────────────────────── @@ -63,12 +110,16 @@ public sealed class EnsureWarIndustryGoal : GoapGoal } var missingStages = - (state.HasRefinedMetalsProduction ? 0 : 1) + - (state.HasHullpartsProduction ? 0 : 1) + - (state.HasClaytronicsProduction ? 0 : 1) + + (FactionPlanningState.IsCommodityOperational(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f) ? 0 : 1) + + (FactionPlanningState.IsCommodityOperational(state.HullpartsProjectedProductionRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f) ? 0 : 1) + + (FactionPlanningState.IsCommodityOperational(state.ClaytronicsProjectedProductionRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f) ? 0 : 1) + (state.HasShipFactory ? 0 : 1); + var supplyNeed = + FactionPlanningState.ComputeCommodityNeed(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsUsageRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f) + + FactionPlanningState.ComputeCommodityNeed(state.HullpartsProjectedProductionRate, state.HullpartsUsageRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f) + + FactionPlanningState.ComputeCommodityNeed(state.ClaytronicsProjectedProductionRate, state.ClaytronicsUsageRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f); - return missingStages <= 0 ? 0f : 125f + (missingStages * 18f); + return missingStages <= 0 && supplyNeed <= 0.01f ? 0f : 110f + (missingStages * 22f) + (supplyNeed * 0.18f); } } @@ -77,21 +128,22 @@ public sealed class EnsureWaterSecurityGoal : GoapGoal public override string Name => "ensure-water-security"; public override bool IsSatisfied(FactionPlanningState state) => - state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f; + FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f); public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) { - if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f) + if (FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f)) { return 0f; } - if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds)) - { - return state.HasWaterProduction ? 0f : 85f; - } - - return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f; + return 55f + FactionPlanningState.ComputeCommodityNeed( + state.WaterProjectedProductionRate, + state.WaterUsageRate, + state.WaterProjectedNetRate, + state.WaterLevelSeconds, + state.WaterLevel, + 300f) * 0.25f; } } @@ -170,159 +222,3 @@ public sealed class EnsureConstructionCapacityGoal : GoapGoal -{ - private readonly string shipKind; - private readonly string shipId; - - public OrderShipProductionAction(string shipKind, string shipId) - { - this.shipKind = shipKind; - this.shipId = shipId; - } - - public override string Name => $"order-{shipId}-production"; - public override float Cost => 1f; - - public override bool CheckPreconditions(FactionPlanningState state) => - state.HasShipFactory && state.HasWarIndustrySupplyChain; - - public override FactionPlanningState ApplyEffects(FactionPlanningState state) - { - switch (shipKind) - { - case "military": state.MilitaryShipCount++; break; - case "mining": state.MinerShipCount++; break; - case "transport": state.TransportShipCount++; break; - case "construction": state.ConstructorShipCount++; break; - } - - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - commander.ActiveDirectives.Add($"produce-{shipKind}-ships"); - } -} - -public sealed class PlanWarIndustryAction : GoapAction -{ - public override string Name => "plan-war-industry"; - public override float Cost => 2f; - - public override bool CheckPreconditions(FactionPlanningState state) => - state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory); - - public override FactionPlanningState ApplyEffects(FactionPlanningState state) - { - state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f); - state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f); - state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f); - state.HasShipFactory = true; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - commander.ActiveDirectives.Add("bootstrap-war-industry"); - - if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project) - { - return; - } - - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); - commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); - } -} - -public sealed class PlanCommoditySupplyAction : GoapAction -{ - private readonly string commodityId; - - public PlanCommoditySupplyAction(string commodityId) - { - this.commodityId = commodityId; - } - - public override string Name => $"plan-{commodityId}-supply"; - public override float Cost => 2f; - - public override bool CheckPreconditions(FactionPlanningState state) => - commodityId switch - { - "water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f, - _ => false, - }; - - public override FactionPlanningState ApplyEffects(FactionPlanningState state) - { - if (string.Equals(commodityId, "water", StringComparison.Ordinal)) - { - state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f); - state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f); - } - - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project) - { - return; - } - - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); - commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); - } -} - -public sealed class ExpandToSystemAction : GoapAction -{ - public override string Name => "expand-to-system"; - public override float Cost => 3f; - - public override bool CheckPreconditions(FactionPlanningState state) => - state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain; - - public override FactionPlanningState ApplyEffects(FactionPlanningState state) - { - state.ControlledSystemCount++; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - commander.ActiveDirectives.Add("expand-territory"); - } -} - -public sealed class LaunchExterminationCampaignAction : GoapAction -{ - public override string Name => "launch-extermination-campaign"; - public override float Cost => 1f; - - public override bool CheckPreconditions(FactionPlanningState state) => - state.EnemyFactionCount > 0 - && state.HasShipFactory - && state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2); - - public override FactionPlanningState ApplyEffects(FactionPlanningState state) - { - state.EnemyShipCount = 0; - state.EnemyStationCount = 0; - state.EnemyFactionCount = 0; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - commander.ActiveDirectives.Add("attack-rival"); - commander.ActiveDirectives.Add("produce-military-ships"); - } -} diff --git a/apps/backend/Factions/AI/FactionObjectivePlanning.cs b/apps/backend/Factions/AI/FactionObjectivePlanning.cs new file mode 100644 index 0000000..4251181 --- /dev/null +++ b/apps/backend/Factions/AI/FactionObjectivePlanning.cs @@ -0,0 +1,1169 @@ +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); +} diff --git a/apps/backend/Factions/Contracts/Factions.cs b/apps/backend/Factions/Contracts/Factions.cs index a582eca..15188a2 100644 --- a/apps/backend/Factions/Contracts/Factions.cs +++ b/apps/backend/Factions/Contracts/Factions.cs @@ -1,6 +1,6 @@ namespace SpaceGame.Api.Factions.Contracts; -public sealed record FactionGoapStateSnapshot( +public sealed record FactionPlanningStateSnapshot( int MilitaryShipCount, int MinerShipCount, int TransportShipCount, @@ -9,17 +9,134 @@ public sealed record FactionGoapStateSnapshot( int TargetSystemCount, bool HasShipFactory, float OreStockpile, - float RefinedMetalsStockpile, - float RefinedMetalsProductionRate, - float HullpartsStockpile, - float HullpartsProductionRate, - float ClaytronicsStockpile, - float ClaytronicsProductionRate, - float WaterStockpile, - float WaterProductionRate, - float WaterShortageHorizonSeconds); + float RefinedMetalsAvailableStock, + float RefinedMetalsUsageRate, + float RefinedMetalsProjectedProductionRate, + float RefinedMetalsProjectedNetRate, + float RefinedMetalsLevelSeconds, + string RefinedMetalsLevel, + float HullpartsAvailableStock, + float HullpartsUsageRate, + float HullpartsProjectedProductionRate, + float HullpartsProjectedNetRate, + float HullpartsLevelSeconds, + string HullpartsLevel, + float ClaytronicsAvailableStock, + float ClaytronicsUsageRate, + float ClaytronicsProjectedProductionRate, + float ClaytronicsProjectedNetRate, + float ClaytronicsLevelSeconds, + string ClaytronicsLevel, + float WaterAvailableStock, + float WaterUsageRate, + float WaterProjectedProductionRate, + float WaterProjectedNetRate, + float WaterLevelSeconds, + string WaterLevel); -public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority); +public sealed record FactionStrategicPrioritySnapshot(string GoalName, float Priority); + +public sealed record FactionCommoditySignalSnapshot( + string ItemId, + float AvailableStock, + float OnHand, + float ProductionRatePerSecond, + float CommittedProductionRatePerSecond, + float UsageRatePerSecond, + float NetRatePerSecond, + float ProjectedNetRatePerSecond, + float LevelSeconds, + string Level, + float ProjectedProductionRatePerSecond, + float BuyBacklog, + float ReservedForConstruction); + +public sealed record FactionThreatSignalSnapshot( + string ScopeId, + string ScopeKind, + int EnemyShipCount, + int EnemyStationCount); + +public sealed record FactionBlackboardSnapshot( + int PlanCycle, + DateTimeOffset UpdatedAtUtc, + int TargetWarshipCount, + bool HasWarIndustrySupplyChain, + bool HasShipyard, + bool HasActiveExpansionProject, + string? ActiveExpansionCommodityId, + string? ActiveExpansionModuleId, + string? ActiveExpansionSiteId, + string? ActiveExpansionSystemId, + int EnemyFactionCount, + int EnemyShipCount, + int EnemyStationCount, + int MilitaryShipCount, + int MinerShipCount, + int TransportShipCount, + int ConstructorShipCount, + int ControlledSystemCount, + IReadOnlyList CommoditySignals, + IReadOnlyList ThreatSignals); + +public sealed record FactionPlanStepSnapshot( + string Id, + string Kind, + string Status, + float Priority, + string? CommodityId, + string? ModuleId, + string? TargetFactionId, + string? TargetSiteId, + string? BlockingReason, + string? Notes, + int LastEvaluatedCycle, + IReadOnlyList DependencyStepIds, + IReadOnlyList RequiredFacts, + IReadOnlyList ProducedFacts, + IReadOnlyList AssignedAssets, + IReadOnlyList IssuedTaskIds); + +public sealed record FactionIssuedTaskSnapshot( + string Id, + string Kind, + string State, + string ObjectiveId, + string StepId, + float Priority, + string? ShipRole, + string? CommodityId, + string? ModuleId, + string? TargetFactionId, + string? TargetSystemId, + string? TargetSiteId, + int CreatedAtCycle, + int UpdatedAtCycle, + string? BlockingReason, + string? Notes, + IReadOnlyList AssignedAssets); + +public sealed record FactionObjectiveSnapshot( + string Id, + string Kind, + string State, + float Priority, + string? ParentObjectiveId, + string? TargetFactionId, + string? TargetSystemId, + string? TargetSiteId, + string? TargetRegionId, + string? CommodityId, + string? ModuleId, + int BudgetWeight, + int SlotCost, + int CreatedAtCycle, + int UpdatedAtCycle, + string? InvalidationReason, + string? BlockingReason, + IReadOnlyList PrerequisiteObjectiveIds, + IReadOnlyList AssignedAssets, + IReadOnlyList Steps); public sealed record FactionSnapshot( string Id, @@ -32,8 +149,11 @@ public sealed record FactionSnapshot( int ShipsBuilt, int ShipsLost, string? DefaultPolicySetId, - FactionGoapStateSnapshot? GoapState, - IReadOnlyList? GoapPriorities); + FactionPlanningStateSnapshot? StrategicAssessment, + IReadOnlyList? StrategicPriorities, + FactionBlackboardSnapshot? Blackboard, + IReadOnlyList? Objectives, + IReadOnlyList? IssuedTasks); public sealed record FactionDelta( string Id, @@ -46,5 +166,8 @@ public sealed record FactionDelta( int ShipsBuilt, int ShipsLost, string? DefaultPolicySetId, - FactionGoapStateSnapshot? GoapState, - IReadOnlyList? GoapPriorities); + FactionPlanningStateSnapshot? StrategicAssessment, + IReadOnlyList? StrategicPriorities, + FactionBlackboardSnapshot? Blackboard, + IReadOnlyList? Objectives, + IReadOnlyList? IssuedTasks); diff --git a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs index 222b914..bb16628 100644 --- a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs +++ b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs @@ -27,7 +27,6 @@ public sealed class CommanderRuntime public string? PolicySetId { get; set; } public string? Doctrine { get; set; } public List Goals { get; } = []; - public HashSet ActiveDirectives { get; } = new(StringComparer.Ordinal); public string? ActiveGoalName { get; set; } public string? ActiveActionName { get; set; } public float ReplanTimer { get; set; } @@ -37,8 +36,194 @@ public sealed class CommanderRuntime public CommanderTaskRuntime? ActiveTask { get; set; } public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); public bool IsAlive { get; set; } = true; - public FactionPlanningState? LastPlanningState { get; set; } - public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; } + public FactionPlanningState? LastStrategicAssessment { get; set; } + public IReadOnlyList<(string Name, float Priority)>? LastStrategicPriorities { get; set; } + public FactionBlackboardRuntime? FactionBlackboard { get; set; } + public List Objectives { get; } = []; + public List IssuedTasks { get; } = []; + public int PlanningCycle { get; set; } +} + +public enum FactionObjectiveKind +{ + DestroyFaction, + BootstrapWarIndustry, + BuildShipyard, + BuildAttackFleet, + EnsureCommoditySupply, + EnsureWaterSecurity, + EnsureMiningCapacity, + EnsureConstructionCapacity, + EnsureTransportCapacity, +} + +public enum FactionObjectiveState +{ + Planned, + Active, + Blocked, + Complete, + Failed, + Cancelled, +} + +public enum FactionPlanStepKind +{ + EnsureCommodityProduction, + EnsureShipyardSite, + ProduceFleet, + AttackFactionAssets, + EnsureWaterSupply, + EnsureMiningCapacity, + EnsureConstructionCapacity, + EnsureTransportCapacity, + MonitorExpansionProject, +} + +public enum FactionPlanStepStatus +{ + Planned, + Ready, + Running, + Blocked, + Complete, + Failed, + Cancelled, +} + +public enum FactionIssuedTaskKind +{ + ExpandIndustry, + ProduceShips, + AttackFactionAssets, + SustainWarIndustry, +} + +public enum FactionIssuedTaskState +{ + Planned, + Active, + Blocked, + Complete, + Cancelled, +} + +public sealed class FactionObjectiveRuntime +{ + public required string Id { get; init; } + public required string MergeKey { get; init; } + public required FactionObjectiveKind Kind { get; init; } + public FactionObjectiveState State { get; set; } = FactionObjectiveState.Planned; + public float Priority { get; set; } + public string? ParentObjectiveId { get; set; } + public string? TargetFactionId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetSiteId { get; set; } + public string? TargetRegionId { get; set; } + public string? CommodityId { get; set; } + public string? ModuleId { get; set; } + public int BudgetWeight { get; set; } + public int SlotCost { get; set; } = 1; + public int CreatedAtCycle { get; init; } + public int UpdatedAtCycle { get; set; } + public string? InvalidationReason { get; set; } + public string? BlockingReason { get; set; } + public HashSet PrerequisiteObjectiveIds { get; } = new(StringComparer.Ordinal); + public HashSet AssignedAssetIds { get; } = new(StringComparer.Ordinal); + public List Steps { get; } = []; +} + +public sealed class FactionPlanStepRuntime +{ + public required string Id { get; init; } + public required string ObjectiveId { get; init; } + public required FactionPlanStepKind Kind { get; init; } + public FactionPlanStepStatus Status { get; set; } = FactionPlanStepStatus.Planned; + public float Priority { get; set; } + public string? CommodityId { get; set; } + public string? ModuleId { get; set; } + public string? TargetFactionId { get; set; } + public string? TargetSiteId { get; set; } + public string? BlockingReason { get; set; } + public string? Notes { get; set; } + public int LastEvaluatedCycle { get; set; } + public HashSet DependencyStepIds { get; } = new(StringComparer.Ordinal); + public HashSet RequiredFacts { get; } = new(StringComparer.Ordinal); + public HashSet ProducedFacts { get; } = new(StringComparer.Ordinal); + public HashSet AssignedAssetIds { get; } = new(StringComparer.Ordinal); + public HashSet IssuedTaskIds { get; } = new(StringComparer.Ordinal); +} + +public sealed class FactionIssuedTaskRuntime +{ + public required string Id { get; init; } + public required string MergeKey { get; init; } + public required FactionIssuedTaskKind Kind { get; init; } + public required string ObjectiveId { get; init; } + public required string StepId { get; init; } + public FactionIssuedTaskState State { get; set; } = FactionIssuedTaskState.Planned; + public float Priority { get; set; } + public string? ShipRole { get; set; } + public string? CommodityId { get; set; } + public string? ModuleId { get; set; } + public string? TargetFactionId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetSiteId { get; set; } + public int CreatedAtCycle { get; init; } + public int UpdatedAtCycle { get; set; } + public string? BlockingReason { get; set; } + public string? Notes { get; set; } + public HashSet AssignedAssetIds { get; } = new(StringComparer.Ordinal); +} + +public sealed class FactionBlackboardRuntime +{ + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int TargetWarshipCount { get; set; } + public bool HasWarIndustrySupplyChain { get; set; } + public bool HasShipyard { get; set; } + public bool HasActiveExpansionProject { get; set; } + public string? ActiveExpansionCommodityId { get; set; } + public string? ActiveExpansionModuleId { get; set; } + public string? ActiveExpansionSiteId { get; set; } + public string? ActiveExpansionSystemId { get; set; } + public int EnemyFactionCount { get; set; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } + public int MilitaryShipCount { get; set; } + public int MinerShipCount { get; set; } + public int TransportShipCount { get; set; } + public int ConstructorShipCount { get; set; } + public int ControlledSystemCount { get; set; } + public List CommoditySignals { get; } = []; + public List ThreatSignals { get; } = []; + public HashSet AvailableShipIds { get; } = new(StringComparer.Ordinal); +} + +public sealed class FactionCommoditySignalRuntime +{ + public required string ItemId { get; init; } + public float AvailableStock { get; set; } + public float OnHand { get; set; } + public float ProductionRatePerSecond { get; set; } + public float CommittedProductionRatePerSecond { get; set; } + public float UsageRatePerSecond { get; set; } + public float NetRatePerSecond { get; set; } + public float ProjectedNetRatePerSecond { get; set; } + public float LevelSeconds { get; set; } + public string Level { get; set; } = "unknown"; + public float ProjectedProductionRatePerSecond { get; set; } + public float BuyBacklog { get; set; } + public float ReservedForConstruction { get; set; } +} + +public sealed class FactionThreatSignalRuntime +{ + public required string ScopeId { get; init; } + public required string ScopeKind { get; init; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } } public sealed class CommanderBehaviorRuntime diff --git a/apps/backend/Industry/Planning/CommodityOperationalSignal.cs b/apps/backend/Industry/Planning/CommodityOperationalSignal.cs new file mode 100644 index 0000000..b0c0f7d --- /dev/null +++ b/apps/backend/Industry/Planning/CommodityOperationalSignal.cs @@ -0,0 +1,54 @@ +namespace SpaceGame.Api.Industry.Planning; + +internal static class CommodityOperationalSignal +{ + internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds) + { + var productionDeficit = MathF.Max(0f, commodity.ConsumptionRatePerSecond - commodity.ProjectedProductionRatePerSecond); + var levelDeficit = MathF.Max(0f, targetLevelSeconds - commodity.LevelSeconds) / MathF.Max(targetLevelSeconds, 1f); + var backlogPressure = MathF.Max(0f, commodity.BuyBacklog + commodity.ReservedForConstruction - commodity.AvailableStock); + + var levelWeight = commodity.Level switch + { + CommodityLevelKind.Critical => 140f, + CommodityLevelKind.Low => 80f, + CommodityLevelKind.Stable => 20f, + _ => 0f, + }; + + return levelWeight + + (productionDeficit * 140f) + + (levelDeficit * 120f) + + backlogPressure; + } + + internal static bool IsOperational(FactionCommoditySnapshot commodity, float targetLevelSeconds) => + commodity.ProjectedProductionRatePerSecond > 0.01f + && commodity.ProjectedNetRatePerSecond >= -0.01f + && commodity.LevelSeconds >= targetLevelSeconds + && commodity.Level is CommodityLevelKind.Stable or CommodityLevelKind.Surplus; + + internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) => + !IsOperational(commodity, targetLevelSeconds) + || commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low; + + internal static float ComputeFeasibilityFactor(FactionCommoditySnapshot commodity, float targetLevelSeconds) + { + if (commodity.AvailableStock <= 0.01f && commodity.ProjectedProductionRatePerSecond <= 0.01f) + { + return 0.65f; + } + + if (commodity.Level is CommodityLevelKind.Critical) + { + return 0.72f; + } + + if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds) + { + return 0.84f; + } + + return 1f; + } +} diff --git a/apps/backend/Industry/Planning/FactionEconomySnapshot.cs b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs index 175184d..e84822e 100644 --- a/apps/backend/Industry/Planning/FactionEconomySnapshot.cs +++ b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs @@ -41,50 +41,27 @@ internal sealed class FactionCommoditySnapshot internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond; internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond; internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond; + internal float OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f); + internal float LevelSeconds => AvailableStock <= 0.01f + ? 0f + : AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f); - internal float ShortageHorizonSeconds - { - get - { - if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f) + internal CommodityLevelKind Level => + LevelSeconds switch { - return float.PositiveInfinity; - } + <= 60f => CommodityLevelKind.Critical, + <= 180f => CommodityLevelKind.Low, + <= 480f => CommodityLevelKind.Stable, + _ => CommodityLevelKind.Surplus, + }; +} - if (NetRatePerSecond >= -0.01f) - { - return float.PositiveInfinity; - } - - return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond); - } - } - - internal float ProjectedShortageHorizonSeconds - { - get - { - if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f) - { - return float.PositiveInfinity; - } - - if (ProjectedNetRatePerSecond >= -0.01f) - { - return float.PositiveInfinity; - } - - return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond); - } - } - - internal float PressureScore => - MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound)) - + MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f; - - internal float ProjectedPressureScore => - MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound)) - + MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f; +internal enum CommodityLevelKind +{ + Critical, + Low, + Stable, + Surplus, } internal static class FactionEconomyAnalyzer diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs index 0c0cad7..b9b51fa 100644 --- a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -4,6 +4,9 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; internal static class FactionIndustryPlanner { + private const float CommodityTargetLevelSeconds = 240f; + private const float WaterTargetLevelSeconds = 300f; + internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId) { if (HasActiveExpansionProject(world, factionId)) @@ -64,14 +67,13 @@ internal static class FactionIndustryPlanner .Select(itemId => new { ItemId = itemId, - HasProducer = FactionHasProducerForCommodity(world, factionId, itemId), - Pressure = GetCommodityPressure(world, factionId, itemId), - Stockpile = GetCommodityStockpile(world, factionId, itemId), + Commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId), }) - .Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f) - .OrderByDescending(entry => !entry.HasProducer ? 1 : 0) - .ThenByDescending(entry => entry.Pressure) - .ThenBy(entry => entry.Stockpile) + .Where(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f + || CommodityOperationalSignal.IsStrained(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) + .OrderByDescending(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f ? 1 : 0) + .ThenByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Commodity, GetTargetLevelSeconds(entry.ItemId))) + .ThenBy(entry => entry.Commodity.AvailableStock) .Select(entry => entry.ItemId) .FirstOrDefault(); @@ -301,14 +303,20 @@ internal static class FactionIndustryPlanner .GroupBy(order => order.ItemId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal); - if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships")) + if (CommanderPlanningService.FactionCommanderHasIssuedTask(world, factionId, FactionIssuedTaskKind.ProduceShips, "military")) { demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; } return demandByItem - .Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value)) + .Select(entry => + { + var itemId = ResolveBottleneckCommodity(world, factionId, entry.Key); + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); + var score = entry.Value + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); + return (ItemId: itemId, Score: score); + }) .Where(entry => entry.ItemId is not null) .GroupBy(entry => entry.ItemId!, StringComparer.Ordinal) .Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score))) @@ -358,7 +366,11 @@ internal static class FactionIndustryPlanner var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId) .Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId)) - .Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId))) + .Select(inputId => + { + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); + return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)), Stockpile: commodity.AvailableStock); + }) .OrderByDescending(entry => entry.Score) .ThenBy(entry => entry.Stockpile) .FirstOrDefault(); @@ -370,11 +382,15 @@ internal static class FactionIndustryPlanner } var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId) - .Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId))) + .Select(inputId => + { + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId); + return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId))); + }) .OrderByDescending(entry => entry.Score) .FirstOrDefault(); - return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f + return weakestInput.Score > GetCommodityNeedScore(world, factionId, itemId) * 0.6f ? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited) : itemId; } @@ -419,13 +435,14 @@ internal static class FactionIndustryPlanner && string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal) && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); - private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId) + private static float GetCommodityNeedScore(SimulationWorld world, string factionId, string itemId) { - return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore; + var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId); + return CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId)); } - private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) => - FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock; + private static float GetTargetLevelSeconds(string itemId) => + string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds; private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId) { diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index b6da8c3..19fca17 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -207,8 +207,11 @@ internal sealed class SimulationProjectionService faction.ShipsBuilt, faction.ShipsLost, faction.DefaultPolicySetId, - faction.GoapState, - faction.GoapPriorities)).ToList()); + faction.StrategicAssessment, + faction.StrategicPriorities, + faction.Blackboard, + faction.Objectives, + faction.IssuedTasks)).ToList()); } public void PrimeDeltaBaseline(SimulationWorld world) @@ -515,10 +518,18 @@ internal sealed class SimulationProjectionService private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander) { - var goapSig = commander?.LastGoalPriorities is { } prios + var prioritySig = commander?.LastStrategicPriorities is { } prios ? string.Join(",", prios.Select(p => $"{p.Name}:{p.Priority:0.##}")) : string.Empty; - return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{goapSig}"; + var objectiveSig = commander?.Objectives is { Count: > 0 } objectives + ? string.Join(",", objectives.Select(objective => + $"{objective.Kind}:{objective.State}:{objective.Priority:0.##}:{objective.BlockingReason}:{objective.InvalidationReason}")) + : string.Empty; + var taskSig = commander?.IssuedTasks is { Count: > 0 } tasks + ? string.Join(",", tasks.Select(task => + $"{task.Kind}:{task.State}:{task.Priority:0.##}:{task.ShipRole}:{task.CommodityId}:{task.TargetFactionId}:{task.TargetSiteId}")) + : string.Empty; + return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{prioritySig}|{objectiveSig}|{taskSig}"; } private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( @@ -759,12 +770,15 @@ internal sealed class SimulationProjectionService private static FactionDelta ToFactionDelta(FactionRuntime faction, CommanderRuntime? commander) { - FactionGoapStateSnapshot? goapState = null; - IReadOnlyList? goapPriorities = null; + FactionPlanningStateSnapshot? strategicAssessment = null; + IReadOnlyList? strategicPriorities = null; + FactionBlackboardSnapshot? blackboard = null; + IReadOnlyList? objectives = null; + IReadOnlyList? issuedTasks = null; - if (commander?.LastPlanningState is { } ps) + if (commander?.LastStrategicAssessment is { } ps) { - goapState = new FactionGoapStateSnapshot( + strategicAssessment = new FactionPlanningStateSnapshot( ps.MilitaryShipCount, ps.MinerShipCount, ps.TransportShipCount, @@ -773,20 +787,149 @@ internal sealed class SimulationProjectionService ps.TargetSystemCount, ps.HasShipFactory, NormalizeFiniteFloat(ps.OreStockpile), - NormalizeFiniteFloat(ps.RefinedMetalsStockpile), - NormalizeFiniteFloat(ps.RefinedMetalsProductionRate), - NormalizeFiniteFloat(ps.HullpartsStockpile), - NormalizeFiniteFloat(ps.HullpartsProductionRate), - NormalizeFiniteFloat(ps.ClaytronicsStockpile), - NormalizeFiniteFloat(ps.ClaytronicsProductionRate), - NormalizeFiniteFloat(ps.WaterStockpile), - NormalizeFiniteFloat(ps.WaterProductionRate), - NormalizeFiniteFloat(ps.WaterShortageHorizonSeconds)); + NormalizeFiniteFloat(ps.RefinedMetalsAvailableStock), + NormalizeFiniteFloat(ps.RefinedMetalsUsageRate), + NormalizeFiniteFloat(ps.RefinedMetalsProjectedProductionRate), + NormalizeFiniteFloat(ps.RefinedMetalsProjectedNetRate), + NormalizeFiniteFloat(ps.RefinedMetalsLevelSeconds), + ps.RefinedMetalsLevel, + NormalizeFiniteFloat(ps.HullpartsAvailableStock), + NormalizeFiniteFloat(ps.HullpartsUsageRate), + NormalizeFiniteFloat(ps.HullpartsProjectedProductionRate), + NormalizeFiniteFloat(ps.HullpartsProjectedNetRate), + NormalizeFiniteFloat(ps.HullpartsLevelSeconds), + ps.HullpartsLevel, + NormalizeFiniteFloat(ps.ClaytronicsAvailableStock), + NormalizeFiniteFloat(ps.ClaytronicsUsageRate), + NormalizeFiniteFloat(ps.ClaytronicsProjectedProductionRate), + NormalizeFiniteFloat(ps.ClaytronicsProjectedNetRate), + NormalizeFiniteFloat(ps.ClaytronicsLevelSeconds), + ps.ClaytronicsLevel, + NormalizeFiniteFloat(ps.WaterAvailableStock), + NormalizeFiniteFloat(ps.WaterUsageRate), + NormalizeFiniteFloat(ps.WaterProjectedProductionRate), + NormalizeFiniteFloat(ps.WaterProjectedNetRate), + NormalizeFiniteFloat(ps.WaterLevelSeconds), + ps.WaterLevel); } - if (commander?.LastGoalPriorities is { } prios) + if (commander?.LastStrategicPriorities is { } prios) { - goapPriorities = prios.Select(p => new FactionGoapPrioritySnapshot(p.Name, p.Priority)).ToList(); + strategicPriorities = prios.Select(p => new FactionStrategicPrioritySnapshot(p.Name, p.Priority)).ToList(); + } + + if (commander?.FactionBlackboard is { } bb) + { + blackboard = new FactionBlackboardSnapshot( + bb.PlanCycle, + bb.UpdatedAtUtc, + bb.TargetWarshipCount, + bb.HasWarIndustrySupplyChain, + bb.HasShipyard, + bb.HasActiveExpansionProject, + bb.ActiveExpansionCommodityId, + bb.ActiveExpansionModuleId, + bb.ActiveExpansionSiteId, + bb.ActiveExpansionSystemId, + bb.EnemyFactionCount, + bb.EnemyShipCount, + bb.EnemyStationCount, + bb.MilitaryShipCount, + bb.MinerShipCount, + bb.TransportShipCount, + bb.ConstructorShipCount, + bb.ControlledSystemCount, + bb.CommoditySignals.Select(signal => new FactionCommoditySignalSnapshot( + signal.ItemId, + NormalizeFiniteFloat(signal.AvailableStock), + NormalizeFiniteFloat(signal.OnHand), + NormalizeFiniteFloat(signal.ProductionRatePerSecond), + NormalizeFiniteFloat(signal.CommittedProductionRatePerSecond), + NormalizeFiniteFloat(signal.UsageRatePerSecond), + NormalizeFiniteFloat(signal.NetRatePerSecond), + NormalizeFiniteFloat(signal.ProjectedNetRatePerSecond), + NormalizeFiniteFloat(signal.LevelSeconds), + signal.Level, + NormalizeFiniteFloat(signal.ProjectedProductionRatePerSecond), + NormalizeFiniteFloat(signal.BuyBacklog), + NormalizeFiniteFloat(signal.ReservedForConstruction))).ToList(), + bb.ThreatSignals.Select(signal => new FactionThreatSignalSnapshot( + signal.ScopeId, + signal.ScopeKind, + signal.EnemyShipCount, + signal.EnemyStationCount)).ToList()); + } + + if (commander?.Objectives is { Count: > 0 } runtimeObjectives) + { + objectives = runtimeObjectives + .OrderByDescending(objective => objective.Priority) + .Select(objective => new FactionObjectiveSnapshot( + objective.Id, + objective.Kind.ToString(), + objective.State.ToString(), + objective.Priority, + objective.ParentObjectiveId, + objective.TargetFactionId, + objective.TargetSystemId, + objective.TargetSiteId, + objective.TargetRegionId, + objective.CommodityId, + objective.ModuleId, + objective.BudgetWeight, + objective.SlotCost, + objective.CreatedAtCycle, + objective.UpdatedAtCycle, + objective.InvalidationReason, + objective.BlockingReason, + objective.PrerequisiteObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + objective.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + objective.Steps + .OrderByDescending(step => step.Priority) + .Select(step => new FactionPlanStepSnapshot( + step.Id, + step.Kind.ToString(), + step.Status.ToString(), + step.Priority, + step.CommodityId, + step.ModuleId, + step.TargetFactionId, + step.TargetSiteId, + step.BlockingReason, + step.Notes, + step.LastEvaluatedCycle, + step.DependencyStepIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + step.RequiredFacts.OrderBy(fact => fact, StringComparer.Ordinal).ToList(), + step.ProducedFacts.OrderBy(fact => fact, StringComparer.Ordinal).ToList(), + step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + step.IssuedTaskIds.OrderBy(id => id, StringComparer.Ordinal).ToList())) + .ToList())) + .ToList(); + } + + if (commander?.IssuedTasks is { Count: > 0 } runtimeTasks) + { + issuedTasks = runtimeTasks + .OrderByDescending(task => task.Priority) + .Select(task => new FactionIssuedTaskSnapshot( + task.Id, + task.Kind.ToString(), + task.State.ToString(), + task.ObjectiveId, + task.StepId, + task.Priority, + task.ShipRole, + task.CommodityId, + task.ModuleId, + task.TargetFactionId, + task.TargetSystemId, + task.TargetSiteId, + task.CreatedAtCycle, + task.UpdatedAtCycle, + task.BlockingReason, + task.Notes, + task.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList())) + .ToList(); } return new FactionDelta( @@ -800,8 +943,11 @@ internal sealed class SimulationProjectionService faction.ShipsBuilt, faction.ShipsLost, faction.DefaultPolicySetId, - goapState, - goapPriorities); + strategicAssessment, + strategicPriorities, + blackboard, + objectives, + issuedTasks); } private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index e08959d..c6105de 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -4,6 +4,9 @@ namespace SpaceGame.Api.Stations.Simulation; internal sealed class InfrastructureSimulationService { + private const float CommodityTargetLevelSeconds = 240f; + private const float EnergyTargetLevelSeconds = 240f; + internal void UpdateClaims(SimulationWorld world, ICollection events) { foreach (var claim in world.Claims) @@ -259,16 +262,19 @@ internal sealed class InfrastructureSimulationService var currentCount = CountModules(station.InstalledModules, objectiveModuleId); var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId); var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem); - var score = 90f + commodity.ProjectedPressureScore + (marginalOutputRate * 900f) + constructionImpact; + var score = 90f + + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(objectiveCommodityId)) + + (marginalOutputRate * 900f) + + constructionImpact; if (currentCount == 0) { score += 80f; } - if (!float.IsPositiveInfinity(commodity.ProjectedShortageHorizonSeconds)) + if (commodity.LevelSeconds < GetTargetLevelSeconds(objectiveCommodityId)) { - score += MathF.Max(0f, 300f - commodity.ProjectedShortageHorizonSeconds) * 0.3f; + score += MathF.Max(0f, GetTargetLevelSeconds(objectiveCommodityId) - commodity.LevelSeconds) * 0.3f; } score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId); @@ -287,16 +293,19 @@ internal sealed class InfrastructureSimulationService var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01"); var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem); var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01"); - var score = 40f + energy.ProjectedPressureScore * 0.5f + constructionImpact + readinessUnlock; + var score = 40f + + CommodityOperationalSignal.ComputeNeedScore(energy, EnergyTargetLevelSeconds) * 0.5f + + constructionImpact + + readinessUnlock; if (currentCount == 0) { score += 70f; } - if (!float.IsPositiveInfinity(energy.ProjectedShortageHorizonSeconds)) + if (energy.LevelSeconds < EnergyTargetLevelSeconds) { - score += MathF.Max(0f, 240f - energy.ProjectedShortageHorizonSeconds) * 0.2f; + score += MathF.Max(0f, EnergyTargetLevelSeconds - energy.LevelSeconds) * 0.2f; } return score - (currentCount * 40f); @@ -433,17 +442,9 @@ internal sealed class InfrastructureSimulationService foreach (var input in recipe.Inputs) { var inputCommodity = economy.GetCommodity(input.ItemId); - if (inputCommodity.AvailableStock <= 0.01f && inputCommodity.ProjectedProductionRatePerSecond <= 0.01f) - { - feasibility *= 0.65f; - continue; - } - - if (!float.IsPositiveInfinity(inputCommodity.ProjectedShortageHorizonSeconds) - && inputCommodity.ProjectedShortageHorizonSeconds < 180f) - { - feasibility *= 0.82f; - } + feasibility *= CommodityOperationalSignal.ComputeFeasibilityFactor( + inputCommodity, + GetTargetLevelSeconds(input.ItemId)); } } @@ -505,7 +506,8 @@ internal sealed class InfrastructureSimulationService { inputFactor *= 0.95f + (availableStockRatio * 0.05f); } - else if (commodity.ProjectedProductionRatePerSecond > 0.01f) + else if (commodity.ProjectedProductionRatePerSecond > 0.01f + && commodity.Level is not CommodityLevelKind.Critical) { inputFactor *= 0.82f + (availableStockRatio * 0.08f); } @@ -719,6 +721,11 @@ internal sealed class InfrastructureSimulationService private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) => world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal); + private static float GetTargetLevelSeconds(string commodityId) => + string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : + string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : + CommodityTargetLevelSeconds; + internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) { var nextModuleId = GetNextStationModuleToBuild(station, world); diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index f8da69f..d40ca7d 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -44,7 +44,7 @@ internal sealed class StationSimulationService var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") - && FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") + && FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") ? 90f : 0f; @@ -163,14 +163,33 @@ internal sealed class StationSimulationService var priority = (float)recipe.Priority; var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); - var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f; - priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure); + var fleetPressure = FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") ? 1f : 0f; + priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure); return priority; } - private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure) + private static float GetStationRecipePriorityAdjustment(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure) { + if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) + { + var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind); + return shipDefinition.Kind switch + { + "military" => recipe.Id switch + { + "frigate-construction" => 320f * shipPressure, + "destroyer-construction" => 200f * shipPressure, + "cruiser-construction" => 120f * shipPressure, + _ => 160f * shipPressure, + }, + "construction" => 260f * shipPressure, + "mining" => 250f * shipPressure, + "transport" => 230f * shipPressure, + _ => 0f, + }; + } + var outputItemIds = recipe.Outputs .Select(output => output.ItemId) .ToHashSet(StringComparer.Ordinal); @@ -201,9 +220,6 @@ internal sealed class StationSimulationService { "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" => 220f * MathF.Max(expansionPressure, fleetPressure), - "frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure), - "destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure), - "cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure), "ammo-fabrication" => -80f * expansionPressure, "trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly" => -120f * expansionPressure, @@ -228,8 +244,7 @@ internal sealed class StationSimulationService return false; } - if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal) - || !FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")) + if (!FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, shipDefinition.Kind)) { return false; } @@ -431,16 +446,17 @@ internal sealed class StationSimulationService private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve) { var commodity = economy.GetCommodity(itemId); - if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds)) + if (commodity.Level == CommodityLevelKind.Critical) { - return MathF.Max(0f, baseReserve); + return baseReserve * 1.6f; } - return commodity.ShortageHorizonSeconds < 180f - ? baseReserve * 1.5f - : commodity.ShortageHorizonSeconds < 360f - ? baseReserve * 1.2f - : baseReserve; + return commodity.Level switch + { + CommodityLevelKind.Low => baseReserve * 1.25f, + CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseReserve * 1.1f, + _ => MathF.Max(0f, baseReserve), + }; } private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger) @@ -452,24 +468,36 @@ internal sealed class StationSimulationService private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) { var commodity = economy.GetCommodity(itemId); - if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds)) + return commodity.Level switch { - return commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.3f; - } - - return commodity.ShortageHorizonSeconds < 180f - ? baseValuation * 1.5f - : commodity.ShortageHorizonSeconds < 360f - ? baseValuation * 1.25f - : baseValuation; + CommodityLevelKind.Critical => baseValuation * 1.6f, + CommodityLevelKind.Low => baseValuation * 1.3f, + CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseValuation * 1.15f, + CommodityLevelKind.Surplus when commodity.ProjectedNetRatePerSecond > 0.01f => baseValuation * 0.9f, + _ => commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.15f, + }; } private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) { var commodity = economy.GetCommodity(itemId); - return commodity.NetRatePerSecond > 0.01f && commodity.ShortageHorizonSeconds > 600f + return commodity.Level == CommodityLevelKind.Surplus && commodity.NetRatePerSecond > 0.01f ? baseValuation * 0.75f - : baseValuation; + : commodity.Level == CommodityLevelKind.Critical + ? baseValuation * 1.15f + : baseValuation; + } + + private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind) + { + var factionCommander = FindFactionCommander(world, factionId); + var task = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ProduceShips, shipKind); + if (task is null) + { + return 0f; + } + + return task.State == FactionIssuedTaskState.Blocked ? 0.4f : 1f; } private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)