diff --git a/apps/backend/Economy/Contracts/Economy.cs b/apps/backend/Economy/Contracts/Economy.cs index 23a80ce..668396f 100644 --- a/apps/backend/Economy/Contracts/Economy.cs +++ b/apps/backend/Economy/Contracts/Economy.cs @@ -35,7 +35,11 @@ public sealed record PolicySetSnapshot( string TradeAccessPolicy, string DockingAccessPolicy, string ConstructionAccessPolicy, - string OperationalRangePolicy); + string OperationalRangePolicy, + string CombatEngagementPolicy, + bool AvoidHostileSystems, + float FleeHullRatio, + IReadOnlyList BlacklistedSystemIds); public sealed record PolicySetDelta( string Id, @@ -44,4 +48,8 @@ public sealed record PolicySetDelta( string TradeAccessPolicy, string DockingAccessPolicy, string ConstructionAccessPolicy, - string OperationalRangePolicy); + string OperationalRangePolicy, + string CombatEngagementPolicy, + bool AvoidHostileSystems, + float FleeHullRatio, + IReadOnlyList BlacklistedSystemIds); diff --git a/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs b/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs index e344b92..8d97348 100644 --- a/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs +++ b/apps/backend/Economy/Runtime/CommerceRuntimeModels.cs @@ -26,5 +26,9 @@ public sealed class PolicySetRuntime public string DockingAccessPolicy { get; set; } = "owner-and-allies"; public string ConstructionAccessPolicy { get; set; } = "owner-only"; public string OperationalRangePolicy { get; set; } = "unrestricted"; + public string CombatEngagementPolicy { get; set; } = "defensive"; + public bool AvoidHostileSystems { get; set; } = true; + public float FleeHullRatio { get; set; } = 0.35f; + public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); public string LastDeltaSignature { get; set; } = string.Empty; } diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 363f70e..e359c61 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -1,77 +1,296 @@ +using SpaceGame.Api.Industry.Planning; +using SpaceGame.Api.Stations.Simulation; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Factions.AI; internal sealed class CommanderPlanningService { - private const float FactionCommanderReplanInterval = 10f; - private const float ShipCommanderReplanInterval = 5f; - private readonly FactionObjectivePlanner _objectivePlanner = new(); - private readonly FactionObjectiveExecutor _objectiveExecutor = new(); + private const float FactionCommanderReplanInterval = 8f; + private const float FleetCommanderReplanInterval = 4f; + private const float StationCommanderReplanInterval = 5f; + private const float ShipCommanderReplanInterval = 3f; + private const int MaxDecisionLogEntries = 40; + private const int MaxOutcomeEntries = 32; + private const int MaxAiOrdersPerShip = 2; - private static readonly GoapPlanner _shipPlanner = new(s => s.Clone()); - - private static readonly IReadOnlyList> _factionGoals = - [ - new ExterminateRivalGoal(), - new EnsureWarIndustryGoal(), - new ExpandTerritoryGoal(), - new EnsureWarFleetGoal(), - new EnsureWaterSecurityGoal(), - new EnsureMiningCapacityGoal(), - new EnsureConstructionCapacityGoal(), - ]; - - private static readonly IReadOnlyList> _shipActions = - [ - new SetAttackObjectiveAction(), - new SetMiningObjectiveAction(), - new SetPatrolObjectiveAction(), - new SetConstructionObjectiveAction(), - new SetTradeObjectiveAction(), - new SetIdleObjectiveAction(), - ]; - - private static readonly GoapGoal _shipGoal = new AssignObjectiveGoal(); - - internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection events) + internal void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection events) { - // Faction commanders run first so their directives are available to ship commanders in the same tick. + EnsureHierarchy(world); + foreach (var commander in world.Commanders) { - if (!commander.IsAlive || commander.Kind != CommanderKind.Faction) + if (!commander.IsAlive) { continue; } - TickCommander(commander, deltaSeconds); - UpdateFactionCommander(engine, world, commander); + if (commander.ReplanTimer > 0f) + { + commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds); + } } - foreach (var commander in world.Commanders) + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList()) { - if (!commander.IsAlive || commander.Kind != CommanderKind.Ship) + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) { continue; } - TickCommander(commander, deltaSeconds); - UpdateShipCommander(engine, world, commander); + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateFactionCommander(world, commander, events); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Fleet).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateFleetCommander(world, commander); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Station).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateStationCommander(world, commander); + } + + foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Ship).ToList()) + { + if (PlayerFactionService.IsPlayerFaction(world, commander.FactionId)) + { + continue; + } + + if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + { + continue; + } + + UpdateShipCommander(world, commander, events); } } - private static void TickCommander(CommanderRuntime commander, float deltaSeconds) + internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => + world.Commanders.FirstOrDefault(commander => + commander.Kind == CommanderKind.Faction && + commander.FactionId == factionId); + + internal static FactionRuntime? FindFaction(SimulationWorld world, string factionId) => + world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, factionId, StringComparison.Ordinal)); + + internal static FactionStrategicStateRuntime? FindFactionStrategicState(SimulationWorld world, string factionId) => + FindFaction(world, factionId)?.StrategicState; + + internal static FactionEconomicAssessmentRuntime? FindFactionEconomicAssessment(SimulationWorld world, string factionId) => + FindFactionStrategicState(world, factionId)?.EconomicAssessment; + + internal static FactionThreatAssessmentRuntime? FindFactionThreatAssessment(SimulationWorld world, string factionId) => + FindFactionStrategicState(world, factionId)?.ThreatAssessment; + + private static void EnsureHierarchy(SimulationWorld world) { - if (commander.ReplanTimer > 0f) + var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); + var factionCommanders = world.Commanders + .Where(commander => commander.Kind == CommanderKind.Faction) + .ToDictionary(commander => commander.FactionId, StringComparer.Ordinal); + + foreach (var faction in world.Factions) { - commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds); + EnsureFactionStateDefaults(world, faction); + + if (!factionCommanders.TryGetValue(faction.Id, out var commander)) + { + commander = new CommanderRuntime + { + Id = $"commander-faction-{faction.Id}", + Kind = CommanderKind.Faction, + FactionId = faction.Id, + ControlledEntityId = faction.Id, + PolicySetId = faction.DefaultPolicySetId, + Doctrine = "strategic-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 5 }, + }; + world.Commanders.Add(commander); + commandersById[commander.Id] = commander; + factionCommanders[faction.Id] = commander; + } + } + + foreach (var commander in world.Commanders) + { + commander.SubordinateCommanderIds.Clear(); + } + + var stationCommanders = new Dictionary(StringComparer.Ordinal); + foreach (var station in world.Stations) + { + if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander)) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Station && + string.Equals(candidate.ControlledEntityId, station.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-station-{station.Id}", + Kind = CommanderKind.Station, + FactionId = station.FactionId, + ControlledEntityId = station.Id, + Doctrine = "station-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = 3, + Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), + Strategy = 3, + }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = parentCommander.Id; + commander.PolicySetId = parentCommander.PolicySetId; + station.CommanderId = commander.Id; + station.PolicySetId ??= parentCommander.PolicySetId; + stationCommanders[station.Id] = commander; + } + + foreach (var ship in world.Ships) + { + if (!factionCommanders.TryGetValue(ship.FactionId, out var factionCommander)) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Ship && + string.Equals(candidate.ControlledEntityId, ship.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = ship.FactionId, + ControlledEntityId = ship.Id, + Doctrine = "ship-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), + Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), + Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), + }, + }; + world.Commanders.Add(commander); + } + + var parentCommander = ResolveShipParentCommander(world, ship, factionCommander, stationCommanders); + commander.ParentCommanderId = parentCommander.Id; + commander.PolicySetId = parentCommander.PolicySetId; + ship.CommanderId = commander.Id; + ship.PolicySetId ??= parentCommander.PolicySetId; + } + + foreach (var commander in world.Commanders) + { + if (commander.ParentCommanderId is not null && commandersById.TryGetValue(commander.ParentCommanderId, out var parent)) + { + parent.SubordinateCommanderIds.Add(commander.Id); + } + } + + foreach (var faction in world.Factions) + { + faction.CommanderIds.Clear(); + } + + foreach (var commander in world.Commanders) + { + if (world.Factions.FirstOrDefault(faction => faction.Id == commander.FactionId) is { } faction) + { + faction.CommanderIds.Add(commander.Id); + } } } - private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) + private static void EnsureFactionStateDefaults(SimulationWorld world, FactionRuntime faction) { - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + faction.Doctrine.StrategicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.StrategicPosture) ? "balanced" : faction.Doctrine.StrategicPosture; + faction.Doctrine.ExpansionPosture = string.IsNullOrWhiteSpace(faction.Doctrine.ExpansionPosture) ? "measured" : faction.Doctrine.ExpansionPosture; + faction.Doctrine.MilitaryPosture = string.IsNullOrWhiteSpace(faction.Doctrine.MilitaryPosture) ? "defensive" : faction.Doctrine.MilitaryPosture; + faction.Doctrine.EconomicPosture = string.IsNullOrWhiteSpace(faction.Doctrine.EconomicPosture) ? "self-sufficient" : faction.Doctrine.EconomicPosture; + faction.Doctrine.DesiredControlledSystems = Math.Max(2, Math.Min(world.Systems.Count, faction.Doctrine.DesiredControlledSystems <= 0 ? 3 : faction.Doctrine.DesiredControlledSystems)); + faction.Doctrine.DesiredMilitaryPerFront = Math.Max(1, faction.Doctrine.DesiredMilitaryPerFront); + faction.Doctrine.DesiredMinersPerSystem = Math.Max(1, faction.Doctrine.DesiredMinersPerSystem); + faction.Doctrine.DesiredTransportsPerSystem = Math.Max(1, faction.Doctrine.DesiredTransportsPerSystem); + faction.Doctrine.DesiredConstructors = Math.Max(1, faction.Doctrine.DesiredConstructors); + faction.Doctrine.ReserveCreditsRatio = ClampRatio(faction.Doctrine.ReserveCreditsRatio, 0.2f); + faction.Doctrine.ExpansionBudgetRatio = ClampRatio(faction.Doctrine.ExpansionBudgetRatio, 0.25f); + faction.Doctrine.WarBudgetRatio = ClampRatio(faction.Doctrine.WarBudgetRatio, 0.35f); + faction.Doctrine.ReserveMilitaryRatio = ClampRatio(faction.Doctrine.ReserveMilitaryRatio, 0.2f); + faction.Doctrine.OffensiveReadinessThreshold = ClampRatio(faction.Doctrine.OffensiveReadinessThreshold, 0.62f); + faction.Doctrine.SupplySecurityBias = ClampRatio(faction.Doctrine.SupplySecurityBias, 0.55f); + faction.Doctrine.FailureAversion = ClampRatio(faction.Doctrine.FailureAversion, 0.45f); + faction.Doctrine.ReinforcementLeadPerFront = Math.Max(1, faction.Doctrine.ReinforcementLeadPerFront); + } + + private static float ClampRatio(float value, float fallback) => + value is >= 0f and <= 1f ? value : fallback; + + private static CommanderRuntime ResolveShipParentCommander( + SimulationWorld world, + ShipRuntime ship, + CommanderRuntime factionCommander, + IReadOnlyDictionary stationCommanders) + { + if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) { + return factionCommander; + } + + var stationCommander = world.Stations + .Where(station => string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .Select(station => station.CommanderId is not null && stationCommanders.TryGetValue(station.Id, out var commander) + ? commander + : null) + .FirstOrDefault(candidate => candidate is not null); + + return stationCommander ?? factionCommander; + } + + private static void UpdateFactionCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) + { + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.IsAlive = false; return; } @@ -79,290 +298,3117 @@ internal sealed class CommanderPlanningService commander.NeedsReplan = false; commander.PlanningCycle += 1; - var state = BuildFactionPlanningState(world, commander.FactionId); + EnsureFactionStateDefaults(world, faction); - var rankedGoals = _factionGoals - .Select(g => (goal: g, priority: g.ComputePriority(state, world, commander))) - .Where(x => x.priority > 0f) - .OrderByDescending(x => x.priority) - .ToList(); + var nowUtc = DateTimeOffset.UtcNow; + var previousTheaters = faction.StrategicState.Theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); + var previousCampaigns = faction.StrategicState.Campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); + var previousObjectives = faction.StrategicState.Objectives.ToDictionary(objective => objective.Id, StringComparer.Ordinal); + var previousPrograms = faction.StrategicState.ProductionPrograms.ToDictionary(program => program.Id, StringComparer.Ordinal); - 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); + var economy = FactionEconomyAnalyzer.Build(world, faction.Id); + var expansionProject = ResolveExpansionProject(world, faction); + var threatAssessment = BuildThreatAssessment(world, faction, commander, nowUtc); + var economicAssessment = BuildEconomicAssessment(world, faction, commander, economy, expansionProject, threatAssessment, nowUtc); + UpdateDoctrine(world, faction, threatAssessment, economicAssessment, expansionProject); + UpdateBudget(faction, threatAssessment, economicAssessment); + UpdateMemory(world, faction, threatAssessment, economicAssessment, nowUtc); + + if (expansionProject is not null + && economicAssessment.ConstructorShipCount > 0 + && faction.StrategicState.Budget.ExpansionCredits > 0f) + { + FactionIndustryPlanner.EnsureExpansionSite(world, faction.Id, expansionProject); + expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) ?? expansionProject; + economicAssessment.PrimaryExpansionSiteId = expansionProject.SiteId; + economicAssessment.PrimaryExpansionSystemId = expansionProject.SystemId; + } + + var theaters = BuildTheaters(world, faction, threatAssessment, economicAssessment, expansionProject, nowUtc); + var campaigns = BuildCampaigns(world, faction, theaters, threatAssessment, economicAssessment, expansionProject, previousCampaigns, nowUtc); + var theatersById = theaters.ToDictionary(theater => theater.Id, StringComparer.Ordinal); + foreach (var campaign in campaigns) + { + if (campaign.TheaterId is not null && theatersById.TryGetValue(campaign.TheaterId, out var theater)) + { + theater.CampaignIds.Add(campaign.Id); + } + } + + var objectives = BuildObjectives(world, faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousObjectives, nowUtc); + var reservations = BuildReservations(world, faction, objectives, nowUtc); + var programs = BuildProductionPrograms(faction, theaters, campaigns, economicAssessment, threatAssessment, expansionProject, previousPrograms); + + ReconcileCampaignLifecycle(world, faction, previousCampaigns, campaigns, economicAssessment, threatAssessment, nowUtc); + ReconcileObjectiveLifecycle(faction, previousObjectives, objectives, nowUtc); + ReconcileTheaterLifecycle(faction, previousTheaters, theaters, nowUtc); + ReconcileProgramLifecycle(faction, previousPrograms, programs, nowUtc); + + faction.Memory.LastPlanCycle = commander.PlanningCycle; + faction.Memory.UpdatedAtUtc = nowUtc; + faction.StrategicState.PlanCycle = commander.PlanningCycle; + faction.StrategicState.UpdatedAtUtc = nowUtc; + faction.StrategicState.Status = ResolveStrategicStatus(theaters, campaigns, economicAssessment, threatAssessment); + faction.StrategicState.EconomicAssessment = economicAssessment; + faction.StrategicState.ThreatAssessment = threatAssessment; + faction.StrategicState.Theaters.Clear(); + faction.StrategicState.Theaters.AddRange(theaters); + faction.StrategicState.Campaigns.Clear(); + faction.StrategicState.Campaigns.AddRange(campaigns); + faction.StrategicState.Objectives.Clear(); + faction.StrategicState.Objectives.AddRange(objectives); + faction.StrategicState.Reservations.Clear(); + faction.StrategicState.Reservations.AddRange(reservations); + faction.StrategicState.ProductionPrograms.Clear(); + faction.StrategicState.ProductionPrograms.AddRange(programs); + + ApplyDelegation(world, faction, commander, events, nowUtc); } - private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) + private static void UpdateStationCommander(SimulationWorld world, CommanderRuntime commander) { - if (commander.ReplanTimer > 0f && !commander.NeedsReplan) + commander.ReplanTimer = StationCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); + + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (station is null) { + commander.IsAlive = false; + commander.Assignment = null; return; } + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.Assignment = null; + return; + } + + var objective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (objective is not null) + { + commander.ActiveObjectiveIds.Add(objective.Id); + commander.Assignment = ToAssignment(objective); + return; + } + + var activeSite = world.ConstructionSites + .Where(site => + site.FactionId == station.FactionId && + site.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned && + (string.Equals(site.StationId, station.Id, StringComparison.Ordinal) || string.Equals(site.SystemId, station.SystemId, StringComparison.Ordinal))) + .OrderByDescending(site => string.Equals(site.StationId, station.Id, StringComparison.Ordinal) ? 1 : 0) + .ThenBy(site => site.Id, StringComparer.Ordinal) + .FirstOrDefault(); + var strategicAssignment = BuildStationFocusAssignment(world, faction, station, activeSite); + commander.Assignment = strategicAssignment; + } + + private static void UpdateShipCommander(SimulationWorld world, CommanderRuntime commander, ICollection events) + { commander.ReplanTimer = ShipCommanderReplanInterval; commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); if (ship is null) + { + commander.IsAlive = false; + commander.Assignment = null; + return; + } + + var faction = FindFaction(world, commander.FactionId); + if (faction is null) + { + commander.Assignment = null; + return; + } + + var assignedObjective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + var nextAssignment = assignedObjective is null + ? null + : ToAssignment(assignedObjective); + if (assignedObjective is not null) + { + commander.ActiveObjectiveIds.Add(assignedObjective.Id); + } + + if (!AssignmentsEqual(commander.Assignment, nextAssignment)) + { + commander.Assignment = nextAssignment; + ship.NeedsReplan = true; + events.Add(new SimulationEventRecord( + "ship", + ship.Id, + nextAssignment is null ? "assignment-cleared" : "assignment-updated", + nextAssignment is null + ? $"{ship.Definition.Label} returned to default behavior." + : $"{ship.Definition.Label} assigned to {nextAssignment.Kind}.", + DateTimeOffset.UtcNow)); + } + } + + private static IndustryExpansionProject? ResolveExpansionProject(SimulationWorld world, FactionRuntime faction) => + FactionIndustryPlanner.GetActiveExpansionProject(world, faction.Id) + ?? FactionIndustryPlanner.AnalyzeExpansionNeed(world, faction.Id) + ?? FactionIndustryPlanner.AnalyzeShipyardNeed(world, faction.Id); + + private static FactionThreatAssessmentRuntime BuildThreatAssessment( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime commander, + DateTimeOffset nowUtc) + { + var assessment = new FactionThreatAssessmentRuntime + { + PlanCycle = commander.PlanningCycle, + UpdatedAtUtc = nowUtc, + EnemyFactionCount = world.Factions.Count(candidate => candidate.Id != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, candidate.Id)), + EnemyShipCount = world.Ships.Count(ship => ship.Health > 0f && ship.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId)), + EnemyStationCount = world.Stations.Count(station => station.FactionId != faction.Id && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId)), + }; + + var controlledSystems = GeopoliticalSimulationService.GetControlledSystems(world, faction.Id) + .ToHashSet(StringComparer.Ordinal); + var factionStationSystems = world.Stations + .Where(station => station.FactionId == faction.Id) + .Select(station => station.SystemId) + .ToHashSet(StringComparer.Ordinal); + var borderTensions = world.Geopolitics?.Diplomacy.BorderTensions + .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) + || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) + .ToList() ?? []; + var activeWars = world.Geopolitics?.Diplomacy.Wars + .Where(war => war.Status == "active" + && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) + || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) + .ToList() ?? []; + + var threatSignals = world.Systems + .Select(system => + { + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, system.Definition.Id); + var strategicProfile = FindStrategicProfile(world, system.Definition.Id); + var territoryPressure = FindTerritoryPressure(world, faction.Id, system.Definition.Id); + var systemTensions = borderTensions + .Where(tension => tension.SystemIds.Contains(system.Definition.Id, StringComparer.Ordinal)) + .ToList(); + var enemyShips = world.Ships + .Where(ship => ship.Health > 0f + && ship.FactionId != faction.Id + && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, ship.FactionId) + && ship.SystemId == system.Definition.Id) + .ToList(); + var enemyStations = world.Stations + .Where(station => station.FactionId != faction.Id + && GeopoliticalSimulationService.HasHostileRelation(world, faction.Id, station.FactionId) + && station.SystemId == system.Definition.Id) + .ToList(); + if (enemyShips.Count == 0 && enemyStations.Count == 0 && systemTensions.Count == 0 && (territoryPressure?.PressureScore ?? 0f) < 0.08f) + { + return null; + } + + var primaryEnemyFactionId = enemyShips + .GroupBy(ship => ship.FactionId, StringComparer.Ordinal) + .Select(group => (FactionId: group.Key, Weight: group.Count() * 10)) + .Concat(enemyStations + .GroupBy(station => station.FactionId, StringComparer.Ordinal) + .Select(group => (FactionId: group.Key, Weight: group.Count() * 25))) + .GroupBy(entry => entry.FactionId, StringComparer.Ordinal) + .OrderByDescending(group => group.Sum(entry => entry.Weight)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.Key) + .FirstOrDefault(); + + var warBias = activeWars.Any(war => string.Equals(war.FactionAId, primaryEnemyFactionId, StringComparison.Ordinal) || string.Equals(war.FactionBId, primaryEnemyFactionId, StringComparison.Ordinal)) + ? 24f + : 0f; + var priorityBias = controlledSystems.Contains(system.Definition.Id) ? 40 : factionStationSystems.Contains(system.Definition.Id) ? 25 : 0; + var diplomacyBias = systemTensions.Sum(tension => tension.TensionScore * 26f) + systemTensions.Sum(tension => tension.AccessFriction * 12f); + var territoryBias = (territoryPressure?.PressureScore ?? 0f) * 35f + + ((strategicProfile?.IsContested ?? false) ? 24f : 0f) + + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 12f); + var scopeKind = controlState?.IsContested == true || (factionStationSystems.Contains(system.Definition.Id) && !controlledSystems.Contains(system.Definition.Id)) + ? "contested-system" + : controlledSystems.Contains(system.Definition.Id) + ? "controlled-system" + : "hostile-system"; + return new + { + Signal = new FactionThreatSignalRuntime + { + ScopeId = system.Definition.Id, + ScopeKind = scopeKind, + EnemyShipCount = enemyShips.Count, + EnemyStationCount = enemyStations.Count, + EnemyFactionId = primaryEnemyFactionId, + }, + Score = (enemyStations.Count * 30) + (enemyShips.Count * 10) + priorityBias + diplomacyBias + territoryBias + warBias, + }; + }) + .Where(entry => entry is not null) + .Select(entry => entry!) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Signal.ScopeId, StringComparer.Ordinal) + .ToList(); + + assessment.ThreatSignals.AddRange(threatSignals.Select(entry => entry.Signal)); + assessment.PrimaryThreatSystemId = threatSignals.FirstOrDefault()?.Signal.ScopeId; + assessment.PrimaryThreatFactionId = threatSignals.FirstOrDefault()?.Signal.EnemyFactionId; + return assessment; + } + + private static FactionEconomicAssessmentRuntime BuildEconomicAssessment( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime commander, + FactionEconomySnapshot economy, + IndustryExpansionProject? expansionProject, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) + { + var controlledSystems = StationSimulationService.GetFactionControlledSystemsCount(world, faction.Id); + var frontCount = Math.Max(1, + threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system") + + (expansionProject is null ? 0 : 1)); + var militaryShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "military"); + var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && HasShipCapabilities(ship.Definition, "mining")); + var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "transport"); + var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && ship.Definition.Kind == "construction"); + var hasShipyard = world.Stations.Any(station => + string.Equals(station.FactionId, faction.Id, StringComparison.Ordinal) && + station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)); + var hasWarIndustrySupplyChain = HasOperationalWarIndustry(economy); + var factionRegions = world.Geopolitics?.EconomyRegions.Regions + .Where(region => string.Equals(region.FactionId, faction.Id, StringComparison.Ordinal)) + .ToList() ?? []; + var regionSecurity = world.Geopolitics?.EconomyRegions.SecurityAssessments + .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) + .ToList() ?? []; + var regionEconomics = world.Geopolitics?.EconomyRegions.EconomicAssessments + .Where(assessment => factionRegions.Any(region => region.Id == assessment.RegionId)) + .ToList() ?? []; + var regionalBottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => factionRegions.Any(region => region.Id == bottleneck.RegionId)) + .ToList() ?? []; + var corridorRisk = world.Geopolitics?.EconomyRegions.Corridors + .Where(corridor => string.Equals(corridor.FactionId, faction.Id, StringComparison.Ordinal)) + .DefaultIfEmpty() + .Average(corridor => corridor?.RiskScore ?? 0f) ?? 0f; + var regionalSupplyRisk = regionSecurity.Count == 0 ? 0f : regionSecurity.Average(assessment => assessment.SupplyRisk); + var regionalSustainment = regionEconomics.Count == 0 ? 1f : regionEconomics.Average(assessment => assessment.SustainmentScore); + + var assessment = new FactionEconomicAssessmentRuntime + { + PlanCycle = commander.PlanningCycle, + UpdatedAtUtc = nowUtc, + MilitaryShipCount = militaryShipCount, + MinerShipCount = minerShipCount, + TransportShipCount = transportShipCount, + ConstructorShipCount = constructorShipCount, + ControlledSystemCount = controlledSystems, + TargetMilitaryShipCount = Math.Max(frontCount * faction.Doctrine.DesiredMilitaryPerFront, controlledSystems + 2), + TargetMinerShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredMinersPerSystem, 2), + TargetTransportShipCount = Math.Max(controlledSystems * faction.Doctrine.DesiredTransportsPerSystem, 2), + TargetConstructorShipCount = faction.Doctrine.DesiredConstructors, + HasShipyard = hasShipyard, + HasWarIndustrySupplyChain = hasWarIndustrySupplyChain, + PrimaryExpansionSiteId = expansionProject?.SiteId, + PrimaryExpansionSystemId = expansionProject?.SystemId, + ReplacementPressure = MathF.Max(0f, (faction.ShipsLost - faction.Memory.LastObservedShipsLost) * 6f) + + MathF.Max(0f, frontCount - Math.Max(1, militaryShipCount)) + + (regionalSupplyRisk * 4f), + }; + + assessment.CommoditySignals.AddRange( + economy.Commodities + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => new FactionCommoditySignalRuntime + { + ItemId = entry.Key, + AvailableStock = entry.Value.AvailableStock, + OnHand = entry.Value.OnHand, + ProductionRatePerSecond = entry.Value.ProductionRatePerSecond, + CommittedProductionRatePerSecond = entry.Value.CommittedProductionRatePerSecond, + UsageRatePerSecond = entry.Value.OperationalUsageRatePerSecond, + NetRatePerSecond = entry.Value.NetRatePerSecond, + ProjectedNetRatePerSecond = entry.Value.ProjectedNetRatePerSecond, + LevelSeconds = entry.Value.LevelSeconds, + Level = entry.Value.Level.ToString().ToLowerInvariant(), + ProjectedProductionRatePerSecond = entry.Value.ProjectedProductionRatePerSecond, + BuyBacklog = entry.Value.BuyBacklog, + ReservedForConstruction = entry.Value.ReservedForConstruction, + })); + + assessment.CriticalShortageCount = assessment.CommoditySignals.Count(signal => signal.Level is "critical" or "low") + + regionalBottlenecks.Count(bottleneck => bottleneck.Severity >= 2.5f); + assessment.IndustrialBottleneckItemId = assessment.CommoditySignals + .OrderByDescending(ComputeCommodityPriority) + .ThenBy(signal => signal.ItemId, StringComparer.Ordinal) + .Select(signal => signal.ItemId) + .FirstOrDefault() + ?? regionalBottlenecks + .OrderByDescending(bottleneck => bottleneck.Severity) + .ThenBy(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) + .Select(bottleneck => bottleneck.ItemId) + .FirstOrDefault(); + + if (assessment.PrimaryExpansionSystemId is null) + { + assessment.PrimaryExpansionSystemId = factionRegions + .Join(regionEconomics, region => region.Id, economicState => economicState.RegionId, (region, economicState) => new { region, economicState }) + .OrderByDescending(entry => entry.economicState.ConstructionPressure) + .ThenByDescending(entry => entry.economicState.CorridorDependency) + .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) + .Select(entry => entry.region.CoreSystemId) + .FirstOrDefault(); + } + + var transportCoverage = assessment.TargetTransportShipCount <= 0 + ? 1f + : Math.Clamp(assessment.TransportShipCount / (float)assessment.TargetTransportShipCount, 0f, 1.35f); + var minerCoverage = assessment.TargetMinerShipCount <= 0 + ? 1f + : Math.Clamp(assessment.MinerShipCount / (float)assessment.TargetMinerShipCount, 0f, 1.35f); + var constructorCoverage = assessment.TargetConstructorShipCount <= 0 + ? 1f + : Math.Clamp(assessment.ConstructorShipCount / (float)assessment.TargetConstructorShipCount, 0f, 1.35f); + var shortagePenalty = MathF.Min(0.55f, assessment.CriticalShortageCount * 0.08f); + var replacementPenalty = MathF.Min(0.45f, assessment.ReplacementPressure / MathF.Max(12f, assessment.TargetMilitaryShipCount * 8f)); + + assessment.LogisticsSecurityScore = Math.Clamp( + (transportCoverage * 0.45f) + + (minerCoverage * 0.2f) + + (constructorCoverage * 0.1f) + + (hasWarIndustrySupplyChain ? 0.2f : 0.05f) + - shortagePenalty + - (regionalSupplyRisk * 0.3f) + - (corridorRisk * 0.18f), + 0f, + 1f); + assessment.SustainmentScore = Math.Clamp( + (assessment.LogisticsSecurityScore * 0.55f) + + ((hasShipyard ? 0.15f : 0f) + (hasWarIndustrySupplyChain ? 0.15f : 0f)) + + ((assessment.MilitaryShipCount >= assessment.TargetMilitaryShipCount ? 0.2f : 0.08f)) + + (regionalSustainment * 0.18f) + - replacementPenalty, + 0f, + 1f); + + return assessment; + } + + private static bool HasOperationalWarIndustry(FactionEconomySnapshot economy) + { + var energy = economy.GetCommodity("energycells"); + var refined = economy.GetCommodity("refinedmetals"); + var hullparts = economy.GetCommodity("hullparts"); + var claytronics = economy.GetCommodity("claytronics"); + return CommodityOperationalSignal.IsOperational(energy, 180f) + && CommodityOperationalSignal.IsOperational(refined, 180f) + && CommodityOperationalSignal.IsOperational(hullparts, 180f) + && CommodityOperationalSignal.IsOperational(claytronics, 180f); + } + + private static void UpdateDoctrine( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject) + { + var controlledThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system"); + var contestedThreats = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "contested-system"); + var expansionPressure = StationSimulationService.GetFactionExpansionPressure(world, faction.Id); + var shortagePressure = economicAssessment.CriticalShortageCount; + var activeWars = world.Geopolitics?.Diplomacy.Wars.Count(war => + war.Status == "active" + && (string.Equals(war.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(war.FactionBId, faction.Id, StringComparison.Ordinal))) ?? 0; + var borderTension = world.Geopolitics?.Diplomacy.BorderTensions + .Where(tension => string.Equals(tension.FactionAId, faction.Id, StringComparison.Ordinal) || string.Equals(tension.FactionBId, faction.Id, StringComparison.Ordinal)) + .DefaultIfEmpty() + .Average(tension => tension?.TensionScore ?? 0f) ?? 0f; + + faction.Doctrine.StrategicPosture = activeWars > 0 || controlledThreats > 1 + ? "fortify-and-recover" + : controlledThreats > 0 || borderTension > 0.45f + ? expansionProject is null ? "contested" : "defend-and-delay" + : expansionProject is not null && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold + ? "growth-through-pressure" + : shortagePressure > 0 ? "economic-recovery" : "stable-growth"; + faction.Doctrine.MilitaryPosture = activeWars + controlledThreats + contestedThreats switch + { + >= 3 => "mobilized", + > 0 when economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold && economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount => "counteroffensive", + > 0 => "defensive", + _ => economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount && economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold ? "expeditionary" : "defensive", + }; + faction.Doctrine.ExpansionPosture = expansionProject is not null + ? controlledThreats > 0 || economicAssessment.SustainmentScore < 0.58f ? "cautious" : "active" + : expansionPressure > 0.2f && economicAssessment.SustainmentScore >= 0.55f ? "measured" : "consolidating"; + faction.Doctrine.EconomicPosture = shortagePressure >= 3 + ? "stabilizing" + : economicAssessment.HasWarIndustrySupplyChain && economicAssessment.SustainmentScore >= 0.7f + ? "surplus" + : "self-sufficient"; + } + + private static void UpdateBudget( + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment) + { + var reserveCredits = faction.Credits * faction.Doctrine.ReserveCreditsRatio; + var discretionary = MathF.Max(0f, faction.Credits - reserveCredits); + var warRatio = threatAssessment.ThreatSignals.Count > 0 + ? MathF.Max(faction.Doctrine.WarBudgetRatio, 0.4f) + : faction.Doctrine.WarBudgetRatio * 0.5f; + var expansionRatio = economicAssessment.PrimaryExpansionSystemId is not null + ? MathF.Max(faction.Doctrine.ExpansionBudgetRatio, 0.25f) + : faction.Doctrine.ExpansionBudgetRatio * 0.5f; + + faction.StrategicState.Budget = new FactionBudgetRuntime + { + ReservedCredits = reserveCredits, + WarCredits = discretionary * Math.Clamp(warRatio, 0f, 1f), + ExpansionCredits = discretionary * Math.Clamp(expansionRatio, 0f, 1f), + ReservedMilitaryAssets = Math.Max(1, (int)MathF.Ceiling(economicAssessment.TargetMilitaryShipCount * faction.Doctrine.ReserveMilitaryRatio)), + ReservedLogisticsAssets = Math.Max(economicAssessment.TargetTransportShipCount, economicAssessment.TransportShipCount), + ReservedConstructionAssets = Math.Max(economicAssessment.TargetConstructorShipCount, economicAssessment.ConstructorShipCount), + }; + } + + private static void UpdateMemory( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + DateTimeOffset nowUtc) + { + foreach (var station in world.Stations.Where(station => station.FactionId == faction.Id)) + { + faction.Memory.KnownSystemIds.Add(station.SystemId); + } + + foreach (var ship in world.Ships.Where(ship => ship.FactionId == faction.Id)) + { + faction.Memory.KnownSystemIds.Add(ship.SystemId); + } + + foreach (var signal in threatAssessment.ThreatSignals) + { + faction.Memory.KnownSystemIds.Add(signal.ScopeId); + if (!string.IsNullOrWhiteSpace(signal.EnemyFactionId)) + { + faction.Memory.KnownEnemyFactionIds.Add(signal.EnemyFactionId); + } + + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); + if (systemMemory is null) + { + systemMemory = new FactionSystemMemoryRuntime { SystemId = signal.ScopeId }; + faction.Memory.SystemMemories.Add(systemMemory); + } + + systemMemory.LastSeenAtUtc = nowUtc; + systemMemory.LastEnemyShipCount = signal.EnemyShipCount; + systemMemory.LastEnemyStationCount = signal.EnemyStationCount; + systemMemory.ControlledByFaction = signal.ScopeKind == "controlled-system"; + systemMemory.LastRole = signal.ScopeKind; + var strategicProfile = FindStrategicProfile(world, signal.ScopeId); + var territoryPressure = FindTerritoryPressure(world, faction.Id, signal.ScopeId); + systemMemory.FrontierPressure = ((signal.EnemyStationCount * 0.9f) + (signal.EnemyShipCount * 0.35f)) + + ((territoryPressure?.PressureScore ?? 0f) * 1.4f) + + ((strategicProfile?.ZoneKind == "frontier" ? 1f : 0f) * 0.35f); + systemMemory.RouteRisk = MathF.Max( + GeopoliticalSimulationService.GetSystemRouteRisk(world, signal.ScopeId, faction.Id), + signal.ScopeKind is "controlled-system" or "contested-system" + ? MathF.Min(1f, (signal.EnemyShipCount * 0.08f) + (signal.EnemyStationCount * 0.16f)) + : MathF.Min(1f, (signal.EnemyShipCount * 0.05f) + (signal.EnemyStationCount * 0.1f))); + if (signal.ScopeKind is "controlled-system" or "contested-system") + { + systemMemory.LastContestedAtUtc = nowUtc; + } + } + + foreach (var systemId in faction.Memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal)) + { + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); + if (systemMemory is not null) + { + continue; + } + + faction.Memory.SystemMemories.Add(new FactionSystemMemoryRuntime + { + SystemId = systemId, + LastSeenAtUtc = nowUtc, + ControlledByFaction = FactionControlsSystem(world, faction.Id, systemId), + LastRole = FactionControlsSystem(world, faction.Id, systemId) ? "controlled-system" : "observed-system", + RouteRisk = GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id), + }); + } + + foreach (var zone in world.Geopolitics?.Territory.Zones.Where(zone => string.Equals(zone.FactionId, faction.Id, StringComparison.Ordinal)) ?? []) + { + faction.Memory.KnownSystemIds.Add(zone.SystemId); + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == zone.SystemId); + if (systemMemory is null) + { + continue; + } + + systemMemory.LastRole = zone.Kind switch + { + "contested" => "contested-system", + "frontier" => "controlled-system", + "corridor" => "controlled-system", + _ => systemMemory.LastRole, + }; + systemMemory.RouteRisk = MathF.Max(systemMemory.RouteRisk, GeopoliticalSimulationService.GetSystemRouteRisk(world, zone.SystemId, faction.Id)); + } + + foreach (var systemMemory in faction.Memory.SystemMemories) + { + if (threatAssessment.ThreatSignals.All(signal => signal.ScopeId != systemMemory.SystemId)) + { + systemMemory.FrontierPressure *= 0.92f; + systemMemory.RouteRisk *= 0.9f; + } + } + + foreach (var signal in economicAssessment.CommoditySignals) + { + var commodityMemory = faction.Memory.CommodityMemories.FirstOrDefault(candidate => candidate.ItemId == signal.ItemId); + if (commodityMemory is null) + { + commodityMemory = new FactionCommodityMemoryRuntime { ItemId = signal.ItemId }; + faction.Memory.CommodityMemories.Add(commodityMemory); + } + + commodityMemory.LastObservedBacklog = signal.BuyBacklog; + commodityMemory.UpdatedAtUtc = nowUtc; + commodityMemory.HistoricalShortageScore = (commodityMemory.HistoricalShortageScore * 0.85f) + + (signal.Level is "critical" ? 1.2f : signal.Level is "low" ? 0.65f : 0f) + + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 0.1f); + commodityMemory.HistoricalSurplusScore = (commodityMemory.HistoricalSurplusScore * 0.85f) + + (signal.Level == "surplus" ? 0.4f : 0f); + if (signal.Level is "critical" or "low") + { + commodityMemory.LastCriticalAtUtc = nowUtc; + } + + var impactedSystems = world.Stations + .Where(station => station.FactionId == faction.Id && station.MarketOrderIds.Any(orderId => + world.MarketOrders.Any(order => + order.Id == orderId && + order.Kind == MarketOrderKinds.Buy && + order.ItemId == signal.ItemId && + order.RemainingAmount > 0.01f))) + .Select(station => station.SystemId) + .Distinct(StringComparer.Ordinal) + .ToList(); + foreach (var systemId in impactedSystems) + { + var systemMemory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == systemId); + if (systemMemory is null) + { + continue; + } + + systemMemory.HistoricalShortagePressure = (systemMemory.HistoricalShortagePressure * 0.82f) + + (signal.Level is "critical" ? 0.7f : signal.Level is "low" ? 0.35f : 0f); + if (signal.Level is "critical" or "low") + { + systemMemory.LastShortageAtUtc = nowUtc; + } + } + } + + faction.Memory.SystemMemories.Sort((left, right) => string.Compare(left.SystemId, right.SystemId, StringComparison.Ordinal)); + faction.Memory.CommodityMemories.Sort((left, right) => string.Compare(left.ItemId, right.ItemId, StringComparison.Ordinal)); + + if (faction.ShipsBuilt != faction.Memory.LastObservedShipsBuilt) + { + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-built-{faction.Memory.LastPlanCycle}-{faction.ShipsBuilt}", + Kind = "ships-built", + Summary = $"{faction.Label} has built {faction.ShipsBuilt} ships.", + OccurredAtUtc = nowUtc, + }); + faction.Memory.LastObservedShipsBuilt = faction.ShipsBuilt; + } + + if (faction.ShipsLost != faction.Memory.LastObservedShipsLost) + { + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-lost-{faction.Memory.LastPlanCycle}-{faction.ShipsLost}", + Kind = "ships-lost", + Summary = $"{faction.Label} has lost {faction.ShipsLost} ships.", + OccurredAtUtc = nowUtc, + }); + faction.Memory.LastObservedShipsLost = faction.ShipsLost; + } + + faction.Memory.LastObservedCredits = faction.Credits; + } + + private static List BuildTheaters( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject, + DateTimeOffset nowUtc) + { + var theaters = new List(); + var systemMemories = faction.Memory.SystemMemories.ToDictionary(memory => memory.SystemId, StringComparer.Ordinal); + + foreach (var frontLine in (world.Geopolitics?.Territory.FrontLines + .Where(front => front.FactionIds.Contains(faction.Id, StringComparer.Ordinal)) + .OrderBy(front => front.Id, StringComparer.Ordinal) + ?? Enumerable.Empty())) + { + var ownedSystemId = frontLine.SystemIds + .FirstOrDefault(systemId => GeopoliticalSimulationService.FactionControlsSystem(world, faction.Id, systemId)) + ?? frontLine.SystemIds.FirstOrDefault(systemId => world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == systemId)) + ?? frontLine.SystemIds.FirstOrDefault(); + if (ownedSystemId is null) + { + continue; + } + if (theaters.Any(existing => string.Equals(existing.SystemId, ownedSystemId, StringComparison.Ordinal) && existing.Kind == "defense-front")) + { + continue; + } + + var pressure = FindTerritoryPressure(world, faction.Id, ownedSystemId); + var security = FindRegionalSecurityAssessment(world, faction.Id, ownedSystemId); + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-defense-{ownedSystemId}", + Kind = "defense-front", + SystemId = ownedSystemId, + Status = "active", + Priority = 96f + ((pressure?.PressureScore ?? 0f) * 24f) + ((security?.BorderPressure ?? 0f) * 20f), + SupplyRisk = MathF.Max(pressure?.CorridorRisk ?? 0f, security?.SupplyRisk ?? 0f), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, ownedSystemId), + TargetFactionId = frontLine.FactionIds.FirstOrDefault(id => !string.Equals(id, faction.Id, StringComparison.Ordinal)), + AnchorEntityId = frontLine.Id, + AnchorPosition = ResolveSystemAnchor(world, ownedSystemId), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var signal in threatAssessment.ThreatSignals + .Where(candidate => candidate.ScopeKind is "controlled-system" or "contested-system") + .OrderByDescending(candidate => (candidate.EnemyStationCount * 30) + (candidate.EnemyShipCount * 10)) + .ThenBy(candidate => candidate.ScopeId, StringComparer.Ordinal)) + { + if (theaters.Any(existing => string.Equals(existing.SystemId, signal.ScopeId, StringComparison.Ordinal) && existing.Kind == "defense-front")) + { + continue; + } + + var memory = systemMemories.GetValueOrDefault(signal.ScopeId); + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-defense-{signal.ScopeId}", + Kind = "defense-front", + SystemId = signal.ScopeId, + Status = "active", + Priority = 90f + (signal.EnemyStationCount * 12f) + (signal.EnemyShipCount * 4f) + ((memory?.DefensiveFailures ?? 0) * 6f), + SupplyRisk = memory?.RouteRisk ?? 0f, + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, signal.ScopeId), + TargetFactionId = signal.EnemyFactionId, + AnchorPosition = ResolveSystemAnchor(world, signal.ScopeId), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var memory in faction.Memory.SystemMemories + .Where(candidate => + candidate.ControlledByFaction && + (candidate.FrontierPressure > 0.35f || candidate.RouteRisk > 0.3f || candidate.HistoricalShortagePressure > 0.45f) && + theaters.All(existing => existing.SystemId != candidate.SystemId || existing.Kind != "defense-front")) + .OrderByDescending(candidate => (candidate.FrontierPressure * 50f) + (candidate.RouteRisk * 30f) + (candidate.HistoricalShortagePressure * 25f)) + .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal) + .Take(2)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-frontier-{memory.SystemId}", + Kind = "defense-front", + SystemId = memory.SystemId, + Status = "active", + Priority = 58f + (memory.FrontierPressure * 22f) + (memory.RouteRisk * 14f) + (memory.HistoricalShortagePressure * 10f), + SupplyRisk = memory.RouteRisk, + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, memory.SystemId), + AnchorPosition = ResolveSystemAnchor(world, memory.SystemId), + UpdatedAtUtc = nowUtc, + }); + } + + if (CanRunOffensivePosture(faction, economicAssessment, threatAssessment)) + { + foreach (var target in SelectOffensiveTargets(world, faction, threatAssessment, economicAssessment) + .Take(2)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-offense-{target.SystemId}", + Kind = "offense-front", + SystemId = target.SystemId, + Status = "active", + Priority = target.Priority, + SupplyRisk = target.SupplyRisk, + FriendlyAssetValue = target.Value, + TargetFactionId = target.TargetFactionId, + AnchorEntityId = target.AnchorEntityId, + AnchorPosition = target.AnchorPosition, + UpdatedAtUtc = nowUtc, + }); + } + } + + if (expansionProject is not null && CanSupportExpansion(faction, economicAssessment, threatAssessment)) + { + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-expansion-{expansionProject.SystemId}-{expansionProject.CelestialId}", + Kind = "expansion-front", + SystemId = expansionProject.SystemId, + Status = "active", + Priority = 65f + (economicAssessment.HasShipyard ? 0f : 15f), + SupplyRisk = ComputeSystemRisk(world, faction, expansionProject.SystemId), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, expansionProject.SystemId), + AnchorEntityId = expansionProject.SiteId ?? expansionProject.CelestialId, + AnchorPosition = ResolveExpansionAnchor(world, expansionProject), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var signal in economicAssessment.CommoditySignals + .Where(candidate => + candidate.Level is "critical" or "low" + || candidate.ProjectedNetRatePerSecond < -0.01f + || faction.Memory.CommodityMemories.FirstOrDefault(memory => memory.ItemId == candidate.ItemId) is { HistoricalShortageScore: > 0.8f }) + .OrderByDescending(candidate => ComputeCommodityPriority(candidate)) + .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) + .Take(3)) + { + var systemId = FindPrimaryCommoditySystem(world, faction.Id, signal.ItemId) ?? economicAssessment.PrimaryExpansionSystemId ?? world.Systems.First().Definition.Id; + theaters.Add(new FactionTheaterRuntime + { + Id = $"theater-economy-{signal.ItemId}", + Kind = "economic-front", + SystemId = systemId, + Status = "active", + Priority = 45f + ComputeCommodityPriority(signal), + SupplyRisk = ComputeSystemRisk(world, faction, systemId), + FriendlyAssetValue = EstimateFriendlyAssetValue(world, faction.Id, systemId), + AnchorEntityId = ResolveCommodityAnchorStation(world, faction.Id, signal.ItemId)?.Id, + UpdatedAtUtc = nowUtc, + }); + } + + theaters.Sort((left, right) => + { + var priority = right.Priority.CompareTo(left.Priority); + return priority != 0 ? priority : string.Compare(left.Id, right.Id, StringComparison.Ordinal); + }); + + return theaters; + } + + private static List BuildCampaigns( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList theaters, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousCampaigns, + DateTimeOffset nowUtc) + { + var campaigns = new List(); + + foreach (var theater in theaters) + { + var id = theater.Kind switch + { + "defense-front" => $"campaign-defense-{theater.SystemId}", + "offense-front" => $"campaign-offense-{theater.SystemId}", + "expansion-front" => $"campaign-expansion-{theater.SystemId}", + "economic-front" => $"campaign-economy-{ResolveCommodityFromTheaterId(theater.Id) ?? theater.Id}", + _ => $"campaign-{theater.Id}", + }; + previousCampaigns.TryGetValue(id, out var previous); + var systemMemory = theater.SystemId is null ? null : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == theater.SystemId); + var supplyAdequacy = ComputeCampaignSupplyAdequacy(faction, theater, economicAssessment); + var continuationScore = ComputeCampaignContinuationScore(faction, theater, economicAssessment, systemMemory); + var pauseReason = ResolveCampaignPauseReason(faction, theater, economicAssessment, threatAssessment, systemMemory); + var effectiveContinuationScore = previous is not null && previous.ContinuationScore > 0f + ? MathF.Max(previous.ContinuationScore * 0.7f, continuationScore) + : continuationScore; + var campaign = new FactionCampaignRuntime + { + Id = id, + Kind = theater.Kind switch + { + "defense-front" => "defense", + "offense-front" => "offense", + "expansion-front" => "expansion", + "economic-front" => "economic-stabilization", + _ => theater.Kind, + }, + Status = pauseReason is null ? "active" : "paused", + Priority = theater.Priority, + TheaterId = theater.Id, + TargetFactionId = theater.TargetFactionId, + TargetSystemId = theater.SystemId, + TargetEntityId = theater.AnchorEntityId, + CommodityId = theater.Kind == "economic-front" ? ResolveCommodityFromTheaterId(theater.Id) : expansionProject?.CommodityId, + SupportStationId = expansionProject?.SupportStationId, + CurrentStepIndex = previous?.CurrentStepIndex ?? 0, + Summary = BuildCampaignSummary(theater, expansionProject), + UpdatedAtUtc = nowUtc, + PauseReason = pauseReason, + ContinuationScore = effectiveContinuationScore, + SupplyAdequacy = supplyAdequacy, + ReplacementPressure = economicAssessment.ReplacementPressure, + FailureCount = previous?.FailureCount ?? GetCampaignFailureCount(systemMemory, theater.Kind), + SuccessCount = previous?.SuccessCount ?? GetCampaignSuccessCount(systemMemory, theater.Kind), + RequiresReinforcement = RequiresReinforcement(theater, economicAssessment, threatAssessment), + }; + + campaign.Steps.AddRange(BuildCampaignSteps(campaign, expansionProject)); + if (previous is not null) + { + campaign.CreatedAtUtc = previous.CreatedAtUtc; + campaign.FleetCommanderId = previous.FleetCommanderId; + } + + campaigns.Add(campaign); + } + + if (economicAssessment.MilitaryShipCount < economicAssessment.TargetMilitaryShipCount + || economicAssessment.ReplacementPressure > 0.5f + || !economicAssessment.HasWarIndustrySupplyChain) + { + previousCampaigns.TryGetValue("campaign-force-build-up", out var previous); + var campaign = new FactionCampaignRuntime + { + Id = "campaign-force-build-up", + Kind = "force-build-up", + Status = "active", + Priority = 60f + ((economicAssessment.TargetMilitaryShipCount - economicAssessment.MilitaryShipCount) * 5f) + (economicAssessment.ReplacementPressure * 10f), + CurrentStepIndex = previous?.CurrentStepIndex ?? 0, + Summary = "Expand warfighting capacity.", + UpdatedAtUtc = nowUtc, + ContinuationScore = 0.9f, + SupplyAdequacy = economicAssessment.SustainmentScore, + ReplacementPressure = economicAssessment.ReplacementPressure, + FailureCount = previous?.FailureCount ?? 0, + SuccessCount = previous?.SuccessCount ?? 0, + RequiresReinforcement = true, + }; + campaign.Steps.AddRange( + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-step-1", Kind = "stabilize-war-industry", Status = "active", Summary = "Stabilize core war industry inputs." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-step-2", Kind = "increase-warship-output", Status = "planned", Summary = "Increase military ship production." }, + ]); + if (previous is not null) + { + campaign.CreatedAtUtc = previous.CreatedAtUtc; + } + + campaigns.Add(campaign); + } + + return campaigns + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + } + + private static List BuildCampaignSteps(FactionCampaignRuntime campaign, IndustryExpansionProject? expansionProject) + { + return campaign.Kind switch + { + "defense" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-defense", Status = "active", Summary = "Stage defenders into the contested system." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-hold", Kind = "hold-space", Status = "planned", Summary = "Hold the system and protect friendly assets." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-sustain", Kind = "sustain-defense", Status = "planned", Summary = "Sustain logistics and restore security." }, + ], + "offense" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-stage", Kind = "stage-offense", Status = "active", Summary = "Stage assault forces at the target frontier." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-strike", Kind = "strike-targets", Status = "planned", Summary = "Engage hostile military and stations." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-hold", Kind = "hold-gains", Status = "planned", Summary = "Maintain pressure and deny recovery." }, + ], + "expansion" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-claim", Kind = "secure-claim", Status = "active", Summary = $"Secure {expansionProject?.CelestialId ?? campaign.TargetEntityId} for construction." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-supply", Kind = "supply-site", Status = "planned", Summary = "Move construction materials to the site." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-guard", Kind = "guard-site", Status = "planned", Summary = "Defend the expansion site until operational." }, + ], + "economic-stabilization" => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-source", Kind = "secure-supply", Status = "active", Summary = "Secure incoming production and mining sources." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-move", Kind = "move-goods", Status = "planned", Summary = "Route logistics toward shortage points." }, + new FactionPlanStepRuntime { Id = $"{campaign.Id}-stabilize", Kind = "stabilize-market", Status = "planned", Summary = "Restore stable stock levels." }, + ], + _ => + [ + new FactionPlanStepRuntime { Id = $"{campaign.Id}-step", Kind = "maintain", Status = "active", Summary = campaign.Summary }, + ], + }; + } + + private static List BuildObjectives( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList theaters, + IReadOnlyList campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + var objectives = new List(); + var campaignsById = campaigns.ToDictionary(campaign => campaign.Id, StringComparer.Ordinal); + + foreach (var campaign in campaigns) + { + var theater = theaters.FirstOrDefault(candidate => candidate.Id == campaign.TheaterId); + switch (campaign.Kind) + { + case "defense": + AddDefenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); + break; + case "offense": + if (campaign.Status == "active") + { + AddOffenseObjectives(world, faction, campaign, theater, objectives, previousObjectives, nowUtc); + } + break; + case "expansion": + if (campaign.Status == "active") + { + AddExpansionObjectives(world, faction, campaign, theater, expansionProject, objectives, previousObjectives, nowUtc); + } + break; + case "economic-stabilization": + AddEconomicObjectives(world, faction, campaign, theater, economicAssessment, objectives, previousObjectives, nowUtc); + break; + case "force-build-up": + AddForceBuildUpObjectives(world, faction, campaign, economicAssessment, objectives, previousObjectives, nowUtc); + break; + } + } + + foreach (var objective in objectives) + { + if (campaignsById.TryGetValue(objective.CampaignId, out var campaign)) + { + campaign.ObjectiveIds.Add(objective.Id); + } + } + + return objectives + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + } + + private static void AddDefenseObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + var stations = world.Stations + .Where(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId) + .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Take(2) + .ToList(); + foreach (var station in stations) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-protect-station-{station.Id}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "protect-station", + DelegationKind = "ship", + BehaviorKind = "protect-station", + Status = "active", + Priority = campaign.Priority + 8f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + Notes = $"Protect {station.Label}", + UpdatedAtUtc = nowUtc, + }, + nowUtc)); + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-patrol-{campaign.TargetSystemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "patrol-front", + DelegationKind = "ship", + BehaviorKind = "patrol", + Status = "active", + Priority = campaign.Priority + 2f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Patrol defensive front", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, + }, + nowUtc)); + + if ((theater?.SupplyRisk ?? 0f) > 0.25f) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-police-{campaign.TargetSystemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "police-front", + DelegationKind = "ship", + BehaviorKind = "police", + Status = "active", + Priority = campaign.Priority + 1f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Police frontier logistics routes", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + } + } + + private static void AddOffenseObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + var enemyStation = world.Stations + .Where(station => station.FactionId != faction.Id && station.SystemId == campaign.TargetSystemId) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (enemyStation is not null) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-strike-station-{enemyStation.Id}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "strike-station", + DelegationKind = "ship", + BehaviorKind = "attack-target", + Status = "active", + Priority = campaign.Priority + 10f, + TargetSystemId = enemyStation.SystemId, + TargetEntityId = enemyStation.Id, + TargetPosition = enemyStation.Position, + Notes = $"Strike {enemyStation.Label}", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-hold-front", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "hold-front", + DelegationKind = "ship", + BehaviorKind = "protect-position", + Status = "active", + Priority = campaign.Priority + 3f, + TargetSystemId = campaign.TargetSystemId, + TargetPosition = theater?.AnchorPosition ?? ResolveSystemAnchor(world, campaign.TargetSystemId), + Notes = "Maintain assault formation", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-fleet-supply", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "fleet-sustainment", + DelegationKind = "ship", + BehaviorKind = "supply-fleet", + Status = "active", + Priority = campaign.Priority + 1.5f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + ItemId = "hullparts", + Notes = "Sustain frontline fleet operations", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + } + + private static void AddExpansionObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + IndustryExpansionProject? expansionProject, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) + { + if (expansionProject is null) { return; } - var state = BuildShipPlanningState(world, ship, commander); - var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions); - if (plan?.CurrentAction is { } action) + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-construct-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "construct-site", + DelegationKind = "ship", + BehaviorKind = "construct-station", + Status = "active", + Priority = campaign.Priority + 8f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + TargetEntityId = expansionProject.SiteId, + TargetPosition = ResolveExpansionAnchor(world, expansionProject), + Notes = $"Construct {expansionProject.ModuleId}", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 2, + }, + nowUtc)); + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-supply-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "supply-site", + DelegationKind = "ship", + BehaviorKind = "find-build-tasks", + Status = "active", + Priority = campaign.Priority + 4f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + TargetEntityId = expansionProject.SiteId, + ItemId = expansionProject.CommodityId, + Notes = "Supply expansion site", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-guard-site", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "guard-site", + DelegationKind = "ship", + BehaviorKind = "protect-position", + Status = "active", + Priority = campaign.Priority + 2f, + TargetSystemId = expansionProject.SystemId, + TargetPosition = ResolveExpansionAnchor(world, expansionProject), + TargetEntityId = expansionProject.SiteId, + Notes = "Guard construction frontier", + UpdatedAtUtc = nowUtc, + UseOrders = true, + StagingOrderKind = ShipOrderKinds.Move, + ReinforcementLevel = 1, + }, + nowUtc)); + + if (CanMineItem(world, expansionProject.CommodityId)) { - commander.ActiveGoalName = _shipGoal.Name; - commander.ActiveActionName = action.Name; - action.Execute(engine, world, commander); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-expansion-input", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "mine-expansion-input", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 1f, + HomeSystemId = expansionProject.SystemId, + HomeStationId = expansionProject.SupportStationId, + TargetSystemId = expansionProject.SystemId, + ItemId = expansionProject.CommodityId, + Notes = $"Mine {expansionProject.CommodityId} for frontier build-up", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); } } - internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId) - { - var stations = world.Stations.Where(s => s.FactionId == factionId).ToList(); - var economy = FactionEconomyAnalyzer.Build(world, factionId); - var refinedMetals = economy.GetCommodity("refinedmetals"); - var hullparts = economy.GetCommodity("hullparts"); - var claytronics = economy.GetCommodity("claytronics"); - var water = economy.GetCommodity("water"); - - return new FactionPlanningState - { - EnemyFactionCount = world.Factions.Count(f => f.Id != factionId), - EnemyShipCount = world.Ships.Count(s => - s.Health > 0f && - !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), - EnemyStationCount = world.Stations.Count(s => - !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), - MilitaryShipCount = world.Ships.Count(s => - s.FactionId == factionId && - string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)), - MinerShipCount = world.Ships.Count(s => - s.FactionId == factionId && - string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)), - TransportShipCount = world.Ships.Count(s => - s.FactionId == factionId && - string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)), - ConstructorShipCount = world.Ships.Count(s => - s.FactionId == factionId && - string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)), - ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId), - TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)), - HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), - OreStockpile = economy.GetCommodity("ore").OnHand, - RefinedMetalsAvailableStock = refinedMetals.AvailableStock, - RefinedMetalsUsageRate = refinedMetals.OperationalUsageRatePerSecond, - RefinedMetalsProjectedProductionRate = refinedMetals.ProjectedProductionRatePerSecond, - RefinedMetalsProjectedNetRate = refinedMetals.ProjectedNetRatePerSecond, - RefinedMetalsLevelSeconds = refinedMetals.LevelSeconds, - RefinedMetalsLevel = refinedMetals.Level.ToString().ToLowerInvariant(), - HullpartsAvailableStock = hullparts.AvailableStock, - HullpartsUsageRate = hullparts.OperationalUsageRatePerSecond, - HullpartsProjectedProductionRate = hullparts.ProjectedProductionRatePerSecond, - HullpartsProjectedNetRate = hullparts.ProjectedNetRatePerSecond, - HullpartsLevelSeconds = hullparts.LevelSeconds, - HullpartsLevel = hullparts.Level.ToString().ToLowerInvariant(), - ClaytronicsAvailableStock = claytronics.AvailableStock, - ClaytronicsUsageRate = claytronics.OperationalUsageRatePerSecond, - ClaytronicsProjectedProductionRate = claytronics.ProjectedProductionRatePerSecond, - ClaytronicsProjectedNetRate = claytronics.ProjectedNetRatePerSecond, - ClaytronicsLevelSeconds = claytronics.LevelSeconds, - ClaytronicsLevel = claytronics.Level.ToString().ToLowerInvariant(), - WaterAvailableStock = water.AvailableStock, - WaterUsageRate = water.OperationalUsageRatePerSecond, - WaterProjectedProductionRate = water.ProjectedProductionRatePerSecond, - WaterProjectedNetRate = water.ProjectedNetRatePerSecond, - WaterLevelSeconds = water.LevelSeconds, - WaterLevel = water.Level.ToString().ToLowerInvariant(), - }; - } - - private static ShipPlanningState BuildShipPlanningState( - SimulationWorld world, - ShipRuntime ship, - CommanderRuntime commander) - { - var factionCommander = FindFactionCommander(world, commander.FactionId); - - var enemyTarget = SelectEnemyTarget(world, ship); - var tradeRoute = SelectTradeRoute(world, ship.FactionId); - var expansionTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ExpandIndustry); - var attackTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.AttackFactionAssets); - var shipyardExpansionTask = factionCommander?.IssuedTasks - .Where(task => - task.Kind == FactionIssuedTaskKind.ExpandIndustry - && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active - && string.Equals(task.ModuleId, "module_gen_build_l_01", StringComparison.Ordinal)) - .OrderByDescending(task => task.Priority) - .FirstOrDefault(); - var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId); - if (commander.ActiveBehavior is not null) - { - commander.ActiveBehavior.AreaSystemId = attackTask?.TargetSystemId ?? expansionTask?.TargetSystemId ?? enemyTarget?.SystemId; - commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId; - if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) - { - commander.ActiveBehavior.ItemId = tradeRoute?.ItemId; - commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId; - commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId; - } - else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && (expansionTask is not null || expansionProject is not null)) - { - commander.ActiveBehavior.StationId = expansionProject?.SupportStationId; - commander.ActiveBehavior.TargetEntityId = expansionTask?.TargetSiteId ?? expansionProject?.SiteId; - commander.ActiveBehavior.ModuleId = expansionTask?.ModuleId ?? expansionProject?.ModuleId; - commander.ActiveBehavior.AreaSystemId = expansionTask?.TargetSystemId ?? expansionProject?.SystemId; - } - else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) - { - commander.ActiveBehavior.TargetEntityId = null; - commander.ActiveBehavior.ModuleId = null; - commander.ActiveBehavior.AreaSystemId = ship.SystemId; - } - } - - return new ShipPlanningState - { - ShipKind = ship.Definition.Kind, - HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"), - FactionWantsOre = true, - FactionWantsCombat = attackTask is not null, - FactionWantsExpansion = expansionTask is not null, - FactionNeedsShipyard = shipyardExpansionTask is not null - && !world.Stations.Any(station => - string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal) - && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), - TargetEnemySystemId = attackTask?.TargetSystemId ?? enemyTarget?.SystemId, - TargetEnemyEntityId = enemyTarget?.EntityId, - TradeItemId = tradeRoute?.ItemId, - TradeSourceStationId = tradeRoute?.SourceStationId, - TradeDestinationStationId = tradeRoute?.DestinationStationId, - }; - } - - internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => - world.Commanders.FirstOrDefault(c => - c.FactionId == factionId && - string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); - - internal static bool FactionCommanderHasIssuedTask( + private static void AddEconomicObjectives( SimulationWorld world, - string factionId, - FactionIssuedTaskKind kind, - string? shipRole = null) => - FindFactionCommander(world, factionId)? - .IssuedTasks.Any(task => - task.Kind == kind - && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked - && (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) ?? false; - - internal static FactionIssuedTaskRuntime? GetHighestPriorityIssuedTask( - CommanderRuntime? factionCommander, - FactionIssuedTaskKind kind, - string? shipRole = null) => - factionCommander?.IssuedTasks - .Where(task => - task.Kind == kind - && task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked - && (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) - .OrderByDescending(task => task.Priority) - .FirstOrDefault(); - - private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship) + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionTheaterRuntime? theater, + FactionEconomicAssessmentRuntime economicAssessment, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - var hostileShip = world.Ships - .Where(candidate => - candidate.Health > 0f && - !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) - .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) - .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .Select(candidate => (candidate.Id, candidate.SystemId)) - .FirstOrDefault(); - - if (hostileShip != default) + var itemId = campaign.CommodityId ?? ResolveCommodityFromTheaterId(theater?.Id); + if (string.IsNullOrWhiteSpace(itemId)) { - return hostileShip; + return; } - var hostileStation = world.Stations - .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) - .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) - .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .Select(candidate => (candidate.Id, candidate.SystemId)) - .FirstOrDefault(); + var anchorStation = ResolveCommodityAnchorStation(world, faction.Id, itemId); + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-trade-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "trade-shortage", + DelegationKind = "ship", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = campaign.Priority + 5f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, + TargetEntityId = anchorStation?.Id, + ItemId = itemId, + Notes = $"Stabilize {itemId} shortages", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = campaign.RequiresReinforcement ? 2 : 1, + }, + nowUtc)); - return hostileStation == default ? null : hostileStation; + if (CanMineItem(world, itemId)) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "mine-shortage", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 3f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = campaign.TargetSystemId, + ItemId = itemId, + Notes = $"Mine {itemId} for economic recovery", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-revisit-{itemId}", + CampaignId = campaign.Id, + TheaterId = theater?.Id, + Kind = "revisit-stations", + DelegationKind = "ship", + BehaviorKind = "revisit-known-stations", + Status = "active", + Priority = campaign.Priority + 0.5f, + HomeSystemId = anchorStation?.SystemId, + HomeStationId = anchorStation?.Id, + TargetSystemId = anchorStation?.SystemId ?? campaign.TargetSystemId, + ItemId = itemId, + Notes = $"Refresh station trade knowledge for {itemId}", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); } - private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId) + private static void AddForceBuildUpObjectives( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionEconomicAssessmentRuntime economicAssessment, + ICollection objectives, + IReadOnlyDictionary previousObjectives, + DateTimeOffset nowUtc) { - var stationsById = world.Stations - .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) - .ToDictionary(station => station.Id, StringComparer.Ordinal); - - foreach (var demand in world.MarketOrders - .Where(order => - string.Equals(order.FactionId, factionId, StringComparison.Ordinal) - && order.Kind == MarketOrderKinds.Buy - && order.RemainingAmount > 0.01f - && order.StationId is not null) - .OrderByDescending(order => order.Valuation)) + var shipyard = world.Stations + .Where(station => station.FactionId == faction.Id && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (shipyard is null) { - if (!stationsById.TryGetValue(demand.StationId!, out var destination)) + return; + } + + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-feed-shipyard", + CampaignId = campaign.Id, + Kind = "feed-shipyard", + DelegationKind = "ship", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = campaign.Priority + 4f, + HomeSystemId = shipyard.SystemId, + HomeStationId = shipyard.Id, + TargetSystemId = shipyard.SystemId, + TargetEntityId = shipyard.Id, + ItemId = economicAssessment.IndustrialBottleneckItemId ?? "hullparts", + Notes = "Keep shipyard supplied for fleet growth", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 2, + }, + nowUtc)); + + if (!string.IsNullOrWhiteSpace(economicAssessment.IndustrialBottleneckItemId) + && CanMineItem(world, economicAssessment.IndustrialBottleneckItemId)) + { + objectives.Add(CreateObjective( + previousObjectives, + new FactionOperationalObjectiveRuntime + { + Id = $"{campaign.Id}-mine-bottleneck", + CampaignId = campaign.Id, + Kind = "mine-bottleneck", + DelegationKind = "ship", + BehaviorKind = "expert-auto-mine", + Status = "active", + Priority = campaign.Priority + 2f, + HomeSystemId = shipyard.SystemId, + HomeStationId = shipyard.Id, + TargetSystemId = shipyard.SystemId, + ItemId = economicAssessment.IndustrialBottleneckItemId, + Notes = $"Mine {economicAssessment.IndustrialBottleneckItemId} for replacement pressure relief", + UpdatedAtUtc = nowUtc, + ReinforcementLevel = 1, + }, + nowUtc)); + } + } + + private static FactionOperationalObjectiveRuntime CreateObjective( + IReadOnlyDictionary previousObjectives, + FactionOperationalObjectiveRuntime objective, + DateTimeOffset nowUtc) + { + if (previousObjectives.TryGetValue(objective.Id, out var previous)) + { + objective.CreatedAtUtc = previous.CreatedAtUtc; + objective.CurrentStepIndex = previous.CurrentStepIndex; + objective.CommanderId = previous.CommanderId; + objective.Status = previous.Status; + objective.ReservedAssetIds.AddRange(previous.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal)); + } + + objective.UpdatedAtUtc = nowUtc; + objective.Steps.AddRange(BuildObjectiveSteps(objective)); + return objective; + } + + private static IEnumerable BuildObjectiveSteps(FactionOperationalObjectiveRuntime objective) + { + return + [ + new FactionPlanStepRuntime + { + Id = $"{objective.Id}-step-1", + Kind = "deploy", + Status = objective.Status == "paused" ? "paused" : objective.CommanderId is null ? "planned" : "active", + Summary = "Deploy assigned asset.", + }, + new FactionPlanStepRuntime + { + Id = $"{objective.Id}-step-2", + Kind = "execute", + Status = objective.Status == "paused" ? "paused" : "planned", + Summary = objective.Notes ?? objective.BehaviorKind, + }, + ]; + } + + private static List BuildReservations( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyList objectives, + DateTimeOffset nowUtc) + { + var reservations = new List(); + var commanders = world.Commanders + .Where(commander => commander.IsAlive && commander.FactionId == faction.Id && commander.Kind is CommanderKind.Station or CommanderKind.Ship) + .OrderBy(commander => commander.Kind, StringComparer.Ordinal) + .ThenBy(commander => commander.Id, StringComparer.Ordinal) + .ToList(); + var reservedCommanderIds = new HashSet(StringComparer.Ordinal); + var availableMilitaryCommanders = commanders.Count(commander => + commander.Kind == CommanderKind.Ship && + world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { Definition.Kind: "military", Health: > 0f }); + var committedMilitaryCommanders = 0; + + foreach (var objective in objectives + .OrderByDescending(candidate => candidate.Priority + (candidate.ReinforcementLevel * 4f)) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal)) + { + if (IsCombatObjective(objective) + && objective.Priority < 95f + && availableMilitaryCommanders - committedMilitaryCommanders <= faction.StrategicState.Budget.ReservedMilitaryAssets) + { + objective.Status = "reserved"; + continue; + } + + var commander = SelectCommanderForObjective(world, objective, commanders, reservedCommanderIds); + if (commander is null) { continue; } - if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId)) + reservedCommanderIds.Add(commander.Id); + objective.CommanderId = commander.Id; + objective.ReservedAssetIds.Clear(); + objective.ReservedAssetIds.Add(commander.ControlledEntityId ?? commander.Id); + objective.Status = "active"; + objective.CurrentStepIndex = 0; + if (IsCombatObjective(objective)) + { + committedMilitaryCommanders += 1; + } + + reservations.Add(new FactionAssetReservationRuntime + { + Id = $"reservation-{objective.Id}-{commander.Id}", + ObjectiveId = objective.Id, + CampaignId = objective.CampaignId, + AssetKind = commander.Kind == CommanderKind.Station ? "station-commander" : "ship-commander", + AssetId = commander.Id, + Priority = objective.Priority, + UpdatedAtUtc = nowUtc, + }); + } + + return reservations; + } + + private static CommanderRuntime? SelectCommanderForObjective( + SimulationWorld world, + FactionOperationalObjectiveRuntime objective, + IReadOnlyList commanders, + IReadOnlySet reservedCommanderIds) + { + return commanders + .Where(commander => + !reservedCommanderIds.Contains(commander.Id) && + IsCommanderEligibleForObjective(world, commander, objective)) + .OrderByDescending(commander => ScoreCommanderForObjective(world, commander, objective)) + .ThenBy(commander => commander.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static bool IsCommanderEligibleForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) + { + if (objective.DelegationKind == "station") + { + return commander.Kind == CommanderKind.Station + && (objective.HomeStationId is null || string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)); + } + + if (commander.Kind != CommanderKind.Ship) + { + return false; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (ship is null || ship.Health <= 0f) + { + return false; + } + + return objective.BehaviorKind switch + { + "construct-station" => string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal), + "find-build-tasks" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), + "fill-shortages" or "advanced-auto-trade" or "revisit-known-stations" or "supply-fleet" => string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal), + "local-auto-mine" or "advanced-auto-mine" or "expert-auto-mine" => HasShipCapabilities(ship.Definition, "mining"), + "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" or "attack-target" => string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal), + _ => true, + }; + } + + private static float ScoreCommanderForObjective(SimulationWorld world, CommanderRuntime commander, FactionOperationalObjectiveRuntime objective) + { + var skillScore = commander.Skills.Leadership + commander.Skills.Coordination + commander.Skills.Strategy; + var homeBias = 0f; + + if (commander.Kind == CommanderKind.Station) + { + if (string.Equals(commander.ControlledEntityId, objective.HomeStationId, StringComparison.Ordinal)) + { + homeBias += 25f; + } + + if (world.Stations.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId) is { } station + && string.Equals(station.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) + { + homeBias += 12f; + } + + return homeBias + skillScore; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (ship is null) + { + return float.MinValue; + } + + if (string.Equals(ship.SystemId, objective.TargetSystemId, StringComparison.Ordinal)) + { + homeBias += 30f; + } + else if (string.Equals(ship.SystemId, objective.HomeSystemId, StringComparison.Ordinal)) + { + homeBias += 18f; + } + + if (ship.CommanderId == objective.CommanderId) + { + homeBias += 8f; + } + + var distancePenalty = objective.TargetPosition is null ? 0f : ship.Position.DistanceTo(objective.TargetPosition.Value); + return homeBias + skillScore - distancePenalty; + } + + private static List BuildProductionPrograms( + FactionRuntime faction, + IReadOnlyList theaters, + IReadOnlyList campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + IndustryExpansionProject? expansionProject, + IReadOnlyDictionary previousPrograms) + { + var programs = new List(); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-military", + Kind = "military-fleet", + Status = economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount ? "stable" : "active", + Priority = 80f + (threatAssessment.ThreatSignals.Count * 4f), + ShipKind = "military", + TargetCount = economicAssessment.TargetMilitaryShipCount, + CurrentCount = economicAssessment.MilitaryShipCount, + Notes = "Maintain enough military hulls for all active fronts.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-miners", + Kind = "mining-fleet", + Status = economicAssessment.MinerShipCount >= economicAssessment.TargetMinerShipCount ? "stable" : "active", + Priority = 60f, + ShipKind = "mining", + TargetCount = economicAssessment.TargetMinerShipCount, + CurrentCount = economicAssessment.MinerShipCount, + Notes = "Maintain raw resource extraction capacity.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-transports", + Kind = "logistics-fleet", + Status = economicAssessment.TransportShipCount >= economicAssessment.TargetTransportShipCount ? "stable" : "active", + Priority = 62f, + ShipKind = "transport", + TargetCount = economicAssessment.TargetTransportShipCount, + CurrentCount = economicAssessment.TransportShipCount, + Notes = "Maintain logistics throughput across stations and fronts.", + })); + + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-constructors", + Kind = "construction-fleet", + Status = economicAssessment.ConstructorShipCount >= economicAssessment.TargetConstructorShipCount ? "stable" : "active", + Priority = expansionProject is null ? 35f : 68f, + ShipKind = "construction", + TargetCount = economicAssessment.TargetConstructorShipCount, + CurrentCount = economicAssessment.ConstructorShipCount, + Notes = "Maintain construction capacity for frontier growth.", + })); + + if (!economicAssessment.HasWarIndustrySupplyChain) + { + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-war-industry", + Kind = "war-industry", + Status = "active", + Priority = 78f, + CommodityId = "hullparts", + TargetCount = 1, + CurrentCount = economicAssessment.HasWarIndustrySupplyChain ? 1 : 0, + Notes = "Stabilize war industry bottlenecks.", + })); + } + + if (expansionProject is not null) + { + programs.Add(CreateProductionProgram(previousPrograms, new FactionProductionProgramRuntime + { + Id = $"program-{faction.Id}-expansion", + Kind = "expansion", + Status = "active", + Priority = 72f, + CampaignId = campaigns.FirstOrDefault(candidate => candidate.Kind == "expansion")?.Id, + CommodityId = expansionProject.CommodityId, + ModuleId = expansionProject.ModuleId, + TargetSystemId = expansionProject.SystemId, + TargetCount = 1, + CurrentCount = economicAssessment.PrimaryExpansionSiteId is null ? 0 : 1, + Notes = $"Expand into {expansionProject.SystemId}.", + })); + } + + return programs + .OrderByDescending(program => program.Priority) + .ThenBy(program => program.Id, StringComparer.Ordinal) + .ToList(); + } + + private static FactionProductionProgramRuntime CreateProductionProgram( + IReadOnlyDictionary previousPrograms, + FactionProductionProgramRuntime program) + { + if (previousPrograms.TryGetValue(program.Id, out var previous)) + { + program.CampaignId ??= previous.CampaignId; + } + + return program; + } + + private static void ReconcileCampaignLifecycle( + SimulationWorld world, + FactionRuntime faction, + IReadOnlyDictionary previousCampaigns, + IReadOnlyCollection campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) + { + var activeIds = campaigns.Select(campaign => campaign.Id).ToHashSet(StringComparer.Ordinal); + foreach (var campaign in campaigns) + { + if (!previousCampaigns.ContainsKey(campaign.Id)) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{campaign.Id}-created", + Kind = "campaign-created", + Summary = $"Activated {campaign.Kind} campaign {campaign.Id}.", + RelatedEntityId = campaign.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + } + + foreach (var previous in previousCampaigns.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + UpdateCampaignMemoryFromOutcome(world, faction, previous, economicAssessment, threatAssessment, nowUtc); + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-completed-{faction.StrategicState.PlanCycle}", + Kind = "campaign-completed", + Summary = $"Closed {previous.Kind} campaign {previous.Id}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-{previous.Id}-{faction.StrategicState.PlanCycle}", + Kind = "campaign-completed", + Summary = $"Campaign {previous.Id} left the active strategic set.", + RelatedCampaignId = previous.Id, + OccurredAtUtc = nowUtc, + }); + } + } + + private static void ReconcileObjectiveLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousObjectives, + IReadOnlyCollection objectives, + DateTimeOffset nowUtc) + { + var activeIds = objectives.Select(objective => objective.Id).ToHashSet(StringComparer.Ordinal); + foreach (var objective in objectives.Where(candidate => !previousObjectives.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{objective.Id}-created", + Kind = "objective-created", + Summary = $"Delegated objective {objective.Kind}.", + RelatedEntityId = objective.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousObjectives.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-retired-{faction.StrategicState.PlanCycle}", + Kind = "objective-retired", + Summary = $"Retired objective {previous.Kind}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + } + + private static void ReconcileTheaterLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousTheaters, + IReadOnlyCollection theaters, + DateTimeOffset nowUtc) + { + var activeIds = theaters.Select(theater => theater.Id).ToHashSet(StringComparer.Ordinal); + foreach (var theater in theaters.Where(candidate => !previousTheaters.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{theater.Id}-opened", + Kind = "theater-opened", + Summary = $"Opened {theater.Kind} in {theater.SystemId}.", + RelatedEntityId = theater.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousTheaters.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-closed-{faction.StrategicState.PlanCycle}", + Kind = "theater-closed", + Summary = $"Closed {previous.Kind} in {previous.SystemId}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + } + + private static void ReconcileProgramLifecycle( + FactionRuntime faction, + IReadOnlyDictionary previousPrograms, + IReadOnlyCollection programs, + DateTimeOffset nowUtc) + { + var activeIds = programs.Select(program => program.Id).ToHashSet(StringComparer.Ordinal); + foreach (var program in programs.Where(candidate => !previousPrograms.ContainsKey(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{program.Id}-started", + Kind = "program-started", + Summary = $"Started production program {program.Kind}.", + RelatedEntityId = program.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + + foreach (var previous in previousPrograms.Values.Where(candidate => !activeIds.Contains(candidate.Id))) + { + AppendDecision(faction, new FactionDecisionLogEntryRuntime + { + Id = $"decision-{previous.Id}-stopped-{faction.StrategicState.PlanCycle}", + Kind = "program-stopped", + Summary = $"Stopped production program {previous.Kind}.", + RelatedEntityId = previous.Id, + PlanCycle = faction.StrategicState.PlanCycle, + OccurredAtUtc = nowUtc, + }); + } + } + + private static void ApplyDelegation( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + ICollection events, + DateTimeOffset nowUtc) + { + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id)) + { + commander.ActiveObjectiveIds.Clear(); + } + + foreach (var objective in faction.StrategicState.Objectives.Where(candidate => candidate.CommanderId is not null)) + { + if (world.Commanders.FirstOrDefault(candidate => candidate.Id == objective.CommanderId) is not { } commander) { continue; } - var source = stationsById.Values - .Where(station => - station.Id != destination.Id - && GetInventoryAmount(station.Inventory, demand.ItemId) > 1f) - .OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId)) + commander.ActiveObjectiveIds.Add(objective.Id); + } + + var fleetCommanders = EnsureFleetCommanders(world, faction, factionCommander, nowUtc); + + var focusCampaign = faction.StrategicState.Campaigns + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + factionCommander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = focusCampaign?.Id ?? $"objective-strategic-{faction.Id}", + CampaignId = focusCampaign?.Id, + TheaterId = focusCampaign?.TheaterId, + Kind = "strategic-executive", + BehaviorKind = "strategic-executive", + Status = "active", + Priority = 100f, + HomeSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, + TargetSystemId = focusCampaign?.TargetSystemId ?? faction.StrategicState.ThreatAssessment.PrimaryThreatSystemId ?? faction.StrategicState.EconomicAssessment.PrimaryExpansionSystemId, + TargetEntityId = focusCampaign?.TargetEntityId, + Notes = focusCampaign?.Summary ?? faction.StrategicState.Status, + UpdatedAtUtc = nowUtc, + }; + + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == faction.Id && candidate.Kind is CommanderKind.Ship or CommanderKind.Station)) + { + var objective = faction.StrategicState.Objectives + .Where(candidate => string.Equals(candidate.CommanderId, commander.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) .FirstOrDefault(); - if (source is not null) + if (commander.Kind == CommanderKind.Ship + && world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } ship) { - return (demand.ItemId, source.Id, destination.Id); + if (ApplyShipControlSurface(world, faction, factionCommander, commander, ship, objective, fleetCommanders, nowUtc)) + { + ship.NeedsReplan = true; + ship.LastReplanReason = objective is null ? "faction-fallback-updated" : "faction-objective-updated"; + } } + + if (objective is not null) + { + commander.NeedsReplan = true; + } + } + + RefreshCommanderHierarchy(world, faction.Id); + + events.Add(new SimulationEventRecord( + "faction", + faction.Id, + "strategic-cycle", + $"{faction.Label} strategic cycle {faction.StrategicState.PlanCycle} updated {faction.StrategicState.Campaigns.Count} campaigns across {faction.StrategicState.Theaters.Count} theaters.", + nowUtc)); + } + + private static void UpdateFleetCommander(SimulationWorld world, CommanderRuntime commander) + { + commander.ReplanTimer = FleetCommanderReplanInterval; + commander.NeedsReplan = false; + commander.PlanningCycle += 1; + commander.ActiveObjectiveIds.Clear(); + + var faction = FindFaction(world, commander.FactionId); + var campaign = faction?.StrategicState.Campaigns.FirstOrDefault(candidate => candidate.Id == commander.ControlledEntityId); + if (faction is null || campaign is null) + { + commander.IsAlive = false; + commander.Assignment = null; + return; + } + + var objectives = faction.StrategicState.Objectives + .Where(candidate => candidate.CampaignId == campaign.Id && candidate.CommanderId is not null) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + foreach (var objective in objectives) + { + commander.ActiveObjectiveIds.Add(objective.Id); + } + + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = campaign.Id, + CampaignId = campaign.Id, + TheaterId = campaign.TheaterId, + Kind = "fleet-command", + BehaviorKind = campaign.Kind switch + { + "offense" => "attack-target", + "defense" => "protect-position", + "expansion" => "protect-position", + _ => "patrol", + }, + Status = campaign.Status, + Priority = campaign.Priority, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetEntityId = campaign.TargetEntityId, + Notes = campaign.Summary, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; + } + + private static CommanderAssignmentRuntime BuildStationFocusAssignment( + SimulationWorld world, + FactionRuntime faction, + StationRuntime station, + ConstructionSiteRuntime? activeSite) + { + var economic = faction.StrategicState.EconomicAssessment; + var role = StationSimulationService.DetermineStationRole(station); + var bottleneckItem = economic.IndustrialBottleneckItemId; + var nowUtc = DateTimeOffset.UtcNow; + + if (HasStationModules(station, "module_gen_build_l_01") + && economic.MilitaryShipCount < economic.TargetMilitaryShipCount) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-ship-production", + Kind = "ship-production-focus", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 55f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem ?? "hullparts", + Notes = "Prioritize military replacement output", + UpdatedAtUtc = nowUtc, + }; + } + + if (!string.IsNullOrWhiteSpace(bottleneckItem) && StationCanProduceItem(world, station, bottleneckItem)) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-commodity-focus-{bottleneckItem}", + Kind = "commodity-focus", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 45f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem, + Notes = $"Stabilize {bottleneckItem} production", + UpdatedAtUtc = nowUtc, + }; + } + + if (activeSite is not null) + { + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-expansion-support", + Kind = "expansion-support", + BehaviorKind = "find-build-tasks", + Status = "active", + Priority = 40f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = activeSite.SystemId, + TargetEntityId = activeSite.Id, + ItemId = economic.IndustrialBottleneckItemId, + Notes = $"Support {activeSite.BlueprintId}", + UpdatedAtUtc = nowUtc, + }; + } + + return new CommanderAssignmentRuntime + { + ObjectiveId = $"objective-station-{station.Id}-oversight", + Kind = "station-oversight", + BehaviorKind = "fill-shortages", + Status = "active", + Priority = 30f, + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + TargetSystemId = station.SystemId, + TargetEntityId = station.Id, + ItemId = bottleneckItem, + Notes = role, + UpdatedAtUtc = nowUtc, + }; + } + + private static IReadOnlyDictionary EnsureFleetCommanders( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + DateTimeOffset nowUtc) + { + var activeCampaignIds = faction.StrategicState.Campaigns + .Where(campaign => + campaign.Status == "active" && + faction.StrategicState.Objectives.Any(objective => + objective.CampaignId == campaign.Id && + objective.CommanderId is not null && + (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal)))) + .Select(campaign => campaign.Id) + .ToHashSet(StringComparer.Ordinal); + + world.Commanders.RemoveAll(commander => + commander.Kind == CommanderKind.Fleet && + commander.FactionId == faction.Id && + (commander.ControlledEntityId is null || !activeCampaignIds.Contains(commander.ControlledEntityId))); + + var fleetCommanders = new Dictionary(StringComparer.Ordinal); + foreach (var campaign in faction.StrategicState.Campaigns.Where(campaign => activeCampaignIds.Contains(campaign.Id))) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Fleet && + candidate.FactionId == faction.Id && + string.Equals(candidate.ControlledEntityId, campaign.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-fleet-{campaign.Id}", + Kind = CommanderKind.Fleet, + FactionId = faction.Id, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = campaign.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "fleet-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = Math.Clamp(factionCommander.Skills.Leadership, 3, 5), + Coordination = Math.Clamp(factionCommander.Skills.Coordination + 1, 3, 5), + Strategy = Math.Clamp(factionCommander.Skills.Strategy, 3, 5), + }, + ReplanTimer = 0f, + NeedsReplan = true, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = factionCommander.Id; + commander.PolicySetId = factionCommander.PolicySetId; + commander.IsAlive = true; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = campaign.Id, + CampaignId = campaign.Id, + TheaterId = campaign.TheaterId, + Kind = "fleet-command", + BehaviorKind = campaign.Kind switch + { + "offense" => "attack-target", + "defense" => "protect-position", + "expansion" => "protect-position", + _ => "patrol", + }, + Status = campaign.Status, + Priority = campaign.Priority + 1f, + HomeSystemId = campaign.TargetSystemId, + TargetSystemId = campaign.TargetSystemId, + TargetEntityId = campaign.TargetEntityId, + Notes = campaign.Summary, + UpdatedAtUtc = nowUtc, + }; + campaign.FleetCommanderId = commander.Id; + fleetCommanders[campaign.Id] = commander; + } + + return fleetCommanders; + } + + private static bool ApplyShipControlSurface( + SimulationWorld world, + FactionRuntime faction, + CommanderRuntime factionCommander, + CommanderRuntime commander, + ShipRuntime ship, + FactionOperationalObjectiveRuntime? objective, + IReadOnlyDictionary fleetCommanders, + DateTimeOffset nowUtc) + { + var desiredParentCommanderId = ResolveDelegatedParent(world, factionCommander, commander, objective, fleetCommanders); + var parentChanged = !string.Equals(commander.ParentCommanderId, desiredParentCommanderId, StringComparison.Ordinal); + var policyChanged = !string.Equals(commander.PolicySetId, factionCommander.PolicySetId, StringComparison.Ordinal); + commander.ParentCommanderId = desiredParentCommanderId; + commander.PolicySetId = factionCommander.PolicySetId; + ship.PolicySetId = commander.PolicySetId; + + var desiredBehavior = objective is null + ? BuildFallbackBehavior(world, ship) + : BuildBehaviorForObjective(world, ship, objective); + var behaviorChanged = !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior); + if (behaviorChanged) + { + ApplyBehavior(ship.DefaultBehavior, desiredBehavior); + } + + var desiredOrder = BuildAiOrderForObjective(world, ship, objective, nowUtc); + var ordersChanged = ReconcileAiOrders(ship, desiredOrder); + var desiredControlSourceKind = objective is null ? "faction-fallback" : "faction-objective"; + var desiredControlSourceId = objective?.Id ?? commander.Id; + var desiredControlReason = objective?.Notes ?? objective?.Kind ?? desiredBehavior.Kind; + var controlChanged = + !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) + || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) + || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + ship.LastDeltaSignature = parentChanged || controlChanged ? string.Empty : ship.LastDeltaSignature; + return policyChanged || behaviorChanged || ordersChanged; + } + + private static string ResolveDelegatedParent( + SimulationWorld world, + CommanderRuntime factionCommander, + CommanderRuntime commander, + FactionOperationalObjectiveRuntime? objective, + IReadOnlyDictionary fleetCommanders) + { + if (objective?.CampaignId is not null + && fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander) + && (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, "supply-fleet", StringComparison.Ordinal))) + { + return fleetCommander.Id; + } + + if (objective?.HomeStationId is not null + && world.Stations.FirstOrDefault(station => station.Id == objective.HomeStationId)?.CommanderId is { } stationCommanderId) + { + return stationCommanderId; + } + + return factionCommander.Id; + } + + private static DefaultBehaviorRuntime BuildFallbackBehavior(SimulationWorld world, ShipRuntime ship) + { + var homeStation = ResolveFallbackHomeStation(world, ship); + if (HasShipCapabilities(ship.Definition, "mining")) + { + return new DefaultBehaviorRuntime + { + Kind = ship.Definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + PreferredItemId = null, + Radius = 24f, + MaxSystemRange = ship.Definition.CargoCapacity >= 120f ? 3 : 1, + }; + } + + if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "advanced-auto-trade", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + Radius = 24f, + MaxSystemRange = 2, + }; + } + + if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "construct-station", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + Radius = 28f, + MaxSystemRange = 2, + }; + } + + if (string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)) + { + var anchor = homeStation?.Position ?? ship.Position; + var patrolRadius = (homeStation?.Radius ?? 30f) + 90f; + return new DefaultBehaviorRuntime + { + Kind = "patrol", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + TargetPosition = anchor, + PatrolPoints = BuildPatrolPoints(anchor, patrolRadius), + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + Radius = patrolRadius, + MaxSystemRange = 1, + WaitSeconds = 2f, + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; + } + + return new DefaultBehaviorRuntime + { + Kind = "idle", + HomeSystemId = homeStation?.SystemId ?? ship.SystemId, + HomeStationId = homeStation?.Id, + AreaSystemId = homeStation?.SystemId ?? ship.SystemId, + }; + } + + private static StationRuntime? ResolveFallbackHomeStation(SimulationWorld world, ShipRuntime ship) => + world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static DefaultBehaviorRuntime BuildBehaviorForObjective( + SimulationWorld world, + ShipRuntime ship, + FactionOperationalObjectiveRuntime objective) + { + var fallback = BuildFallbackBehavior(world, ship); + var areaSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? fallback.AreaSystemId ?? ship.SystemId; + var radius = objective.BehaviorKind switch + { + "protect-position" or "protect-station" or "patrol" or "police" => MathF.Max(80f, fallback.Radius), + "follow-ship" or "protect-ship" => MathF.Max(18f, fallback.Radius * 0.6f), + "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" => MathF.Max(20f, fallback.Radius), + _ => fallback.Radius, + }; + var maxRange = objective.BehaviorKind switch + { + "attack-target" or "protect-position" or "protect-station" or "protect-ship" or "patrol" or "police" => Math.Max(1, fallback.MaxSystemRange), + "fill-shortages" or "advanced-auto-trade" or "find-build-tasks" or "supply-fleet" => Math.Max(2, fallback.MaxSystemRange), + _ => fallback.MaxSystemRange, + }; + + return new DefaultBehaviorRuntime + { + Kind = objective.BehaviorKind, + HomeSystemId = objective.HomeSystemId ?? fallback.HomeSystemId ?? ship.SystemId, + HomeStationId = objective.HomeStationId ?? fallback.HomeStationId, + AreaSystemId = areaSystemId, + TargetEntityId = objective.TargetEntityId, + PreferredItemId = objective.ItemId ?? fallback.PreferredItemId, + PreferredNodeId = fallback.PreferredNodeId, + PreferredConstructionSiteId = objective.Kind is "construct-site" or "supply-site" ? objective.TargetEntityId : fallback.PreferredConstructionSiteId, + PreferredModuleId = fallback.PreferredModuleId, + TargetPosition = objective.TargetPosition ?? fallback.TargetPosition, + WaitSeconds = objective.BehaviorKind == "supply-fleet" ? 4f : fallback.WaitSeconds, + Radius = radius, + MaxSystemRange = maxRange, + KnownStationsOnly = objective.BehaviorKind == "revisit-known-stations", + PatrolPoints = objective.BehaviorKind == "patrol" + ? BuildPatrolPoints(objective.TargetPosition ?? fallback.TargetPosition ?? ship.Position, radius) + : [], + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + RepeatOrders = [], + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; + } + + private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) + { + target.Kind = source.Kind; + target.HomeSystemId = source.HomeSystemId; + target.HomeStationId = source.HomeStationId; + target.AreaSystemId = source.AreaSystemId; + target.TargetEntityId = source.TargetEntityId; + target.PreferredItemId = source.PreferredItemId; + target.PreferredNodeId = source.PreferredNodeId; + target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; + target.PreferredModuleId = source.PreferredModuleId; + target.TargetPosition = source.TargetPosition; + target.WaitSeconds = source.WaitSeconds; + target.Radius = source.Radius; + target.MaxSystemRange = source.MaxSystemRange; + target.KnownStationsOnly = source.KnownStationsOnly; + target.PatrolPoints = source.PatrolPoints.ToList(); + target.PatrolIndex = source.PatrolIndex; + target.RepeatOrders = source.RepeatOrders.ToList(); + target.RepeatIndex = source.RepeatIndex; + } + + private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly + && left.PatrolPoints.SequenceEqual(right.PatrolPoints) + && left.RepeatOrders.Count == right.RepeatOrders.Count + && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); + + private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static ShipOrderRuntime? BuildAiOrderForObjective( + SimulationWorld world, + ShipRuntime ship, + FactionOperationalObjectiveRuntime? objective, + DateTimeOffset nowUtc) + { + if (objective is null || !objective.UseOrders || string.IsNullOrWhiteSpace(objective.StagingOrderKind)) + { + return null; + } + + var targetSystemId = objective.TargetSystemId ?? objective.HomeSystemId ?? ship.SystemId; + var targetPosition = objective.TargetPosition + ?? ResolveEntityPosition(world, objective.TargetEntityId) + ?? ship.Position; + var alreadyInSystem = string.Equals(ship.SystemId, targetSystemId, StringComparison.Ordinal); + var inPosition = alreadyInSystem && ship.Position.DistanceTo(targetPosition) <= MathF.Max(14f, objective.ReinforcementLevel * 18f); + if (inPosition) + { + return null; + } + + return new ShipOrderRuntime + { + Id = $"ai-order-{objective.Id}", + Kind = objective.StagingOrderKind, + Priority = 90 + objective.ReinforcementLevel, + InterruptCurrentPlan = true, + Label = $"{objective.Kind} staging", + TargetEntityId = objective.TargetEntityId, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + DestinationStationId = objective.BehaviorKind == "dock-and-wait" ? objective.TargetEntityId : null, + ItemId = objective.ItemId, + WaitSeconds = 0f, + Radius = MathF.Max(12f, objective.ReinforcementLevel * 18f), + MaxSystemRange = null, + KnownStationsOnly = false, + }; + } + + private static Vector3? ResolveEntityPosition(SimulationWorld world, string? entityId) + { + if (entityId is null) + { + return null; + } + + var shipPosition = world.Ships.FirstOrDefault(ship => ship.Id == entityId)?.Position; + if (shipPosition is not null) + { + return shipPosition; + } + + var stationPosition = world.Stations.FirstOrDefault(station => station.Id == entityId)?.Position; + if (stationPosition is not null) + { + return stationPosition; + } + + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId); + if (site?.CelestialId is { } celestialId) + { + return world.Celestials.FirstOrDefault(celestial => celestial.Id == celestialId)?.Position; } return null; } - private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId) + private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder) { - if (!world.ItemDefinitions.TryGetValue(itemId, out var definition)) + var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal) && (desiredOrder is null || !string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal))) > 0; + if (desiredOrder is null) { - return false; + return changed; } - var requiredModule = GetStorageRequirement(definition.CargoKind); - if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal)); + if (existing is not null) { - return false; + if (ShipOrdersEqual(existing, desiredOrder)) + { + return changed; + } + + ship.OrderQueue.Remove(existing); + changed = true; } - var capacity = GetStationStorageCapacity(station, definition.CargoKind); - if (capacity <= 0.01f) + if (ship.OrderQueue.Count >= MaxAiOrdersPerShip) { - return false; + changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0; } - var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal)) - .Sum(entry => entry.Value); + if (ship.OrderQueue.Count < 8) + { + ship.OrderQueue.Add(desiredOrder); + changed = true; + } - return used <= capacity - 1f; + return changed; } + + private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.Priority == right.Priority + && left.InterruptCurrentPlan == right.InterruptCurrentPlan + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static void RefreshCommanderHierarchy(SimulationWorld world, string factionId) + { + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId)) + { + commander.SubordinateCommanderIds.Clear(); + } + + var commandersById = world.Commanders.ToDictionary(commander => commander.Id, StringComparer.Ordinal); + foreach (var commander in world.Commanders.Where(candidate => candidate.FactionId == factionId && candidate.ParentCommanderId is not null)) + { + if (commander.ParentCommanderId is { } parentCommanderId && commandersById.TryGetValue(parentCommanderId, out var parent)) + { + parent.SubordinateCommanderIds.Add(commander.Id); + } + } + } + + private static bool IsCombatObjective(FactionOperationalObjectiveRuntime objective) => + objective.BehaviorKind is "attack-target" or "protect-position" or "protect-ship" or "protect-station" or "patrol" or "police"; + + private static float EstimateFriendlyAssetValue(SimulationWorld world, string factionId, string systemId) + { + var stationValue = world.Stations.Count(station => station.FactionId == factionId && station.SystemId == systemId) * 35f; + var shipValue = world.Ships.Count(ship => ship.FactionId == factionId && ship.SystemId == systemId && ship.Health > 0f) * 6f; + return stationValue + shipValue; + } + + private static bool CanRunOffensivePosture( + FactionRuntime faction, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) + { + var defenseLoad = threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind is "controlled-system" or "contested-system"); + var militarySurplus = economicAssessment.MilitaryShipCount + - Math.Max(1, economicAssessment.TargetMilitaryShipCount - faction.Doctrine.ReinforcementLeadPerFront); + return economicAssessment.SustainmentScore >= faction.Doctrine.OffensiveReadinessThreshold + && economicAssessment.LogisticsSecurityScore >= faction.Doctrine.SupplySecurityBias + && militarySurplus > 0 + && defenseLoad <= Math.Max(1, economicAssessment.ControlledSystemCount / 2); + } + + private static bool CanSupportExpansion( + FactionRuntime faction, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) => + economicAssessment.ConstructorShipCount > 0 + && economicAssessment.SustainmentScore >= 0.52f + && threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") <= 1 + && faction.StrategicState.Budget.ExpansionCredits > 0f; + + private static float ComputeSystemRisk(SimulationWorld world, FactionRuntime faction, string systemId) => + MathF.Max( + faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == systemId)?.RouteRisk ?? 0f, + GeopoliticalSimulationService.GetSystemRouteRisk(world, systemId, faction.Id)); + + private static SectorStrategicProfileRuntime? FindStrategicProfile(SimulationWorld world, string systemId) => + world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => string.Equals(profile.SystemId, systemId, StringComparison.Ordinal)); + + private static TerritoryPressureRuntime? FindTerritoryPressure(SimulationWorld world, string factionId, string systemId) => + world.Geopolitics?.Territory.Pressures + .Where(pressure => string.Equals(pressure.SystemId, systemId, StringComparison.Ordinal) + && (pressure.FactionId is null || string.Equals(pressure.FactionId, factionId, StringComparison.Ordinal))) + .OrderByDescending(pressure => pressure.PressureScore + pressure.CorridorRisk) + .ThenBy(pressure => pressure.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static RegionalSecurityAssessmentRuntime? FindRegionalSecurityAssessment(SimulationWorld world, string factionId, string systemId) + { + var regionId = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId)?.Id; + return regionId is null + ? null + : world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, regionId, StringComparison.Ordinal)); + } + + private static IEnumerable SelectOffensiveTargets( + SimulationWorld world, + FactionRuntime faction, + FactionThreatAssessmentRuntime threatAssessment, + FactionEconomicAssessmentRuntime economicAssessment) + { + return threatAssessment.ThreatSignals + .Where(signal => signal.ScopeKind == "hostile-system") + .Select(signal => + { + var memory = faction.Memory.SystemMemories.FirstOrDefault(candidate => candidate.SystemId == signal.ScopeId); + var distancePenalty = faction.Memory.SystemMemories + .Where(candidate => candidate.ControlledByFaction) + .Select(candidate => GetSystemDistanceTier(world, candidate.SystemId, signal.ScopeId)) + .DefaultIfEmpty(world.Systems.Count) + .Min(); + var failurePenalty = ((memory?.OffensiveFailures ?? 0) * 16f) + ((memory?.DefensiveFailures ?? 0) * 4f); + var value = (signal.EnemyStationCount * 28f) + (signal.EnemyShipCount * 8f); + var pressureBias = MathF.Max(0f, economicAssessment.SustainmentScore - 0.5f) * 25f; + var supplyRisk = memory?.RouteRisk ?? 0.1f; + var priority = 62f + value + pressureBias - (distancePenalty * 9f) - failurePenalty - (supplyRisk * 18f); + return new OffensiveTargetCandidate( + signal.ScopeId, + signal.EnemyFactionId, + ResolvePrimaryOffensiveAnchor(world, faction.Id, signal.ScopeId), + ResolveSystemAnchor(world, signal.ScopeId), + MathF.Max(0f, priority), + supplyRisk, + value); + }) + .Where(candidate => candidate.Priority > 35f) + .OrderByDescending(candidate => candidate.Priority) + .ThenBy(candidate => candidate.SystemId, StringComparer.Ordinal); + } + + private static string? ResolvePrimaryOffensiveAnchor(SimulationWorld world, string factionId, string systemId) => + world.Stations + .Where(station => station.FactionId != factionId && station.SystemId == systemId) + .OrderByDescending(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal) ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Select(station => station.Id) + .FirstOrDefault(); + + private static bool StationCanProduceItem(SimulationWorld world, StationRuntime station, string itemId) => + world.Recipes.Values.Any(recipe => + StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))); + + private static float ComputeCampaignSupplyAdequacy( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment) + { + var riskPenalty = theater.SupplyRisk * 0.45f; + var assetBias = MathF.Min(0.25f, theater.FriendlyAssetValue / 200f); + return Math.Clamp((economicAssessment.SustainmentScore * 0.75f) + assetBias - riskPenalty, 0f, 1f); + } + + private static float ComputeCampaignContinuationScore( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionSystemMemoryRuntime? systemMemory) + { + var successBias = theater.Kind switch + { + "offense-front" => (systemMemory?.OffensiveSuccesses ?? 0) * 0.08f, + _ => (systemMemory?.DefensiveSuccesses ?? 0) * 0.06f, + }; + var failurePenalty = theater.Kind switch + { + "offense-front" => (systemMemory?.OffensiveFailures ?? 0) * (0.09f + faction.Doctrine.FailureAversion * 0.1f), + _ => (systemMemory?.DefensiveFailures ?? 0) * 0.06f, + }; + return Math.Clamp( + 0.35f + + (theater.Priority / 160f) + + (economicAssessment.SustainmentScore * 0.4f) + + successBias + - failurePenalty + - (theater.SupplyRisk * 0.3f), + 0f, + 1.4f); + } + + private static string? ResolveCampaignPauseReason( + FactionRuntime faction, + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + FactionSystemMemoryRuntime? systemMemory) + { + return theater.Kind switch + { + "offense-front" when !CanRunOffensivePosture(faction, economicAssessment, threatAssessment) => "offensive-readiness-insufficient", + "offense-front" when (systemMemory?.OffensiveFailures ?? 0) >= 2 && economicAssessment.SustainmentScore < 0.75f => "recover-after-failure", + "offense-front" when economicAssessment.ReplacementPressure > Math.Max(4f, economicAssessment.TargetMilitaryShipCount * 0.4f) => "replacement-pressure", + "expansion-front" when !CanSupportExpansion(faction, economicAssessment, threatAssessment) => "expansion-delayed", + "economic-front" when economicAssessment.CriticalShortageCount == 0 && economicAssessment.SustainmentScore > 0.7f => "economy-stable", + _ => null, + }; + } + + private static int GetCampaignFailureCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => + theaterKind switch + { + "offense-front" => systemMemory?.OffensiveFailures ?? 0, + _ => systemMemory?.DefensiveFailures ?? 0, + }; + + private static int GetCampaignSuccessCount(FactionSystemMemoryRuntime? systemMemory, string theaterKind) => + theaterKind switch + { + "offense-front" => systemMemory?.OffensiveSuccesses ?? 0, + _ => systemMemory?.DefensiveSuccesses ?? 0, + }; + + private static bool RequiresReinforcement( + FactionTheaterRuntime theater, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) => + theater.Kind == "defense-front" + ? theater.Priority > 105f || threatAssessment.ThreatSignals.Count(signal => signal.ScopeKind == "controlled-system") > 1 + : theater.Kind == "offense-front" + ? economicAssessment.SustainmentScore > 0.7f && economicAssessment.MilitaryShipCount > economicAssessment.TargetMilitaryShipCount + : theater.Kind == "economic-front" + ? economicAssessment.CriticalShortageCount > 2 + : false; + + private static void UpdateCampaignMemoryFromOutcome( + SimulationWorld world, + FactionRuntime faction, + FactionCampaignRuntime campaign, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment, + DateTimeOffset nowUtc) + { + var systemMemory = campaign.TargetSystemId is null + ? null + : faction.Memory.SystemMemories.FirstOrDefault(memory => memory.SystemId == campaign.TargetSystemId); + var success = campaign.Kind switch + { + "defense" => campaign.TargetSystemId is not null + && FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) + && threatAssessment.ThreatSignals.All(signal => signal.ScopeId != campaign.TargetSystemId || signal.ScopeKind == "hostile-system"), + "offense" => campaign.TargetSystemId is not null + && (FactionControlsSystem(world, faction.Id, campaign.TargetSystemId) + || world.Stations.All(station => station.FactionId == faction.Id || station.SystemId != campaign.TargetSystemId)), + "expansion" => campaign.TargetSystemId is not null + && (world.ConstructionSites.Any(site => site.FactionId == faction.Id && site.SystemId == campaign.TargetSystemId) + || world.Stations.Any(station => station.FactionId == faction.Id && station.SystemId == campaign.TargetSystemId)), + "economic-stabilization" => campaign.CommodityId is not null + && economicAssessment.CommoditySignals.FirstOrDefault(signal => signal.ItemId == campaign.CommodityId) is { Level: not ("critical" or "low") }, + "force-build-up" => economicAssessment.MilitaryShipCount >= economicAssessment.TargetMilitaryShipCount, + _ => true, + }; + + if (systemMemory is not null) + { + if (campaign.Kind == "offense") + { + if (success) + { + systemMemory.OffensiveSuccesses += 1; + } + else + { + systemMemory.OffensiveFailures += 1; + } + } + else if (campaign.Kind == "defense") + { + if (success) + { + systemMemory.DefensiveSuccesses += 1; + systemMemory.FrontierPressure *= 0.75f; + systemMemory.RouteRisk *= 0.8f; + } + else + { + systemMemory.DefensiveFailures += 1; + } + } + } + + AppendOutcome(faction, new FactionOutcomeRecordRuntime + { + Id = $"outcome-{campaign.Id}-{nowUtc.ToUnixTimeMilliseconds()}", + Kind = success ? "campaign-success" : "campaign-failure", + Summary = $"{campaign.Kind} campaign {campaign.Id} {(success ? "succeeded" : "failed")}.", + RelatedCampaignId = campaign.Id, + OccurredAtUtc = nowUtc, + }); + } + + private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) + { + if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) + { + return 0; + } + + var originPosition = world.Systems.FirstOrDefault(system => system.Definition.Id == originSystemId)?.Position ?? Vector3.Zero; + return world.Systems + .OrderBy(system => system.Position.DistanceTo(originPosition)) + .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) + .Select(system => system.Definition.Id) + .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) + .Count(); + } + + private static List BuildPatrolPoints(Vector3 anchor, float radius) => + [ + new Vector3(anchor.X + radius, anchor.Y, anchor.Z), + new Vector3(anchor.X, anchor.Y, anchor.Z + radius), + new Vector3(anchor.X - radius, anchor.Y, anchor.Z), + new Vector3(anchor.X, anchor.Y, anchor.Z - radius), + ]; + + private sealed record OffensiveTargetCandidate( + string SystemId, + string? TargetFactionId, + string? AnchorEntityId, + Vector3 AnchorPosition, + float Priority, + float SupplyRisk, + float Value); + + private static CommanderAssignmentRuntime ToAssignment(FactionOperationalObjectiveRuntime objective) => new() + { + ObjectiveId = objective.Id, + CampaignId = objective.CampaignId, + TheaterId = objective.TheaterId, + Kind = objective.Kind, + BehaviorKind = objective.BehaviorKind, + Status = objective.Status, + Priority = objective.Priority, + HomeSystemId = objective.HomeSystemId, + HomeStationId = objective.HomeStationId, + TargetSystemId = objective.TargetSystemId, + TargetEntityId = objective.TargetEntityId, + TargetPosition = objective.TargetPosition, + ItemId = objective.ItemId, + Notes = objective.Notes, + UpdatedAtUtc = objective.UpdatedAtUtc, + }; + + private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) + && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) + && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) + && string.Equals(left.Status, right.Status, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && left.Priority.Equals(right.Priority); + } + + private static void AppendDecision(FactionRuntime faction, FactionDecisionLogEntryRuntime entry) + { + if (faction.DecisionLog.Any(candidate => candidate.Id == entry.Id)) + { + return; + } + + faction.DecisionLog.Add(entry); + if (faction.DecisionLog.Count > MaxDecisionLogEntries) + { + faction.DecisionLog.RemoveRange(0, faction.DecisionLog.Count - MaxDecisionLogEntries); + } + } + + private static void AppendOutcome(FactionRuntime faction, FactionOutcomeRecordRuntime entry) + { + if (faction.Memory.RecentOutcomes.Any(candidate => candidate.Id == entry.Id)) + { + return; + } + + faction.Memory.RecentOutcomes.Add(entry); + if (faction.Memory.RecentOutcomes.Count > MaxOutcomeEntries) + { + faction.Memory.RecentOutcomes.RemoveRange(0, faction.Memory.RecentOutcomes.Count - MaxOutcomeEntries); + } + } + + private static string ResolveStrategicStatus( + IReadOnlyCollection theaters, + IReadOnlyCollection campaigns, + FactionEconomicAssessmentRuntime economicAssessment, + FactionThreatAssessmentRuntime threatAssessment) + { + if (threatAssessment.ThreatSignals.Any(signal => signal.ScopeKind == "controlled-system")) + { + return "defending"; + } + + if (campaigns.Any(campaign => campaign.Kind == "offense")) + { + return "offensive"; + } + + if (campaigns.Any(campaign => campaign.Kind == "expansion")) + { + return "expanding"; + } + + if (economicAssessment.CommoditySignals.Any(signal => signal.Level is "critical" or "low")) + { + return "stabilizing"; + } + + return theaters.Count == 0 ? "stable" : "active"; + } + + private static float ComputeCommodityPriority(FactionCommoditySignalRuntime signal) + { + var levelBias = signal.Level switch + { + "critical" => 60f, + "low" => 35f, + _ => 10f, + }; + return levelBias + + signal.BuyBacklog + + signal.ReservedForConstruction + + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f); + } + + private static bool CanMineItem(SimulationWorld world, string itemId) => + world.Nodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal)); + + private static string? FindPrimaryCommoditySystem(SimulationWorld world, string factionId, string itemId) => + world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) + .Join( + world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), + bottleneck => bottleneck.RegionId, + region => region.Id, + (bottleneck, region) => new { bottleneck, region }) + .OrderByDescending(entry => entry.bottleneck.Severity) + .ThenBy(entry => entry.region.Id, StringComparer.Ordinal) + .Select(entry => entry.region.CoreSystemId) + .FirstOrDefault() + ?? world.Stations + .Where(station => station.FactionId == factionId && GetInventoryAmount(station.Inventory, itemId) > 0.01f) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .Select(station => station.SystemId) + .FirstOrDefault(); + + private static StationRuntime? ResolveCommodityAnchorStation(SimulationWorld world, string factionId, string itemId) => + world.Stations + .Where(station => station.FactionId == factionId) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, itemId)) + .ThenByDescending(station => station.MarketOrderIds.Count(orderId => + world.MarketOrders.Any(order => order.Id == orderId && order.ItemId == itemId && order.Kind == MarketOrderKinds.Buy))) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static string BuildCampaignSummary(FactionTheaterRuntime theater, IndustryExpansionProject? expansionProject) => + theater.Kind switch + { + "defense-front" => $"Defend {theater.SystemId} from hostile pressure.", + "offense-front" => $"Project force into {theater.SystemId}.", + "expansion-front" => $"Expand into {expansionProject?.CelestialId ?? theater.SystemId}.", + "economic-front" => $"Stabilize commodity shortages around {theater.AnchorEntityId ?? theater.SystemId}.", + _ => theater.Kind, + }; + + private static string? ResolveCommodityFromTheaterId(string? theaterId) + { + if (string.IsNullOrWhiteSpace(theaterId)) + { + return null; + } + + const string prefix = "theater-economy-"; + return theaterId.StartsWith(prefix, StringComparison.Ordinal) ? theaterId[prefix.Length..] : null; + } + + private static Vector3 ResolveSystemAnchor(SimulationWorld world, string? systemId) + { + if (systemId is null) + { + return Vector3.Zero; + } + + var station = world.Stations + .Where(candidate => candidate.SystemId == systemId) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (station is not null) + { + return station.Position; + } + + var celestial = world.Celestials + .Where(candidate => candidate.SystemId == systemId) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return celestial?.Position ?? Vector3.Zero; + } + + private static Vector3 ResolveExpansionAnchor(SimulationWorld world, IndustryExpansionProject project) + { + if (project.SiteId is not null + && world.ConstructionSites.FirstOrDefault(site => site.Id == project.SiteId) is { } site + && world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId) is { } siteCelestial) + { + return siteCelestial.Position; + } + + return world.Celestials.FirstOrDefault(candidate => candidate.Id == project.CelestialId)?.Position + ?? ResolveSystemAnchor(world, project.SystemId); + } + + private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) + => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); } diff --git a/apps/backend/Factions/AI/FactionController.cs b/apps/backend/Factions/AI/FactionController.cs deleted file mode 100644 index 526b3c1..0000000 --- a/apps/backend/Factions/AI/FactionController.cs +++ /dev/null @@ -1,224 +0,0 @@ - -namespace SpaceGame.Api.Factions.AI; - -// ─── Planning State ──────────────────────────────────────────────────────────── - -public sealed class FactionPlanningState -{ - 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 int TargetSystemCount { get; set; } - public bool HasShipFactory { get; set; } - public int EnemyFactionCount { get; set; } - public int EnemyShipCount { get; set; } - public int EnemyStationCount { get; set; } - public float OreStockpile { 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 => RefinedMetalsProjectedProductionRate > 0.01f; - public bool HasHullpartsProduction => HullpartsProjectedProductionRate > 0.01f; - public bool HasClaytronicsProduction => ClaytronicsProjectedProductionRate > 0.01f; - public bool HasWaterProduction => WaterProjectedProductionRate > 0.01f; - - public bool HasWarIndustrySupplyChain => - IsCommodityOperational(RefinedMetalsProjectedProductionRate, RefinedMetalsProjectedNetRate, RefinedMetalsLevelSeconds, RefinedMetalsLevel, 240f) - && IsCommodityOperational(HullpartsProjectedProductionRate, HullpartsProjectedNetRate, HullpartsLevelSeconds, HullpartsLevel, 240f) - && IsCommodityOperational(ClaytronicsProjectedProductionRate, ClaytronicsProjectedNetRate, ClaytronicsLevelSeconds, ClaytronicsLevel, 240f); - - public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone(); - - internal static int ComputeTargetWarships(FactionPlanningState state) - { - 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 ───────────────────────────────────────────────────────────────────── - -public sealed class EnsureWarIndustryGoal : GoapGoal -{ - public override string Name => "ensure-war-industry"; - - public override bool IsSatisfied(FactionPlanningState state) => - state.EnemyFactionCount <= 0 || (state.HasWarIndustrySupplyChain && state.HasShipFactory); - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - if (state.EnemyFactionCount <= 0) - { - return 0f; - } - - var missingStages = - (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 && supplyNeed <= 0.01f ? 0f : 110f + (missingStages * 22f) + (supplyNeed * 0.18f); - } -} - -public sealed class EnsureWaterSecurityGoal : GoapGoal -{ - public override string Name => "ensure-water-security"; - - public override bool IsSatisfied(FactionPlanningState state) => - FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f); - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - if (FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f)) - { - return 0f; - } - - return 55f + FactionPlanningState.ComputeCommodityNeed( - state.WaterProjectedProductionRate, - state.WaterUsageRate, - state.WaterProjectedNetRate, - state.WaterLevelSeconds, - state.WaterLevel, - 300f) * 0.25f; - } -} - -public sealed class EnsureWarFleetGoal : GoapGoal -{ - public override string Name => "ensure-war-fleet"; - - public override bool IsSatisfied(FactionPlanningState state) => - state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state); - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount; - return deficit <= 0 ? 0f : 50f + (deficit * 10f); - } -} - -public sealed class ExterminateRivalGoal : GoapGoal -{ - public override string Name => "exterminate-rival"; - - public override bool IsSatisfied(FactionPlanningState state) => - state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0); - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - if (state.EnemyFactionCount <= 0) - { - return 0f; - } - - return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f); - } -} - -public sealed class ExpandTerritoryGoal : GoapGoal -{ - public override string Name => "expand-territory"; - - public override bool IsSatisfied(FactionPlanningState state) => - state.ControlledSystemCount >= state.TargetSystemCount; - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - var deficit = state.TargetSystemCount - state.ControlledSystemCount; - return deficit <= 0 ? 0f : 80f + (deficit * 15f); - } -} - -public sealed class EnsureMiningCapacityGoal : GoapGoal -{ - private const int MinMiners = 2; - - public override string Name => "ensure-mining-capacity"; - - public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners; - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - var deficit = MinMiners - state.MinerShipCount; - return deficit <= 0 ? 0f : 70f + (deficit * 12f); - } -} - -public sealed class EnsureConstructionCapacityGoal : GoapGoal -{ - private const int MinConstructors = 1; - - public override string Name => "ensure-construction-capacity"; - - public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors; - - public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) - { - var deficit = MinConstructors - state.ConstructorShipCount; - return deficit <= 0 ? 0f : 60f + (deficit * 10f); - } -} diff --git a/apps/backend/Factions/AI/FactionObjectivePlanning.cs b/apps/backend/Factions/AI/FactionObjectivePlanning.cs deleted file mode 100644 index c196ffd..0000000 --- a/apps/backend/Factions/AI/FactionObjectivePlanning.cs +++ /dev/null @@ -1,1385 +0,0 @@ -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 record StepExecutionAssessment( - FactionPlanStepStatus Status, - string StatusReason, - string? BlockingReason = null, - StepExecutionBinding? Binding = null, - IndustryExpansionProject? ExpectedProject = null); - -internal sealed record StepExecutionBinding( - string Kind, - string? TargetId, - string Summary); - -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."); - var activeProject = FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId); - - 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)) - { - var assessment = EvaluateStep(world, commander, objective, step, blackboard, state, activeProject); - EmitTasks(engine, world, commander, objective, step, assessment, 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 StepExecutionAssessment EvaluateStep( - SimulationWorld world, - CommanderRuntime commander, - FactionObjectiveRuntime objective, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - FactionPlanningState state, - IndustryExpansionProject? activeProject) - { - step.LastEvaluatedCycle = commander.PlanningCycle; - step.BlockingReason = null; - step.StatusReason = null; - step.ExecutionBindingKind = null; - step.ExecutionBindingTargetId = null; - step.ExecutionBindingSummary = null; - - StepExecutionAssessment assessment; - if (step.DependencyStepIds.Count > 0 && HasIncompleteDependencies(commander, step)) - { - assessment = new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - "Blocked on prerequisite objective steps.", - BlockingReason: "Waiting for prerequisite objective steps to complete."); - } - else - { - assessment = step.Kind switch - { - FactionPlanStepKind.EnsureCommodityProduction => EvaluateCommodityStep(world, commander, step, blackboard, activeProject), - FactionPlanStepKind.EnsureShipyardSite => EvaluateShipyardStep(world, commander, step, state, activeProject), - FactionPlanStepKind.ProduceFleet => EvaluateFleetProductionStep(commander, step, blackboard, state), - FactionPlanStepKind.AttackFactionAssets => EvaluateAttackStep(commander, step, blackboard, state), - FactionPlanStepKind.EnsureWaterSupply => EvaluateWaterStep(world, commander, step, blackboard, activeProject), - FactionPlanStepKind.EnsureMiningCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.MinerShipCount, 2, "mining"), - FactionPlanStepKind.EnsureConstructionCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.ConstructorShipCount, 1, "construction"), - FactionPlanStepKind.EnsureTransportCapacity => EvaluateCapacityStep(commander, step, blackboard.HasShipyard, state.TransportShipCount, 1, "transport"), - FactionPlanStepKind.MonitorExpansionProject => EvaluateWarIndustryMonitorStep(world, commander, step, blackboard, activeProject), - _ => new StepExecutionAssessment(FactionPlanStepStatus.Failed, "Unknown step kind."), - }; - } - - ApplyAssessment(step, assessment); - return assessment; - } - - private static StepExecutionAssessment EvaluateCommodityStep( - SimulationWorld world, - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - IndustryExpansionProject? activeProject) - { - if (string.IsNullOrWhiteSpace(step.CommodityId)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Failed, - "Commodity step is missing a required commodity.", - BlockingReason: "Commodity planning step is missing a target commodity."); - } - - if (IsCommodityOperational(blackboard, step.CommodityId, 240f)) - { - step.ProducedFacts.Add($"commodity-online:{step.CommodityId}"); - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - $"Commodity {step.CommodityId} is operational in the faction economy."); - } - - var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, step.CommodityId, ignoreActiveExpansionProject: true); - return EvaluateExpansionRequirement(step, expectedProject, activeProject); - } - - private static StepExecutionAssessment EvaluateShipyardStep( - SimulationWorld world, - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionPlanningState state, - IndustryExpansionProject? activeProject) - { - if (state.HasShipFactory) - { - step.ProducedFacts.Add("shipyard-online"); - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - "Faction already has an online shipyard."); - } - - var expectedProject = FactionIndustryPlanner.CreateShipyardFoundationProject(world, commander.FactionId, ignoreActiveExpansionProject: true); - return EvaluateExpansionRequirement(step, expectedProject, activeProject); - } - - private static StepExecutionAssessment EvaluateWaterStep( - SimulationWorld world, - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - IndustryExpansionProject? activeProject) - { - if (IsCommodityOperational(blackboard, "water", 300f)) - { - step.ProducedFacts.Add("commodity-online:water"); - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - "Water supply is operational."); - } - - var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water", ignoreActiveExpansionProject: true); - return EvaluateExpansionRequirement(step, expectedProject, activeProject); - } - - private static StepExecutionAssessment EvaluateFleetProductionStep( - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - FactionPlanningState state) - { - if (state.MilitaryShipCount >= blackboard.TargetWarshipCount) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - "Target war fleet size has been reached."); - } - - if (!blackboard.HasShipyard) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - "Fleet production requires an online shipyard.", - BlockingReason: "Fleet production requires an online shipyard."); - } - - if (TryFindIssuedTaskBinding(commander, step, out var binding)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Running, - "Military fleet production is already bound to an issued ship-production task.", - Binding: binding); - } - - return new StepExecutionAssessment( - FactionPlanStepStatus.Ready, - "Shipyard is available; military fleet production can begin."); - } - - private static StepExecutionAssessment EvaluateAttackStep( - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - FactionPlanningState state) - { - if (blackboard.EnemyFactionCount <= 0) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - "No hostile faction remains to attack."); - } - - if (state.MilitaryShipCount < Math.Max(2, blackboard.TargetWarshipCount / 2)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - "Insufficient military strength to begin the attack objective.", - BlockingReason: "Insufficient military strength to commit to a faction attack objective."); - } - - if (TryFindIssuedTaskBinding(commander, step, out var binding)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Running, - "Attack objective is already bound to a matching combat task.", - Binding: binding); - } - - return new StepExecutionAssessment( - FactionPlanStepStatus.Ready, - "Combat strength is available; attack execution can begin."); - } - - private static StepExecutionAssessment EvaluateCapacityStep( - CommanderRuntime commander, - FactionPlanStepRuntime step, - bool hasShipyard, - int currentCount, - int requiredCount, - string shipRole) - { - if (currentCount >= requiredCount) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - $"Faction already meets the required {shipRole} ship capacity."); - } - - if (!hasShipyard) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - $"No shipyard is currently assigned to produce {shipRole} ships.", - BlockingReason: $"Ship capacity expansion for {shipRole} requires an online shipyard."); - } - - if (TryFindIssuedTaskBinding(commander, step, out var binding)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Running, - $"{shipRole} ship production is already bound to a matching issued task.", - Binding: binding); - } - - return new StepExecutionAssessment( - FactionPlanStepStatus.Ready, - $"Shipyard capacity is available; {shipRole} ship production can begin."); - } - - private static StepExecutionAssessment EvaluateWarIndustryMonitorStep( - SimulationWorld world, - CommanderRuntime commander, - FactionPlanStepRuntime step, - FactionBlackboardRuntime blackboard, - IndustryExpansionProject? activeProject) - { - if (blackboard.HasWarIndustrySupplyChain) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Complete, - "War-industry supply chain is operational."); - } - - foreach (var commodityId in new[] { "refinedmetals", "hullparts", "claytronics" }) - { - if (IsCommodityOperational(blackboard, commodityId, 240f)) - { - continue; - } - - var expectedProject = FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId, ignoreActiveExpansionProject: true); - return EvaluateExpansionRequirement(step, expectedProject, activeProject); - } - - return new StepExecutionAssessment( - FactionPlanStepStatus.Ready, - "War-industry prerequisites are unresolved but no matching active project is bound yet."); - } - - private static StepExecutionAssessment EvaluateExpansionRequirement( - FactionPlanStepRuntime step, - IndustryExpansionProject? expectedProject, - IndustryExpansionProject? activeProject) - { - if (expectedProject is null) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - "Unable to derive a valid expansion plan for the step outcome.", - BlockingReason: BuildMissingPlanReason(step)); - } - - if (activeProject is not null && ProjectsSemanticallyMatch(expectedProject, activeProject)) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Running, - $"Running on matching active expansion project {DescribeProject(activeProject)}.", - Binding: new StepExecutionBinding( - "expansion-project", - activeProject.SiteId, - $"Matched active project {DescribeProject(activeProject)}."), - ExpectedProject: activeProject); - } - - if (activeProject is not null) - { - return new StepExecutionAssessment( - FactionPlanStepStatus.Blocked, - $"Blocked by unrelated active expansion {DescribeProject(activeProject)}; step requires {DescribeProject(expectedProject)}.", - BlockingReason: $"Active expansion {DescribeProject(activeProject)} does not satisfy required outcome {DescribeProject(expectedProject)}.", - ExpectedProject: expectedProject); - } - - return new StepExecutionAssessment( - FactionPlanStepStatus.Ready, - $"Ready to start required expansion {DescribeProject(expectedProject)}.", - ExpectedProject: expectedProject); - } - - private static void ApplyAssessment( - FactionPlanStepRuntime step, - StepExecutionAssessment assessment) - { - step.Status = assessment.Status; - step.StatusReason = assessment.StatusReason; - step.BlockingReason = assessment.BlockingReason; - step.ExecutionBindingKind = assessment.Binding?.Kind; - step.ExecutionBindingTargetId = assessment.Binding?.TargetId; - step.ExecutionBindingSummary = assessment.Binding?.Summary; - } - - 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, - StepExecutionAssessment assessment, - 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: - EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); - break; - case FactionPlanStepKind.EnsureShipyardSite: - EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); - break; - case FactionPlanStepKind.ProduceFleet: - 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); - PromoteShipProductionStepToRunning(step, "military"); - break; - case FactionPlanStepKind.AttackFactionAssets: - UpsertAttackTask(commander, objective, step, touchedTaskIds); - AssignCombatAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - PromoteCombatStepToRunning(step); - break; - case FactionPlanStepKind.EnsureWaterSupply: - EmitExpansionExecution(world, commander, objective, step, assessment, touchedTaskIds, assignedAssetIds); - 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); - PromoteShipProductionStepToRunning(step, "mining"); - 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); - PromoteShipProductionStepToRunning(step, "construction"); - 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); - PromoteShipProductionStepToRunning(step, "transport"); - break; - case FactionPlanStepKind.MonitorExpansionProject: - UpsertWarIndustryTask(commander, objective, step, touchedTaskIds); - break; - } - } - - private static void EmitExpansionExecution( - SimulationWorld world, - CommanderRuntime commander, - FactionObjectiveRuntime objective, - FactionPlanStepRuntime step, - StepExecutionAssessment assessment, - ISet touchedTaskIds, - ISet assignedAssetIds) - { - var project = assessment.ExpectedProject; - if (project is null) - { - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: step.CommodityId, - moduleId: step.ModuleId, - targetSystemId: null, - targetSiteId: null, - blockingReason: step.BlockingReason, - notes: step.StatusReason); - return; - } - - if (step.Status == FactionPlanStepStatus.Ready) - { - FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); - project = project with { SiteId = project.SiteId ?? FindMatchingSiteId(world, commander.FactionId, project) }; - step.TargetSiteId = project.SiteId; - step.Status = FactionPlanStepStatus.Running; - step.StatusReason = $"Started required expansion {DescribeProject(project)}."; - step.ExecutionBindingKind = "expansion-project"; - step.ExecutionBindingTargetId = project.SiteId; - step.ExecutionBindingSummary = $"Started site {project.SiteId ?? "pending"} for {DescribeProject(project)}."; - } - - if (step.Status == FactionPlanStepStatus.Running) - { - step.TargetSiteId ??= project.SiteId; - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: project.CommodityId, - moduleId: project.ModuleId, - targetSystemId: project.SystemId, - targetSiteId: project.SiteId, - blockingReason: step.BlockingReason, - notes: step.StatusReason); - AssignConstructionAssets(world, commander, objective, step, step.IssuedTaskIds, assignedAssetIds); - return; - } - - UpsertExpansionTask( - commander, - objective, - step, - touchedTaskIds, - commodityId: project.CommodityId, - moduleId: project.ModuleId, - targetSystemId: project.SystemId, - targetSiteId: project.SiteId, - blockingReason: step.BlockingReason, - notes: step.StatusReason); - } - - private static void PromoteShipProductionStepToRunning(FactionPlanStepRuntime step, string shipRole) - { - if (step.Status != FactionPlanStepStatus.Ready || step.AssignedAssetIds.Count == 0) - { - return; - } - - step.Status = FactionPlanStepStatus.Running; - step.StatusReason = $"{titleCase(shipRole)} ship production is bound to shipyard assets."; - step.ExecutionBindingKind = "shipyard-production"; - step.ExecutionBindingTargetId = step.AssignedAssetIds.FirstOrDefault(); - step.ExecutionBindingSummary = $"Using shipyard assets {string.Join(", ", step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}."; - } - - private static void PromoteCombatStepToRunning(FactionPlanStepRuntime step) - { - if (step.Status != FactionPlanStepStatus.Ready) - { - return; - } - - if (step.AssignedAssetIds.Count <= 0) - { - step.Status = FactionPlanStepStatus.Blocked; - step.BlockingReason = "No combat ships were available for the attack step."; - step.StatusReason = step.BlockingReason; - return; - } - - step.Status = FactionPlanStepStatus.Running; - step.StatusReason = "Attack step is bound to assigned combat ships."; - step.ExecutionBindingKind = "combat-assets"; - step.ExecutionBindingTargetId = step.AssignedAssetIds.FirstOrDefault(); - step.ExecutionBindingSummary = $"Using combat ships {string.Join(", ", step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}."; - } - - 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 bool ProjectsSemanticallyMatch( - IndustryExpansionProject expectedProject, - IndustryExpansionProject activeProject) => - string.Equals(expectedProject.CommodityId, activeProject.CommodityId, StringComparison.Ordinal) - && string.Equals(expectedProject.ModuleId, activeProject.ModuleId, StringComparison.Ordinal) - && string.Equals(expectedProject.SystemId, activeProject.SystemId, StringComparison.Ordinal) - && string.Equals(expectedProject.CelestialId, activeProject.CelestialId, StringComparison.Ordinal); - - private static string DescribeProject(IndustryExpansionProject project) => - $"{project.CommodityId}/{project.ModuleId} @ {project.SystemId}:{project.CelestialId}"; - - private static string BuildMissingPlanReason(FactionPlanStepRuntime step) => - step.Kind switch - { - FactionPlanStepKind.EnsureCommodityProduction => $"Unable to derive an expansion project for required commodity {step.CommodityId}.", - FactionPlanStepKind.EnsureWaterSupply => "Unable to derive an expansion project for water supply.", - FactionPlanStepKind.EnsureShipyardSite => "Unable to identify a viable shipyard foundation project.", - _ => "Unable to derive the required execution plan for this step.", - }; - - private static bool TryFindIssuedTaskBinding( - CommanderRuntime commander, - FactionPlanStepRuntime step, - out StepExecutionBinding binding) - { - var task = commander.IssuedTasks.FirstOrDefault(candidate => - string.Equals(candidate.StepId, step.Id, StringComparison.Ordinal) - && candidate.State == FactionIssuedTaskState.Active); - if (task is null) - { - binding = default!; - return false; - } - - var targetId = task.TargetSiteId - ?? task.TargetFactionId - ?? task.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); - binding = new StepExecutionBinding( - "issued-task", - targetId, - $"Reusing active issued task {task.Kind} ({task.Id})."); - return true; - } - - private static string? FindMatchingSiteId( - SimulationWorld world, - string factionId, - IndustryExpansionProject project) => - world.ConstructionSites - .Where(site => - string.Equals(site.FactionId, factionId, StringComparison.Ordinal) - && string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal) - && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed - && string.Equals(site.TargetDefinitionId, project.CommodityId, StringComparison.Ordinal) - && string.Equals(site.BlueprintId, project.ModuleId, StringComparison.Ordinal) - && string.Equals(site.SystemId, project.SystemId, StringComparison.Ordinal) - && string.Equals(site.CelestialId, project.CelestialId, StringComparison.Ordinal)) - .Select(site => site.Id) - .FirstOrDefault(); - - private static string titleCase(string value) => - string.IsNullOrWhiteSpace(value) - ? value - : char.ToUpperInvariant(value[0]) + value[1..]; - - 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 2da5f95..923778d 100644 --- a/apps/backend/Factions/Contracts/Factions.cs +++ b/apps/backend/Factions/Contracts/Factions.cs @@ -1,40 +1,76 @@ namespace SpaceGame.Api.Factions.Contracts; -public sealed record FactionPlanningStateSnapshot( - int MilitaryShipCount, - int MinerShipCount, - int TransportShipCount, - int ConstructorShipCount, - int ControlledSystemCount, - int TargetSystemCount, - bool HasShipFactory, - float OreStockpile, - 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 FactionDoctrineSnapshot( + string StrategicPosture, + string ExpansionPosture, + string MilitaryPosture, + string EconomicPosture, + int DesiredControlledSystems, + int DesiredMilitaryPerFront, + int DesiredMinersPerSystem, + int DesiredTransportsPerSystem, + int DesiredConstructors, + float ReserveCreditsRatio, + float ExpansionBudgetRatio, + float WarBudgetRatio, + float ReserveMilitaryRatio, + float OffensiveReadinessThreshold, + float SupplySecurityBias, + float FailureAversion, + int ReinforcementLeadPerFront); -public sealed record FactionStrategicPrioritySnapshot(string GoalName, float Priority); +public sealed record FactionSystemMemorySnapshot( + string SystemId, + DateTimeOffset LastSeenAtUtc, + int LastEnemyShipCount, + int LastEnemyStationCount, + bool ControlledByFaction, + string? LastRole, + float FrontierPressure, + float RouteRisk, + float HistoricalShortagePressure, + int OffensiveFailures, + int DefensiveFailures, + int OffensiveSuccesses, + int DefensiveSuccesses, + DateTimeOffset? LastContestedAtUtc, + DateTimeOffset? LastShortageAtUtc); + +public sealed record FactionCommodityMemorySnapshot( + string ItemId, + float HistoricalShortageScore, + float HistoricalSurplusScore, + float LastObservedBacklog, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? LastCriticalAtUtc); + +public sealed record FactionOutcomeRecordSnapshot( + string Id, + string Kind, + string Summary, + string? RelatedCampaignId, + string? RelatedObjectiveId, + DateTimeOffset OccurredAtUtc); + +public sealed record FactionMemorySnapshot( + int LastPlanCycle, + DateTimeOffset UpdatedAtUtc, + int LastObservedShipsBuilt, + int LastObservedShipsLost, + float LastObservedCredits, + IReadOnlyList KnownSystemIds, + IReadOnlyList KnownEnemyFactionIds, + IReadOnlyList Systems, + IReadOnlyList Commodities, + IReadOnlyList RecentOutcomes); + +public sealed record FactionBudgetSnapshot( + float ReservedCredits, + float ExpansionCredits, + float WarCredits, + int ReservedMilitaryAssets, + int ReservedLogisticsAssets, + int ReservedConstructionAssets); public sealed record FactionCommoditySignalSnapshot( string ItemId, @@ -51,96 +87,185 @@ public sealed record FactionCommoditySignalSnapshot( float BuyBacklog, float ReservedForConstruction); -public sealed record FactionThreatSignalSnapshot( - string ScopeId, - string ScopeKind, - int EnemyShipCount, - int EnemyStationCount); - -public sealed record FactionBlackboardSnapshot( +public sealed record FactionEconomicAssessmentSnapshot( 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, + int TargetMilitaryShipCount, + int TargetMinerShipCount, + int TargetTransportShipCount, + int TargetConstructorShipCount, + bool HasShipyard, + bool HasWarIndustrySupplyChain, + string? PrimaryExpansionSiteId, + string? PrimaryExpansionSystemId, + float ReplacementPressure, + float SustainmentScore, + float LogisticsSecurityScore, + int CriticalShortageCount, + string? IndustrialBottleneckItemId, + IReadOnlyList CommoditySignals); + +public sealed record FactionThreatSignalSnapshot( + string ScopeId, + string ScopeKind, + int EnemyShipCount, + int EnemyStationCount, + string? EnemyFactionId); + +public sealed record FactionThreatAssessmentSnapshot( + int PlanCycle, + DateTimeOffset UpdatedAtUtc, + int EnemyFactionCount, + int EnemyShipCount, + int EnemyStationCount, + string? PrimaryThreatFactionId, + string? PrimaryThreatSystemId, IReadOnlyList ThreatSignals); +public sealed record FactionTheaterSnapshot( + string Id, + string Kind, + string SystemId, + string Status, + float Priority, + float SupplyRisk, + float FriendlyAssetValue, + string? TargetFactionId, + string? AnchorEntityId, + Vector3Dto? AnchorPosition, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList CampaignIds); + public sealed record FactionPlanStepSnapshot( string Id, string Kind, string Status, - float Priority, - string? CommodityId, - string? ModuleId, - string? TargetFactionId, - string? TargetSiteId, - string? StatusReason, - string? ExecutionBindingKind, - string? ExecutionBindingTargetId, - string? ExecutionBindingSummary, - string? BlockingReason, - string? Notes, - int LastEvaluatedCycle, - IReadOnlyList DependencyStepIds, - IReadOnlyList RequiredFacts, - IReadOnlyList ProducedFacts, - IReadOnlyList AssignedAssets, - IReadOnlyList IssuedTaskIds); + string? Summary, + string? BlockingReason); -public sealed record FactionIssuedTaskSnapshot( +public sealed record FactionCampaignSnapshot( string Id, string Kind, - string State, - string ObjectiveId, - string StepId, + string Status, float Priority, - string? ShipRole, - string? CommodityId, - string? ModuleId, + string? TheaterId, string? TargetFactionId, string? TargetSystemId, - string? TargetSiteId, - int CreatedAtCycle, - int UpdatedAtCycle, - string? BlockingReason, - string? Notes, - IReadOnlyList AssignedAssets); + string? TargetEntityId, + string? CommodityId, + string? SupportStationId, + int CurrentStepIndex, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + string? Summary, + string? PauseReason, + float ContinuationScore, + float SupplyAdequacy, + float ReplacementPressure, + int FailureCount, + int SuccessCount, + string? FleetCommanderId, + bool RequiresReinforcement, + IReadOnlyList Steps, + IReadOnlyList ObjectiveIds); public sealed record FactionObjectiveSnapshot( string Id, + string CampaignId, + string? TheaterId, string Kind, - string State, + string DelegationKind, + string BehaviorKind, + string Status, float Priority, - string? ParentObjectiveId, - string? TargetFactionId, + string? CommanderId, + string? HomeSystemId, + string? HomeStationId, string? TargetSystemId, - string? TargetSiteId, - string? TargetRegionId, + string? TargetEntityId, + Vector3Dto? TargetPosition, + string? ItemId, + string? Notes, + int CurrentStepIndex, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + bool UseOrders, + string? StagingOrderKind, + int ReinforcementLevel, + IReadOnlyList Steps, + IReadOnlyList ReservedAssetIds); + +public sealed record FactionReservationSnapshot( + string Id, + string ObjectiveId, + string? CampaignId, + string AssetKind, + string AssetId, + float Priority, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record FactionProductionProgramSnapshot( + string Id, + string Kind, + string Status, + float Priority, + string? CampaignId, string? CommodityId, string? ModuleId, - int BudgetWeight, - int SlotCost, - int CreatedAtCycle, - int UpdatedAtCycle, - string? InvalidationReason, - string? BlockingReason, - IReadOnlyList PrerequisiteObjectiveIds, - IReadOnlyList AssignedAssets, - IReadOnlyList Steps); + string? ShipKind, + string? TargetSystemId, + int TargetCount, + int CurrentCount, + string? Notes); + +public sealed record FactionDecisionLogEntrySnapshot( + string Id, + string Kind, + string Summary, + string? RelatedEntityId, + int PlanCycle, + DateTimeOffset OccurredAtUtc); + +public sealed record FactionStrategicStateSnapshot( + int PlanCycle, + DateTimeOffset UpdatedAtUtc, + string Status, + FactionBudgetSnapshot Budget, + FactionEconomicAssessmentSnapshot EconomicAssessment, + FactionThreatAssessmentSnapshot ThreatAssessment, + IReadOnlyList Theaters, + IReadOnlyList Campaigns, + IReadOnlyList Objectives, + IReadOnlyList Reservations, + IReadOnlyList ProductionPrograms); + +public sealed record CommanderAssignmentSnapshot( + string CommanderId, + string Kind, + string BehaviorKind, + string Status, + string? ObjectiveId, + string? CampaignId, + string? TheaterId, + string? ParentCommanderId, + string? ControlledEntityId, + float Priority, + string? HomeSystemId, + string? HomeStationId, + string? TargetSystemId, + string? TargetEntityId, + Vector3Dto? TargetPosition, + string? ItemId, + string? Notes, + DateTimeOffset? UpdatedAtUtc, + IReadOnlyList ActiveObjectiveIds, + IReadOnlyList SubordinateCommanderIds); public sealed record FactionSnapshot( string Id, @@ -153,11 +278,11 @@ public sealed record FactionSnapshot( int ShipsBuilt, int ShipsLost, string? DefaultPolicySetId, - FactionPlanningStateSnapshot? StrategicAssessment, - IReadOnlyList? StrategicPriorities, - FactionBlackboardSnapshot? Blackboard, - IReadOnlyList? Objectives, - IReadOnlyList? IssuedTasks); + FactionDoctrineSnapshot Doctrine, + FactionMemorySnapshot Memory, + FactionStrategicStateSnapshot StrategicState, + IReadOnlyList DecisionLog, + IReadOnlyList Commanders); public sealed record FactionDelta( string Id, @@ -170,8 +295,8 @@ public sealed record FactionDelta( int ShipsBuilt, int ShipsLost, string? DefaultPolicySetId, - FactionPlanningStateSnapshot? StrategicAssessment, - IReadOnlyList? StrategicPriorities, - FactionBlackboardSnapshot? Blackboard, - IReadOnlyList? Objectives, - IReadOnlyList? IssuedTasks); + FactionDoctrineSnapshot Doctrine, + FactionMemorySnapshot Memory, + FactionStrategicStateSnapshot StrategicState, + IReadOnlyList DecisionLog, + IReadOnlyList Commanders); diff --git a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs index a0f9f22..fd6a1c6 100644 --- a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs +++ b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs @@ -1,4 +1,3 @@ - namespace SpaceGame.Api.Factions.Runtime; public sealed class FactionRuntime @@ -14,6 +13,10 @@ public sealed class FactionRuntime public int ShipsLost { get; set; } public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); public string? DefaultPolicySetId { get; set; } + public FactionDoctrineRuntime Doctrine { get; set; } = new(); + public FactionMemoryRuntime Memory { get; set; } = new(); + public FactionStrategicStateRuntime StrategicState { get; set; } = new(); + public List DecisionLog { get; } = []; public string LastDeltaSignature { get; set; } = string.Empty; } @@ -26,183 +29,296 @@ public sealed class CommanderRuntime public string? ControlledEntityId { get; set; } public string? PolicySetId { get; set; } public string? Doctrine { get; set; } - public List Goals { get; } = []; - public string? ActiveGoalName { get; set; } - public string? ActiveActionName { get; set; } public float ReplanTimer { get; set; } public bool NeedsReplan { get; set; } = true; - public CommanderBehaviorRuntime? ActiveBehavior { get; set; } - public CommanderOrderRuntime? ActiveOrder { get; set; } - public CommanderTaskRuntime? ActiveTask { get; set; } + public CommanderAssignmentRuntime? Assignment { get; set; } + public CommanderSkillProfileRuntime Skills { get; set; } = new(); public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); + public HashSet ActiveObjectiveIds { get; } = new(StringComparer.Ordinal); public bool IsAlive { get; set; } = true; - 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 string LastDeltaSignature { get; set; } = string.Empty; } -public enum FactionObjectiveKind +public sealed class CommanderAssignmentRuntime { - 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 required string ObjectiveId { get; set; } + public string? CampaignId { get; set; } + public string? TheaterId { get; set; } + public required string Kind { get; set; } + public required string BehaviorKind { get; set; } + public string Status { get; set; } = "active"; public float Priority { get; set; } - public string? ParentObjectiveId { get; set; } - public string? TargetFactionId { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { 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 string? TargetEntityId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? ItemId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; } -public sealed class FactionPlanStepRuntime +public sealed class CommanderSkillProfileRuntime +{ + public int Leadership { get; set; } = 3; + public int Coordination { get; set; } = 3; + public int Strategy { get; set; } = 3; +} + +public sealed class FactionDoctrineRuntime +{ + public string StrategicPosture { get; set; } = "balanced"; + public string ExpansionPosture { get; set; } = "measured"; + public string MilitaryPosture { get; set; } = "defensive"; + public string EconomicPosture { get; set; } = "self-sufficient"; + public int DesiredControlledSystems { get; set; } = 3; + public int DesiredMilitaryPerFront { get; set; } = 2; + public int DesiredMinersPerSystem { get; set; } = 1; + public int DesiredTransportsPerSystem { get; set; } = 1; + public int DesiredConstructors { get; set; } = 1; + public float ReserveCreditsRatio { get; set; } = 0.2f; + public float ExpansionBudgetRatio { get; set; } = 0.25f; + public float WarBudgetRatio { get; set; } = 0.35f; + public float ReserveMilitaryRatio { get; set; } = 0.2f; + public float OffensiveReadinessThreshold { get; set; } = 0.62f; + public float SupplySecurityBias { get; set; } = 0.55f; + public float FailureAversion { get; set; } = 0.45f; + public int ReinforcementLeadPerFront { get; set; } = 1; +} + +public sealed class FactionMemoryRuntime +{ + public int LastPlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int LastObservedShipsBuilt { get; set; } + public int LastObservedShipsLost { get; set; } + public float LastObservedCredits { get; set; } + public HashSet KnownSystemIds { get; } = new(StringComparer.Ordinal); + public HashSet KnownEnemyFactionIds { get; } = new(StringComparer.Ordinal); + public List SystemMemories { get; } = []; + public List CommodityMemories { get; } = []; + public List RecentOutcomes { get; } = []; +} + +public sealed class FactionSystemMemoryRuntime +{ + public required string SystemId { get; init; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public int LastEnemyShipCount { get; set; } + public int LastEnemyStationCount { get; set; } + public bool ControlledByFaction { get; set; } + public string? LastRole { get; set; } + public float FrontierPressure { get; set; } + public float RouteRisk { get; set; } + public float HistoricalShortagePressure { get; set; } + public int OffensiveFailures { get; set; } + public int DefensiveFailures { get; set; } + public int OffensiveSuccesses { get; set; } + public int DefensiveSuccesses { get; set; } + public DateTimeOffset? LastContestedAtUtc { get; set; } + public DateTimeOffset? LastShortageAtUtc { get; set; } +} + +public sealed class FactionCommodityMemoryRuntime +{ + public required string ItemId { get; init; } + public float HistoricalShortageScore { get; set; } + public float HistoricalSurplusScore { get; set; } + public float LastObservedBacklog { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public DateTimeOffset? LastCriticalAtUtc { get; set; } +} + +public sealed class FactionOutcomeRecordRuntime { 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? StatusReason { get; set; } - public string? ExecutionBindingKind { get; set; } - public string? ExecutionBindingTargetId { get; set; } - public string? ExecutionBindingSummary { 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 required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedCampaignId { get; set; } + public string? RelatedObjectiveId { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; } -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 sealed class FactionStrategicStateRuntime +{ + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public string Status { get; set; } = "stable"; + public FactionBudgetRuntime Budget { get; set; } = new(); + public FactionEconomicAssessmentRuntime EconomicAssessment { get; set; } = new(); + public FactionThreatAssessmentRuntime ThreatAssessment { get; set; } = new(); + public List Theaters { get; } = []; + public List Campaigns { get; } = []; + public List Objectives { get; } = []; + public List Reservations { get; } = []; + public List ProductionPrograms { get; } = []; +} + +public sealed class FactionBudgetRuntime +{ + public float ReservedCredits { get; set; } + public float ExpansionCredits { get; set; } + public float WarCredits { get; set; } + public int ReservedMilitaryAssets { get; set; } + public int ReservedLogisticsAssets { get; set; } + public int ReservedConstructionAssets { get; set; } +} + +public sealed class FactionEconomicAssessmentRuntime { 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 int TargetMilitaryShipCount { get; set; } + public int TargetMinerShipCount { get; set; } + public int TargetTransportShipCount { get; set; } + public int TargetConstructorShipCount { get; set; } + public bool HasShipyard { get; set; } + public bool HasWarIndustrySupplyChain { get; set; } + public string? PrimaryExpansionSiteId { get; set; } + public string? PrimaryExpansionSystemId { get; set; } + public float ReplacementPressure { get; set; } + public float SustainmentScore { get; set; } + public float LogisticsSecurityScore { get; set; } + public int CriticalShortageCount { get; set; } + public string? IndustrialBottleneckItemId { get; set; } public List CommoditySignals { get; } = []; +} + +public sealed class FactionThreatAssessmentRuntime +{ + public int PlanCycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } + public int EnemyFactionCount { get; set; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } + public string? PrimaryThreatFactionId { get; set; } + public string? PrimaryThreatSystemId { get; set; } public List ThreatSignals { get; } = []; - public HashSet AvailableShipIds { get; } = new(StringComparer.Ordinal); +} + +public sealed class FactionTheaterRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public required string SystemId { get; set; } + public string Status { get; set; } = "active"; + public float Priority { get; set; } + public float SupplyRisk { get; set; } + public float FriendlyAssetValue { get; set; } + public string? TargetFactionId { get; set; } + public string? AnchorEntityId { get; set; } + public Vector3? AnchorPosition { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List CampaignIds { get; } = []; +} + +public sealed class FactionCampaignRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? TheaterId { get; set; } + public string? TargetFactionId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetEntityId { get; set; } + public string? CommodityId { get; set; } + public string? SupportStationId { get; set; } + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public string? Summary { get; set; } + public string? PauseReason { get; set; } + public float ContinuationScore { get; set; } + public float SupplyAdequacy { get; set; } + public float ReplacementPressure { get; set; } + public int FailureCount { get; set; } + public int SuccessCount { get; set; } + public string? FleetCommanderId { get; set; } + public bool RequiresReinforcement { get; set; } + public List Steps { get; } = []; + public List ObjectiveIds { get; } = []; +} + +public sealed class FactionOperationalObjectiveRuntime +{ + public required string Id { get; init; } + public required string CampaignId { get; set; } + public string? TheaterId { get; set; } + public required string Kind { get; set; } + public required string DelegationKind { get; set; } + public required string BehaviorKind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? CommanderId { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? TargetSystemId { get; set; } + public string? TargetEntityId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? ItemId { get; set; } + public string? Notes { get; set; } + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public int ReinforcementLevel { get; set; } + public List Steps { get; } = []; + public List ReservedAssetIds { get; } = []; +} + +public sealed class FactionPlanStepRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public string? Summary { get; set; } + public string? BlockingReason { get; set; } +} + +public sealed class FactionAssetReservationRuntime +{ + public required string Id { get; init; } + public required string ObjectiveId { get; set; } + public string? CampaignId { get; set; } + public required string AssetKind { get; set; } + public required string AssetId { get; set; } + public float Priority { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class FactionProductionProgramRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "planned"; + public float Priority { get; set; } + public string? CampaignId { get; set; } + public string? CommodityId { get; set; } + public string? ModuleId { get; set; } + public string? ShipKind { get; set; } + public string? TargetSystemId { get; set; } + public int TargetCount { get; set; } + public int CurrentCount { get; set; } + public string? Notes { get; set; } +} + +public sealed class FactionDecisionLogEntryRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedEntityId { get; set; } + public int PlanCycle { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; } public sealed class FactionCommoditySignalRuntime @@ -228,38 +344,5 @@ public sealed class FactionThreatSignalRuntime public required string ScopeKind { get; init; } public int EnemyShipCount { get; set; } public int EnemyStationCount { get; set; } -} - -public sealed class CommanderBehaviorRuntime -{ - public required string Kind { get; set; } - public string? Phase { get; set; } - public string? TargetEntityId { get; set; } - public string? ItemId { get; set; } - public string? NodeId { get; set; } - public string? StationId { get; set; } - public string? ModuleId { get; set; } - public string? AreaSystemId { get; set; } - public int PatrolIndex { get; set; } -} - -public sealed class CommanderOrderRuntime -{ - public required string Kind { get; init; } - public OrderStatus Status { get; set; } = OrderStatus.Accepted; - public string? TargetEntityId { get; set; } - public string? DestinationNodeId { get; set; } - public required string DestinationSystemId { get; init; } - public required Vector3 DestinationPosition { get; init; } -} - -public sealed class CommanderTaskRuntime -{ - public required string Kind { get; set; } - public WorkStatus Status { get; set; } = WorkStatus.Pending; - public string? TargetEntityId { get; set; } - public string? TargetSystemId { get; set; } - public string? TargetNodeId { get; set; } - public Vector3? TargetPosition { get; set; } - public float Threshold { get; set; } + public string? EnemyFactionId { get; set; } } diff --git a/apps/backend/Geopolitics/Contracts/Geopolitics.cs b/apps/backend/Geopolitics/Contracts/Geopolitics.cs new file mode 100644 index 0000000..1bf4f73 --- /dev/null +++ b/apps/backend/Geopolitics/Contracts/Geopolitics.cs @@ -0,0 +1,283 @@ +namespace SpaceGame.Api.Geopolitics.Contracts; + +public sealed record SystemRouteLinkSnapshot( + string Id, + string SourceSystemId, + string DestinationSystemId, + float Distance, + bool IsPrimaryLane); + +public sealed record DiplomaticRelationSnapshot( + string Id, + string FactionAId, + string FactionBId, + string Status, + string Posture, + float TrustScore, + float TensionScore, + float GrievanceScore, + string TradeAccessPolicy, + string MilitaryAccessPolicy, + string? WarStateId, + DateTimeOffset? CeasefireUntilUtc, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList ActiveTreatyIds, + IReadOnlyList ActiveIncidentIds); + +public sealed record TreatySnapshot( + string Id, + string Kind, + string Status, + string TradeAccessPolicy, + string MilitaryAccessPolicy, + string? Summary, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList FactionIds); + +public sealed record DiplomaticIncidentSnapshot( + string Id, + string Kind, + string Status, + string SourceFactionId, + string TargetFactionId, + string? SystemId, + string? BorderEdgeId, + string Summary, + float Severity, + float EscalationScore, + DateTimeOffset CreatedAtUtc, + DateTimeOffset LastObservedAtUtc); + +public sealed record BorderTensionSnapshot( + string Id, + string RelationId, + string BorderEdgeId, + string FactionAId, + string FactionBId, + string Status, + float TensionScore, + float IncidentScore, + float MilitaryPressure, + float AccessFriction, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList SystemIds); + +public sealed record WarStateSnapshot( + string Id, + string RelationId, + string FactionAId, + string FactionBId, + string Status, + string WarGoal, + float EscalationScore, + DateTimeOffset StartedAtUtc, + DateTimeOffset? CeasefireUntilUtc, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList ActiveFrontLineIds); + +public sealed record DiplomaticStateSnapshot( + IReadOnlyList Relations, + IReadOnlyList Treaties, + IReadOnlyList Incidents, + IReadOnlyList BorderTensions, + IReadOnlyList Wars); + +public sealed record TerritoryClaimSnapshot( + string Id, + string? SourceClaimId, + string FactionId, + string SystemId, + string CelestialId, + string Status, + string ClaimKind, + float ClaimStrength, + DateTimeOffset UpdatedAtUtc); + +public sealed record TerritoryInfluenceSnapshot( + string Id, + string SystemId, + string FactionId, + float ClaimStrength, + float AssetStrength, + float LogisticsStrength, + float TotalInfluence, + bool IsContesting, + DateTimeOffset UpdatedAtUtc); + +public sealed record TerritoryControlStateSnapshot( + string SystemId, + string? ControllerFactionId, + string? PrimaryClaimantFactionId, + string ControlKind, + bool IsContested, + float ControlScore, + float StrategicValue, + IReadOnlyList ClaimantFactionIds, + IReadOnlyList InfluencingFactionIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record SectorStrategicProfileSnapshot( + string SystemId, + string? ControllerFactionId, + string ZoneKind, + bool IsContested, + float StrategicValue, + float SecurityRating, + float TerritorialPressure, + float LogisticsValue, + string? EconomicRegionId, + string? FrontLineId, + DateTimeOffset UpdatedAtUtc); + +public sealed record BorderEdgeSnapshot( + string Id, + string SourceSystemId, + string DestinationSystemId, + string? SourceFactionId, + string? DestinationFactionId, + bool IsContested, + string? RelationId, + float TensionScore, + float CorridorImportance, + DateTimeOffset UpdatedAtUtc); + +public sealed record FrontLineSnapshot( + string Id, + string Kind, + string Status, + string? AnchorSystemId, + float PressureScore, + float SupplyRisk, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList FactionIds, + IReadOnlyList SystemIds, + IReadOnlyList BorderEdgeIds); + +public sealed record TerritoryZoneSnapshot( + string Id, + string SystemId, + string? FactionId, + string Kind, + string Status, + string? Reason, + DateTimeOffset UpdatedAtUtc); + +public sealed record TerritoryPressureSnapshot( + string Id, + string SystemId, + string? FactionId, + string Kind, + float PressureScore, + float SecurityScore, + float HostileInfluence, + float CorridorRisk, + DateTimeOffset UpdatedAtUtc); + +public sealed record TerritoryStateSnapshot( + IReadOnlyList Claims, + IReadOnlyList Influences, + IReadOnlyList ControlStates, + IReadOnlyList StrategicProfiles, + IReadOnlyList BorderEdges, + IReadOnlyList FrontLines, + IReadOnlyList Zones, + IReadOnlyList Pressures); + +public sealed record EconomicRegionSnapshot( + string Id, + string? FactionId, + string Label, + string Kind, + string Status, + string CoreSystemId, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList SystemIds, + IReadOnlyList StationIds, + IReadOnlyList FrontLineIds, + IReadOnlyList CorridorIds); + +public sealed record SupplyNetworkSnapshot( + string Id, + string RegionId, + float ThroughputScore, + float RiskScore, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList StationIds, + IReadOnlyList ProducerItemIds, + IReadOnlyList ConsumerItemIds, + IReadOnlyList ConstructionItemIds); + +public sealed record LogisticsCorridorSnapshot( + string Id, + string? FactionId, + string Kind, + string Status, + float RiskScore, + float ThroughputScore, + string AccessState, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList SystemPathIds, + IReadOnlyList RegionIds, + IReadOnlyList BorderEdgeIds); + +public sealed record RegionalProductionProfileSnapshot( + string RegionId, + string PrimaryIndustry, + int ShipyardCount, + int StationCount, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList ProducedItemIds, + IReadOnlyList ScarceItemIds); + +public sealed record RegionalTradeBalanceSnapshot( + string RegionId, + int ImportsRequiredCount, + int ExportsSurplusCount, + int CriticalShortageCount, + float NetTradeScore, + DateTimeOffset UpdatedAtUtc); + +public sealed record RegionalBottleneckSnapshot( + string Id, + string RegionId, + string ItemId, + string Cause, + string Status, + float Severity, + DateTimeOffset UpdatedAtUtc); + +public sealed record RegionalSecurityAssessmentSnapshot( + string RegionId, + float SupplyRisk, + float BorderPressure, + int ActiveWarCount, + int HostileRelationCount, + float AccessFriction, + DateTimeOffset UpdatedAtUtc); + +public sealed record RegionalEconomicAssessmentSnapshot( + string RegionId, + float SustainmentScore, + float ProductionDepth, + float ConstructionPressure, + float CorridorDependency, + DateTimeOffset UpdatedAtUtc); + +public sealed record EconomyRegionStateSnapshot( + IReadOnlyList Regions, + IReadOnlyList SupplyNetworks, + IReadOnlyList Corridors, + IReadOnlyList ProductionProfiles, + IReadOnlyList TradeBalances, + IReadOnlyList Bottlenecks, + IReadOnlyList SecurityAssessments, + IReadOnlyList EconomicAssessments); + +public sealed record GeopoliticalStateSnapshot( + int Cycle, + DateTimeOffset UpdatedAtUtc, + IReadOnlyList Routes, + DiplomaticStateSnapshot Diplomacy, + TerritoryStateSnapshot Territory, + EconomyRegionStateSnapshot EconomyRegions); diff --git a/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs new file mode 100644 index 0000000..3e3dba2 --- /dev/null +++ b/apps/backend/Geopolitics/Runtime/GeopoliticalRuntimeModels.cs @@ -0,0 +1,336 @@ +namespace SpaceGame.Api.Geopolitics.Runtime; + +public sealed class GeopoliticalStateRuntime +{ + public int Cycle { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List Routes { get; } = []; + public DiplomaticStateRuntime Diplomacy { get; set; } = new(); + public TerritoryStateRuntime Territory { get; set; } = new(); + public EconomyRegionStateRuntime EconomyRegions { get; set; } = new(); + public string LastDeltaSignature { get; set; } = string.Empty; +} + +public sealed class SystemRouteLinkRuntime +{ + public required string Id { get; init; } + public required string SourceSystemId { get; set; } + public required string DestinationSystemId { get; set; } + public float Distance { get; set; } + public bool IsPrimaryLane { get; set; } = true; +} + +public sealed class DiplomaticStateRuntime +{ + public List Relations { get; } = []; + public List Treaties { get; } = []; + public List Incidents { get; } = []; + public List BorderTensions { get; } = []; + public List Wars { get; } = []; +} + +public sealed class DiplomaticRelationRuntime +{ + public required string Id { get; init; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public string Posture { get; set; } = "neutral"; + public float TrustScore { get; set; } + public float TensionScore { get; set; } + public float GrievanceScore { get; set; } + public string TradeAccessPolicy { get; set; } = "restricted"; + public string MilitaryAccessPolicy { get; set; } = "restricted"; + public string? WarStateId { get; set; } + public DateTimeOffset? CeasefireUntilUtc { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ActiveTreatyIds { get; } = []; + public List ActiveIncidentIds { get; } = []; +} + +public sealed class TreatyRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "active"; + public string TradeAccessPolicy { get; set; } = "restricted"; + public string MilitaryAccessPolicy { get; set; } = "restricted"; + public string? Summary { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List FactionIds { get; } = []; +} + +public sealed class DiplomaticIncidentRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public string Status { get; set; } = "active"; + public required string SourceFactionId { get; set; } + public required string TargetFactionId { get; set; } + public string? SystemId { get; set; } + public string? BorderEdgeId { get; set; } + public required string Summary { get; set; } + public float Severity { get; set; } + public float EscalationScore { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastObservedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class BorderTensionRuntime +{ + public required string Id { get; init; } + public required string RelationId { get; set; } + public required string BorderEdgeId { get; set; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public float TensionScore { get; set; } + public float IncidentScore { get; set; } + public float MilitaryPressure { get; set; } + public float AccessFriction { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemIds { get; } = []; +} + +public sealed class WarStateRuntime +{ + public required string Id { get; init; } + public required string RelationId { get; set; } + public required string FactionAId { get; set; } + public required string FactionBId { get; set; } + public string Status { get; set; } = "active"; + public string WarGoal { get; set; } = "territorial-pressure"; + public float EscalationScore { get; set; } + public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? CeasefireUntilUtc { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ActiveFrontLineIds { get; } = []; +} + +public sealed class TerritoryStateRuntime +{ + public List Claims { get; } = []; + public List Influences { get; } = []; + public List ControlStates { get; } = []; + public List StrategicProfiles { get; } = []; + public List BorderEdges { get; } = []; + public List FrontLines { get; } = []; + public List Zones { get; } = []; + public List Pressures { get; } = []; +} + +public sealed class TerritoryClaimRuntime +{ + public required string Id { get; init; } + public string? SourceClaimId { get; set; } + public required string FactionId { get; set; } + public required string SystemId { get; set; } + public required string CelestialId { get; set; } + public string Status { get; set; } = "active"; + public string ClaimKind { get; set; } = "infrastructure"; + public float ClaimStrength { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class TerritoryInfluenceRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; set; } + public required string FactionId { get; set; } + public float ClaimStrength { get; set; } + public float AssetStrength { get; set; } + public float LogisticsStrength { get; set; } + public float TotalInfluence { get; set; } + public bool IsContesting { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class TerritoryControlStateRuntime +{ + public required string SystemId { get; init; } + public string? ControllerFactionId { get; set; } + public string? PrimaryClaimantFactionId { get; set; } + public string ControlKind { get; set; } = "unclaimed"; + public bool IsContested { get; set; } + public float ControlScore { get; set; } + public float StrategicValue { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ClaimantFactionIds { get; } = []; + public List InfluencingFactionIds { get; } = []; +} + +public sealed class SectorStrategicProfileRuntime +{ + public required string SystemId { get; init; } + public string? ControllerFactionId { get; set; } + public string ZoneKind { get; set; } = "unclaimed"; + public bool IsContested { get; set; } + public float StrategicValue { get; set; } + public float SecurityRating { get; set; } + public float TerritorialPressure { get; set; } + public float LogisticsValue { get; set; } + public string? EconomicRegionId { get; set; } + public string? FrontLineId { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class BorderEdgeRuntime +{ + public required string Id { get; init; } + public required string SourceSystemId { get; set; } + public required string DestinationSystemId { get; set; } + public string? SourceFactionId { get; set; } + public string? DestinationFactionId { get; set; } + public bool IsContested { get; set; } + public string? RelationId { get; set; } + public float TensionScore { get; set; } + public float CorridorImportance { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class FrontLineRuntime +{ + public required string Id { get; init; } + public string Kind { get; set; } = "border-front"; + public string Status { get; set; } = "active"; + public string? AnchorSystemId { get; set; } + public float PressureScore { get; set; } + public float SupplyRisk { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List FactionIds { get; } = []; + public List SystemIds { get; } = []; + public List BorderEdgeIds { get; } = []; +} + +public sealed class TerritoryZoneRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; set; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "unclaimed"; + public string Status { get; set; } = "active"; + public string? Reason { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class TerritoryPressureRuntime +{ + public required string Id { get; init; } + public required string SystemId { get; set; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "border-pressure"; + public float PressureScore { get; set; } + public float SecurityScore { get; set; } + public float HostileInfluence { get; set; } + public float CorridorRisk { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class EconomyRegionStateRuntime +{ + public List Regions { get; } = []; + public List SupplyNetworks { get; } = []; + public List Corridors { get; } = []; + public List ProductionProfiles { get; } = []; + public List TradeBalances { get; } = []; + public List Bottlenecks { get; } = []; + public List SecurityAssessments { get; } = []; + public List EconomicAssessments { get; } = []; +} + +public sealed class EconomicRegionRuntime +{ + public required string Id { get; init; } + public string? FactionId { get; set; } + public required string Label { get; set; } + public string Kind { get; set; } = "balanced-region"; + public string Status { get; set; } = "active"; + public required string CoreSystemId { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemIds { get; } = []; + public List StationIds { get; } = []; + public List FrontLineIds { get; } = []; + public List CorridorIds { get; } = []; +} + +public sealed class SupplyNetworkRuntime +{ + public required string Id { get; init; } + public required string RegionId { get; set; } + public float ThroughputScore { get; set; } + public float RiskScore { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List StationIds { get; } = []; + public List ProducerItemIds { get; } = []; + public List ConsumerItemIds { get; } = []; + public List ConstructionItemIds { get; } = []; +} + +public sealed class LogisticsCorridorRuntime +{ + public required string Id { get; init; } + public string? FactionId { get; set; } + public string Kind { get; set; } = "supply-corridor"; + public string Status { get; set; } = "active"; + public float RiskScore { get; set; } + public float ThroughputScore { get; set; } + public string AccessState { get; set; } = "restricted"; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List SystemPathIds { get; } = []; + public List RegionIds { get; } = []; + public List BorderEdgeIds { get; } = []; +} + +public sealed class RegionalProductionProfileRuntime +{ + public required string RegionId { get; set; } + public string PrimaryIndustry { get; set; } = "mixed"; + public int ShipyardCount { get; set; } + public int StationCount { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List ProducedItemIds { get; } = []; + public List ScarceItemIds { get; } = []; +} + +public sealed class RegionalTradeBalanceRuntime +{ + public required string RegionId { get; set; } + public int ImportsRequiredCount { get; set; } + public int ExportsSurplusCount { get; set; } + public int CriticalShortageCount { get; set; } + public float NetTradeScore { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class RegionalBottleneckRuntime +{ + public required string Id { get; init; } + public required string RegionId { get; set; } + public required string ItemId { get; set; } + public string Cause { get; set; } = "regional-shortage"; + public string Status { get; set; } = "active"; + public float Severity { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class RegionalSecurityAssessmentRuntime +{ + public required string RegionId { get; set; } + public float SupplyRisk { get; set; } + public float BorderPressure { get; set; } + public int ActiveWarCount { get; set; } + public int HostileRelationCount { get; set; } + public float AccessFriction { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class RegionalEconomicAssessmentRuntime +{ + public required string RegionId { get; set; } + public float SustainmentScore { get; set; } + public float ProductionDepth { get; set; } + public float ConstructionPressure { get; set; } + public float CorridorDependency { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs new file mode 100644 index 0000000..af650f2 --- /dev/null +++ b/apps/backend/Geopolitics/Simulation/GeopoliticalSimulationService.cs @@ -0,0 +1,923 @@ +using System.Globalization; + +namespace SpaceGame.Api.Geopolitics.Simulation; + +internal sealed class GeopoliticalSimulationService +{ + internal void Update(SimulationWorld world, float deltaSeconds, ICollection events) + { + var state = EnsureState(world); + state.Cycle += 1; + state.UpdatedAtUtc = world.GeneratedAtUtc; + + RebuildRoutes(world, state); + RebuildTerritory(world, state); + RebuildDiplomacy(world, state, events); + RebuildEconomyRegions(world, state); + } + + internal static GeopoliticalStateRuntime EnsureState(SimulationWorld world) + { + world.Geopolitics ??= new GeopoliticalStateRuntime(); + return world.Geopolitics; + } + + internal static DiplomaticRelationRuntime? FindRelation(SimulationWorld world, string factionAId, string factionBId) + { + var state = EnsureState(world); + return state.Diplomacy.Relations.FirstOrDefault(relation => string.Equals(relation.Id, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal)); + } + + internal static WarStateRuntime? FindWarState(SimulationWorld world, string factionAId, string factionBId) => + EnsureState(world).Diplomacy.Wars.FirstOrDefault(war => string.Equals(war.RelationId, BuildRelationId(factionAId, factionBId), StringComparison.Ordinal) && war.Status == "active"); + + internal static TerritoryControlStateRuntime? GetSystemControlState(SimulationWorld world, string systemId) => + EnsureState(world).Territory.ControlStates.FirstOrDefault(state => string.Equals(state.SystemId, systemId, StringComparison.Ordinal)); + + internal static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) => + string.Equals(GetSystemControlState(world, systemId)?.ControllerFactionId, factionId, StringComparison.Ordinal); + + internal static IReadOnlyList GetControlledSystems(SimulationWorld world, string factionId) => + EnsureState(world).Territory.ControlStates + .Where(state => string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal)) + .OrderBy(state => state.SystemId, StringComparer.Ordinal) + .Select(state => state.SystemId) + .ToList(); + + internal static float GetSystemRouteRisk(SimulationWorld world, string systemId, string? factionId = null) + { + var pressure = EnsureState(world).Territory.Pressures + .Where(entry => string.Equals(entry.SystemId, systemId, StringComparison.Ordinal) + && (factionId is null || string.Equals(entry.FactionId, factionId, StringComparison.Ordinal))) + .OrderByDescending(entry => entry.CorridorRisk) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return pressure?.CorridorRisk + ?? EnsureState(world).Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == systemId)?.TerritorialPressure + ?? 0f; + } + + internal static bool HasHostileRelation(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return false; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation is not null && relation.Posture is "hostile" or "war"; + } + + internal static bool HasTradeAccess(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return true; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation?.TradeAccessPolicy is "open" or "allied"; + } + + internal static bool HasMilitaryAccess(SimulationWorld world, string factionAId, string factionBId) + { + if (string.Equals(factionAId, factionBId, StringComparison.Ordinal)) + { + return true; + } + + var relation = FindRelation(world, factionAId, factionBId); + return relation?.MilitaryAccessPolicy is "open" or "allied"; + } + + internal static EconomicRegionRuntime? GetPrimaryEconomicRegion(SimulationWorld world, string factionId, string systemId) => + EnsureState(world).EconomyRegions.Regions.FirstOrDefault(region => + string.Equals(region.FactionId, factionId, StringComparison.Ordinal) + && region.SystemIds.Contains(systemId, StringComparer.Ordinal)); + + private static void RebuildRoutes(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.Routes.Clear(); + if (world.Systems.Count <= 1) + { + return; + } + + var systems = world.Systems + .OrderBy(system => system.Definition.Id, StringComparer.Ordinal) + .ToList(); + var routeIds = new HashSet(StringComparer.Ordinal); + + foreach (var system in systems) + { + foreach (var neighbor in systems + .Where(candidate => candidate.Definition.Id != system.Definition.Id) + .Select(candidate => new + { + candidate.Definition.Id, + Distance = system.Position.DistanceTo(candidate.Position), + }) + .OrderBy(candidate => candidate.Distance) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .Take(Math.Min(3, systems.Count - 1))) + { + var routeId = BuildPairId("route", system.Definition.Id, neighbor.Id); + if (!routeIds.Add(routeId)) + { + continue; + } + + state.Routes.Add(new SystemRouteLinkRuntime + { + Id = routeId, + SourceSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? system.Definition.Id : neighbor.Id, + DestinationSystemId = string.Compare(system.Definition.Id, neighbor.Id, StringComparison.Ordinal) <= 0 ? neighbor.Id : system.Definition.Id, + Distance = neighbor.Distance, + IsPrimaryLane = true, + }); + } + } + } + + private static void RebuildTerritory(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.Territory.Claims.Clear(); + state.Territory.Influences.Clear(); + state.Territory.ControlStates.Clear(); + state.Territory.StrategicProfiles.Clear(); + state.Territory.BorderEdges.Clear(); + state.Territory.FrontLines.Clear(); + state.Territory.Zones.Clear(); + state.Territory.Pressures.Clear(); + + var nowUtc = world.GeneratedAtUtc; + foreach (var claim in world.Claims.Where(claim => claim.State != ClaimStateKinds.Destroyed)) + { + state.Territory.Claims.Add(new TerritoryClaimRuntime + { + Id = $"territory-{claim.Id}", + SourceClaimId = claim.Id, + FactionId = claim.FactionId, + SystemId = claim.SystemId, + CelestialId = claim.CelestialId, + Status = claim.State, + ClaimKind = "infrastructure", + ClaimStrength = claim.State == ClaimStateKinds.Active ? 1f : 0.65f, + UpdatedAtUtc = nowUtc, + }); + } + + var influencesBySystem = new Dictionary>(StringComparer.Ordinal); + foreach (var system in world.Systems) + { + var claimsByFaction = state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .GroupBy(claim => claim.FactionId, StringComparer.Ordinal); + var stationsByFaction = world.Stations + .Where(station => station.SystemId == system.Definition.Id) + .GroupBy(station => station.FactionId, StringComparer.Ordinal); + var shipsByFaction = world.Ships + .Where(ship => ship.SystemId == system.Definition.Id && ship.Health > 0f) + .GroupBy(ship => ship.FactionId, StringComparer.Ordinal); + var sitesByFaction = world.ConstructionSites + .Where(site => site.SystemId == system.Definition.Id && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) + .GroupBy(site => site.FactionId, StringComparer.Ordinal); + + var factionIds = claimsByFaction.Select(group => group.Key) + .Concat(stationsByFaction.Select(group => group.Key)) + .Concat(shipsByFaction.Select(group => group.Key)) + .Concat(sitesByFaction.Select(group => group.Key)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + var influences = new List(); + foreach (var factionId in factionIds) + { + var claimStrength = claimsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(claim => claim.ClaimStrength * 40f) ?? 0f; + var stationStrength = (stationsByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 50f; + var siteStrength = (sitesByFaction.FirstOrDefault(group => group.Key == factionId)?.Count() ?? 0) * 18f; + var shipStrength = shipsByFaction.FirstOrDefault(group => group.Key == factionId)?.Sum(ship => + ship.Definition.Kind switch + { + "military" => 9f, + "construction" => 4f, + "transport" => 3f, + _ when ship.Definition.Kind == "mining" || ship.Definition.Kind == "miner" => 3f, + _ => 2f, + }) ?? 0f; + var logisticsStrength = MathF.Min(30f, stationStrength * 0.18f) + siteStrength; + influences.Add(new TerritoryInfluenceRuntime + { + Id = $"influence-{system.Definition.Id}-{factionId}", + SystemId = system.Definition.Id, + FactionId = factionId, + ClaimStrength = claimStrength, + AssetStrength = stationStrength + shipStrength, + LogisticsStrength = logisticsStrength, + TotalInfluence = claimStrength + stationStrength + shipStrength + logisticsStrength, + UpdatedAtUtc = nowUtc, + }); + } + + influences.Sort((left, right) => + { + var total = right.TotalInfluence.CompareTo(left.TotalInfluence); + return total != 0 ? total : string.Compare(left.FactionId, right.FactionId, StringComparison.Ordinal); + }); + if (influences.Count > 1) + { + var lead = influences[0].TotalInfluence; + foreach (var influence in influences.Skip(1)) + { + influence.IsContesting = influence.TotalInfluence >= (lead * 0.7f); + } + + influences[0].IsContesting = influences[1].TotalInfluence >= (lead * 0.7f); + } + + influencesBySystem[system.Definition.Id] = influences; + state.Territory.Influences.AddRange(influences); + + var top = influences.FirstOrDefault(); + var second = influences.Skip(1).FirstOrDefault(); + var contested = top is not null && second is not null && second.TotalInfluence >= (top.TotalInfluence * 0.7f); + var controllerFactionId = top is not null && (!contested || top.TotalInfluence >= second!.TotalInfluence + 20f) + ? top.FactionId + : null; + var primaryClaimantFactionId = state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .GroupBy(claim => claim.FactionId, StringComparer.Ordinal) + .OrderByDescending(group => group.Sum(claim => claim.ClaimStrength)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.Key) + .FirstOrDefault(); + + var strategicValue = EstimateSystemStrategicValue(world, system.Definition.Id); + var controlState = new TerritoryControlStateRuntime + { + SystemId = system.Definition.Id, + ControllerFactionId = controllerFactionId, + PrimaryClaimantFactionId = primaryClaimantFactionId, + ControlKind = contested + ? "contested" + : controllerFactionId is not null + ? "controlled" + : primaryClaimantFactionId is not null + ? "claimed" + : "unclaimed", + IsContested = contested, + ControlScore = top?.TotalInfluence ?? 0f, + StrategicValue = strategicValue, + UpdatedAtUtc = nowUtc, + }; + controlState.ClaimantFactionIds.AddRange(state.Territory.Claims + .Where(claim => claim.SystemId == system.Definition.Id) + .Select(claim => claim.FactionId) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal)); + controlState.InfluencingFactionIds.AddRange(influences + .Select(influence => influence.FactionId) + .OrderBy(id => id, StringComparer.Ordinal)); + state.Territory.ControlStates.Add(controlState); + } + + foreach (var route in state.Routes) + { + var left = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.SourceSystemId); + var right = state.Territory.ControlStates.First(stateItem => stateItem.SystemId == route.DestinationSystemId); + var differentControllers = !string.Equals(left.ControllerFactionId, right.ControllerFactionId, StringComparison.Ordinal); + var contested = left.IsContested || right.IsContested || differentControllers; + if (!contested && left.ControllerFactionId is null && right.ControllerFactionId is null) + { + continue; + } + + state.Territory.BorderEdges.Add(new BorderEdgeRuntime + { + Id = $"border-{route.Id}", + SourceSystemId = route.SourceSystemId, + DestinationSystemId = route.DestinationSystemId, + SourceFactionId = left.ControllerFactionId ?? left.PrimaryClaimantFactionId, + DestinationFactionId = right.ControllerFactionId ?? right.PrimaryClaimantFactionId, + IsContested = contested, + TensionScore = MathF.Min(1f, MathF.Abs((left.ControlScore - right.ControlScore) / MathF.Max(50f, left.ControlScore + right.ControlScore))), + CorridorImportance = route.Distance <= 0.01f ? 0f : Math.Clamp((left.StrategicValue + right.StrategicValue) / MathF.Max(route.Distance, 1f), 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + + foreach (var control in state.Territory.ControlStates) + { + var adjacentBorders = state.Territory.BorderEdges.Where(edge => edge.SourceSystemId == control.SystemId || edge.DestinationSystemId == control.SystemId).ToList(); + var hostileBorderCount = adjacentBorders.Count(edge => edge.IsContested); + var corridorImportance = adjacentBorders.Sum(edge => edge.CorridorImportance); + var zoneKind = control.IsContested + ? "contested" + : control.ControllerFactionId is null && control.PrimaryClaimantFactionId is not null + ? "buffer" + : control.ControllerFactionId is not null && hostileBorderCount == 0 + ? "core" + : control.ControllerFactionId is not null && corridorImportance > 1.1f + ? "corridor" + : control.ControllerFactionId is not null + ? "frontier" + : "unclaimed"; + state.Territory.Zones.Add(new TerritoryZoneRuntime + { + Id = $"zone-{control.SystemId}", + SystemId = control.SystemId, + FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, + Kind = zoneKind, + Status = "active", + Reason = zoneKind == "corridor" ? "high-corridor-importance" : zoneKind == "frontier" ? "hostile-border-contact" : zoneKind, + UpdatedAtUtc = nowUtc, + }); + state.Territory.StrategicProfiles.Add(new SectorStrategicProfileRuntime + { + SystemId = control.SystemId, + ControllerFactionId = control.ControllerFactionId, + ZoneKind = zoneKind, + IsContested = control.IsContested, + StrategicValue = control.StrategicValue, + SecurityRating = Math.Clamp(1f - (hostileBorderCount * 0.22f), 0f, 1f), + TerritorialPressure = Math.Clamp(hostileBorderCount * 0.25f, 0f, 1f), + LogisticsValue = Math.Clamp(corridorImportance, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + state.Territory.Pressures.Add(new TerritoryPressureRuntime + { + Id = $"pressure-{control.SystemId}", + SystemId = control.SystemId, + FactionId = control.ControllerFactionId ?? control.PrimaryClaimantFactionId, + Kind = control.IsContested ? "contested-pressure" : "territorial-pressure", + PressureScore = Math.Clamp(hostileBorderCount * 0.28f, 0f, 1f), + SecurityScore = Math.Clamp(1f - (hostileBorderCount * 0.2f), 0f, 1f), + HostileInfluence = influencesBySystem.GetValueOrDefault(control.SystemId)?.Skip(control.ControllerFactionId is null ? 0 : 1).Sum(entry => entry.TotalInfluence) ?? 0f, + CorridorRisk = Math.Clamp(corridorImportance > 0.8f && hostileBorderCount > 0 ? 0.7f : hostileBorderCount * 0.2f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + } + + private static void RebuildDiplomacy(SimulationWorld world, GeopoliticalStateRuntime state, ICollection events) + { + state.Diplomacy.Relations.Clear(); + state.Diplomacy.Treaties.Clear(); + state.Diplomacy.BorderTensions.Clear(); + state.Diplomacy.Wars.Clear(); + + var nowUtc = world.GeneratedAtUtc; + var factionPairs = world.Factions + .OrderBy(faction => faction.Id, StringComparer.Ordinal) + .SelectMany((left, index) => world.Factions.Skip(index + 1).Select(right => (left, right))); + + foreach (var (leftFaction, rightFaction) in factionPairs) + { + var borderEdges = state.Territory.BorderEdges + .Where(edge => + (string.Equals(edge.SourceFactionId, leftFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, rightFaction.Id, StringComparison.Ordinal)) + || (string.Equals(edge.SourceFactionId, rightFaction.Id, StringComparison.Ordinal) && string.Equals(edge.DestinationFactionId, leftFaction.Id, StringComparison.Ordinal))) + .OrderBy(edge => edge.Id, StringComparer.Ordinal) + .ToList(); + var sharedBorderPressure = borderEdges.Sum(edge => edge.TensionScore + (edge.IsContested ? 0.25f : 0f)); + var conflictSystems = borderEdges.SelectMany(edge => new[] { edge.SourceSystemId, edge.DestinationSystemId }).Distinct(StringComparer.Ordinal).ToList(); + var hostilePresence = world.Ships.Count(ship => + ship.Health > 0f + && ((ship.FactionId == leftFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)) + || (ship.FactionId == rightFaction.Id && conflictSystems.Contains(ship.SystemId, StringComparer.Ordinal)))); + var incidentSeverity = Math.Clamp(sharedBorderPressure + (hostilePresence * 0.03f), 0f, 1.6f); + var relationId = BuildRelationId(leftFaction.Id, rightFaction.Id); + var posture = incidentSeverity switch + { + >= 1.1f => "war", + >= 0.65f => "hostile", + >= 0.3f => "wary", + _ => "neutral", + }; + + var relation = new DiplomaticRelationRuntime + { + Id = relationId, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = "active", + Posture = posture, + TrustScore = Math.Clamp(0.7f - incidentSeverity, 0f, 1f), + TensionScore = Math.Clamp(incidentSeverity, 0f, 1f), + GrievanceScore = Math.Clamp(sharedBorderPressure, 0f, 1f), + TradeAccessPolicy = posture is "war" or "hostile" ? "restricted" : "open", + MilitaryAccessPolicy = posture == "neutral" ? "transit" : posture == "wary" ? "restricted" : "denied", + UpdatedAtUtc = nowUtc, + }; + + if (relation.Posture == "neutral") + { + var treaty = new TreatyRuntime + { + Id = $"treaty-open-trade-{relationId}", + Kind = "trade-understanding", + Status = "active", + TradeAccessPolicy = "open", + MilitaryAccessPolicy = "restricted", + Summary = $"Open civilian trade between {leftFaction.Label} and {rightFaction.Label}.", + CreatedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + treaty.FactionIds.Add(leftFaction.Id); + treaty.FactionIds.Add(rightFaction.Id); + state.Diplomacy.Treaties.Add(treaty); + relation.ActiveTreatyIds.Add(treaty.Id); + relation.TradeAccessPolicy = "open"; + } + + state.Diplomacy.Relations.Add(relation); + + foreach (var borderEdge in borderEdges) + { + borderEdge.RelationId = relation.Id; + borderEdge.TensionScore = Math.Clamp(borderEdge.TensionScore + (relation.TensionScore * 0.35f), 0f, 1f); + var tension = new BorderTensionRuntime + { + Id = $"tension-{borderEdge.Id}", + RelationId = relation.Id, + BorderEdgeId = borderEdge.Id, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = relation.Posture is "war" or "hostile" ? "escalating" : "stable", + TensionScore = relation.TensionScore, + IncidentScore = incidentSeverity, + MilitaryPressure = Math.Clamp(hostilePresence * 0.05f, 0f, 1f), + AccessFriction = relation.TradeAccessPolicy == "open" ? 0.15f : 0.75f, + UpdatedAtUtc = nowUtc, + }; + tension.SystemIds.Add(borderEdge.SourceSystemId); + tension.SystemIds.Add(borderEdge.DestinationSystemId); + state.Diplomacy.BorderTensions.Add(tension); + + if (tension.TensionScore >= 0.35f) + { + var incidentId = $"incident-border-{relationId}-{borderEdge.Id}"; + var incident = new DiplomaticIncidentRuntime + { + Id = incidentId, + Kind = borderEdge.IsContested ? "border-clash" : "border-friction", + Status = relation.Posture == "war" ? "escalated" : "active", + SourceFactionId = leftFaction.Id, + TargetFactionId = rightFaction.Id, + SystemId = borderEdge.SourceSystemId, + BorderEdgeId = borderEdge.Id, + Summary = $"{leftFaction.Label} and {rightFaction.Label} are under pressure on {borderEdge.SourceSystemId}/{borderEdge.DestinationSystemId}.", + Severity = tension.TensionScore, + EscalationScore = tension.IncidentScore, + CreatedAtUtc = nowUtc, + LastObservedAtUtc = nowUtc, + }; + state.Diplomacy.Incidents.Add(incident); + relation.ActiveIncidentIds.Add(incident.Id); + } + } + + if (relation.Posture == "war") + { + var warId = $"war-{relationId}"; + var war = new WarStateRuntime + { + Id = warId, + RelationId = relation.Id, + FactionAId = leftFaction.Id, + FactionBId = rightFaction.Id, + Status = "active", + WarGoal = "border-dominance", + EscalationScore = relation.TensionScore, + StartedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + relation.WarStateId = war.Id; + state.Diplomacy.Wars.Add(war); + } + } + + BuildFrontLines(state, nowUtc, events); + } + + private static void BuildFrontLines(GeopoliticalStateRuntime state, DateTimeOffset nowUtc, ICollection events) + { + foreach (var group in state.Diplomacy.BorderTensions + .Where(tension => tension.TensionScore >= 0.35f) + .GroupBy(tension => BuildPairId("front", tension.FactionAId, tension.FactionBId), StringComparer.Ordinal)) + { + var tensions = group.OrderByDescending(tension => tension.TensionScore).ThenBy(tension => tension.Id, StringComparer.Ordinal).ToList(); + var front = new FrontLineRuntime + { + Id = group.Key, + Kind = state.Diplomacy.Wars.Any(war => war.RelationId == tensions[0].RelationId && war.Status == "active") ? "war-front" : "border-front", + Status = "active", + AnchorSystemId = tensions.SelectMany(tension => tension.SystemIds).GroupBy(systemId => systemId, StringComparer.Ordinal).OrderByDescending(entry => entry.Count()).ThenBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key).FirstOrDefault(), + PressureScore = Math.Clamp(tensions.Sum(tension => tension.TensionScore) / tensions.Count, 0f, 1f), + SupplyRisk = Math.Clamp(tensions.Sum(tension => tension.AccessFriction) / tensions.Count, 0f, 1f), + UpdatedAtUtc = nowUtc, + }; + front.FactionIds.Add(tensions[0].FactionAId); + front.FactionIds.Add(tensions[0].FactionBId); + front.SystemIds.AddRange(tensions.SelectMany(tension => tension.SystemIds).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); + front.BorderEdgeIds.AddRange(tensions.Select(tension => tension.BorderEdgeId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal)); + state.Territory.FrontLines.Add(front); + + foreach (var war in state.Diplomacy.Wars.Where(war => string.Equals(war.RelationId, tensions[0].RelationId, StringComparison.Ordinal))) + { + war.ActiveFrontLineIds.Add(front.Id); + } + + events.Add(new SimulationEventRecord("front-line", front.Id, "front-updated", $"Front {front.Id} pressure {front.PressureScore.ToString("0.00", CultureInfo.InvariantCulture)}.", nowUtc, "geopolitics")); + } + + foreach (var profile in state.Territory.StrategicProfiles) + { + profile.FrontLineId = state.Territory.FrontLines.FirstOrDefault(front => front.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; + } + } + + private static void RebuildEconomyRegions(SimulationWorld world, GeopoliticalStateRuntime state) + { + state.EconomyRegions.Regions.Clear(); + state.EconomyRegions.SupplyNetworks.Clear(); + state.EconomyRegions.Corridors.Clear(); + state.EconomyRegions.ProductionProfiles.Clear(); + state.EconomyRegions.TradeBalances.Clear(); + state.EconomyRegions.Bottlenecks.Clear(); + state.EconomyRegions.SecurityAssessments.Clear(); + state.EconomyRegions.EconomicAssessments.Clear(); + + var nowUtc = world.GeneratedAtUtc; + foreach (var faction in world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal)) + { + var factionSystems = state.Territory.ControlStates + .Where(control => string.Equals(control.ControllerFactionId ?? control.PrimaryClaimantFactionId, faction.Id, StringComparison.Ordinal)) + .Select(control => control.SystemId) + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .ToList(); + if (factionSystems.Count == 0) + { + continue; + } + + var connectedComponents = BuildConnectedComponents(factionSystems, state.Routes); + foreach (var component in connectedComponents) + { + var coreSystemId = component + .OrderByDescending(systemId => world.Stations.Count(station => station.FactionId == faction.Id && station.SystemId == systemId)) + .ThenBy(systemId => systemId, StringComparer.Ordinal) + .First(); + var regionId = $"region-{faction.Id}-{coreSystemId}"; + var stations = world.Stations + .Where(station => station.FactionId == faction.Id && component.Contains(station.SystemId, StringComparer.Ordinal)) + .OrderBy(station => station.Id, StringComparer.Ordinal) + .ToList(); + var economy = BuildRegionalEconomy(world, faction.Id, component); + var regionKind = ResolveRegionKind(stations, economy); + var frontLineIds = state.Territory.FrontLines + .Where(front => front.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))) + .Select(front => front.Id) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + var region = new EconomicRegionRuntime + { + Id = regionId, + FactionId = faction.Id, + Label = $"{faction.Label} {coreSystemId}", + Kind = regionKind, + Status = "active", + CoreSystemId = coreSystemId, + UpdatedAtUtc = nowUtc, + }; + region.SystemIds.AddRange(component.OrderBy(id => id, StringComparer.Ordinal)); + region.StationIds.AddRange(stations.Select(station => station.Id)); + region.FrontLineIds.AddRange(frontLineIds); + state.EconomyRegions.Regions.Add(region); + + var producerItems = economy.Commodities + .Where(entry => entry.Value.ProductionRatePerSecond > 0.01f) + .OrderByDescending(entry => entry.Value.ProductionRatePerSecond) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Take(8) + .Select(entry => entry.Key) + .ToList(); + var scarceItems = economy.Commodities + .Where(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low) + .OrderByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Value, 240f)) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Take(8) + .Select(entry => entry.Key) + .ToList(); + + var supplyNetwork = new SupplyNetworkRuntime + { + Id = $"network-{regionId}", + RegionId = regionId, + ThroughputScore = Math.Clamp(stations.Count * 0.18f, 0f, 1f), + RiskScore = Math.Clamp(frontLineIds.Count * 0.24f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }; + supplyNetwork.StationIds.AddRange(stations.Select(station => station.Id)); + supplyNetwork.ProducerItemIds.AddRange(producerItems); + supplyNetwork.ConsumerItemIds.AddRange(scarceItems); + supplyNetwork.ConstructionItemIds.AddRange(world.ConstructionSites + .Where(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) + .SelectMany(site => site.RequiredItems.Keys) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal)); + state.EconomyRegions.SupplyNetworks.Add(supplyNetwork); + + var productionProfile = new RegionalProductionProfileRuntime + { + RegionId = regionId, + PrimaryIndustry = regionKind, + ShipyardCount = stations.Count(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), + StationCount = stations.Count, + UpdatedAtUtc = nowUtc, + }; + productionProfile.ProducedItemIds.AddRange(producerItems); + productionProfile.ScarceItemIds.AddRange(scarceItems); + state.EconomyRegions.ProductionProfiles.Add(productionProfile); + + state.EconomyRegions.TradeBalances.Add(new RegionalTradeBalanceRuntime + { + RegionId = regionId, + ImportsRequiredCount = economy.Commodities.Count(entry => entry.Value.BuyBacklog > 0.01f), + ExportsSurplusCount = economy.Commodities.Count(entry => entry.Value.SellBacklog > 0.01f || entry.Value.Level == CommodityLevelKind.Surplus), + CriticalShortageCount = scarceItems.Count, + NetTradeScore = Math.Clamp((economy.Commodities.Sum(entry => entry.Value.ProjectedNetRatePerSecond) + 5f) / 10f, -1f, 1f), + UpdatedAtUtc = nowUtc, + }); + + if (scarceItems.FirstOrDefault() is { } bottleneckItemId) + { + state.EconomyRegions.Bottlenecks.Add(new RegionalBottleneckRuntime + { + Id = $"bottleneck-{regionId}-{bottleneckItemId}", + RegionId = regionId, + ItemId = bottleneckItemId, + Cause = "regional-shortage", + Status = "active", + Severity = Math.Clamp(CommodityOperationalSignal.ComputeNeedScore(economy.GetCommodity(bottleneckItemId), 240f), 0f, 10f), + UpdatedAtUtc = nowUtc, + }); + } + + var supplyRisk = Math.Clamp(frontLineIds.Count * 0.2f, 0f, 1f); + state.EconomyRegions.SecurityAssessments.Add(new RegionalSecurityAssessmentRuntime + { + RegionId = regionId, + SupplyRisk = supplyRisk, + BorderPressure = Math.Clamp(frontLineIds.Count * 0.22f, 0f, 1f), + ActiveWarCount = state.Diplomacy.Wars.Count(war => war.ActiveFrontLineIds.Intersect(frontLineIds, StringComparer.Ordinal).Any()), + HostileRelationCount = state.Diplomacy.Relations.Count(relation => relation.Posture is "hostile" or "war"), + AccessFriction = Math.Clamp(state.Diplomacy.BorderTensions.Where(tension => tension.SystemIds.Any(systemId => component.Contains(systemId, StringComparer.Ordinal))).DefaultIfEmpty().Average(tension => tension?.AccessFriction ?? 0f), 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + + state.EconomyRegions.EconomicAssessments.Add(new RegionalEconomicAssessmentRuntime + { + RegionId = regionId, + SustainmentScore = Math.Clamp(1f - (scarceItems.Count * 0.12f) - (supplyRisk * 0.35f), 0f, 1f), + ProductionDepth = Math.Clamp(producerItems.Count / 8f, 0f, 1f), + ConstructionPressure = Math.Clamp(world.ConstructionSites.Count(site => site.FactionId == faction.Id && component.Contains(site.SystemId, StringComparer.Ordinal)) * 0.22f, 0f, 1f), + CorridorDependency = Math.Clamp(frontLineIds.Count * 0.18f, 0f, 1f), + UpdatedAtUtc = nowUtc, + }); + } + } + + BuildCorridors(world, state, nowUtc); + foreach (var profile in state.Territory.StrategicProfiles) + { + profile.EconomicRegionId = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(profile.SystemId, StringComparer.Ordinal))?.Id; + } + } + + private static void BuildCorridors(SimulationWorld world, GeopoliticalStateRuntime state, DateTimeOffset nowUtc) + { + foreach (var route in state.Routes) + { + var sourceRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.SourceSystemId, StringComparer.Ordinal)); + var destinationRegion = state.EconomyRegions.Regions.FirstOrDefault(region => region.SystemIds.Contains(route.DestinationSystemId, StringComparer.Ordinal)); + if (sourceRegion is null && destinationRegion is null) + { + continue; + } + + var borderEdge = state.Territory.BorderEdges.FirstOrDefault(edge => + (edge.SourceSystemId == route.SourceSystemId && edge.DestinationSystemId == route.DestinationSystemId) + || (edge.SourceSystemId == route.DestinationSystemId && edge.DestinationSystemId == route.SourceSystemId)); + var risk = borderEdge?.TensionScore ?? 0f; + var corridor = new LogisticsCorridorRuntime + { + Id = $"corridor-{route.Id}", + FactionId = sourceRegion?.FactionId ?? destinationRegion?.FactionId, + Kind = borderEdge?.IsContested == true ? "frontier-corridor" : "supply-corridor", + Status = borderEdge?.IsContested == true ? "risky" : "active", + RiskScore = Math.Clamp(risk + ((sourceRegion is not null && destinationRegion is not null && sourceRegion.Id != destinationRegion.Id) ? 0.15f : 0f), 0f, 1f), + ThroughputScore = Math.Clamp(((sourceRegion?.StationIds.Count ?? 0) + (destinationRegion?.StationIds.Count ?? 0)) / 10f, 0f, 1f), + AccessState = ResolveCorridorAccessState(world, borderEdge, sourceRegion, destinationRegion), + UpdatedAtUtc = nowUtc, + }; + corridor.SystemPathIds.Add(route.SourceSystemId); + corridor.SystemPathIds.Add(route.DestinationSystemId); + if (sourceRegion is not null) + { + corridor.RegionIds.Add(sourceRegion.Id); + } + if (destinationRegion is not null && !corridor.RegionIds.Contains(destinationRegion.Id, StringComparer.Ordinal)) + { + corridor.RegionIds.Add(destinationRegion.Id); + } + if (borderEdge is not null) + { + corridor.BorderEdgeIds.Add(borderEdge.Id); + } + + state.EconomyRegions.Corridors.Add(corridor); + if (sourceRegion is not null && !sourceRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) + { + sourceRegion.CorridorIds.Add(corridor.Id); + } + if (destinationRegion is not null && !destinationRegion.CorridorIds.Contains(corridor.Id, StringComparer.Ordinal)) + { + destinationRegion.CorridorIds.Add(corridor.Id); + } + } + } + + private static string ResolveCorridorAccessState( + SimulationWorld world, + BorderEdgeRuntime? borderEdge, + EconomicRegionRuntime? sourceRegion, + EconomicRegionRuntime? destinationRegion) + { + if (sourceRegion?.FactionId is null || destinationRegion?.FactionId is null) + { + return borderEdge?.IsContested == true ? "restricted" : "open"; + } + + var relation = FindRelation(world, sourceRegion.FactionId, destinationRegion.FactionId); + if (relation is null) + { + return "restricted"; + } + + return relation.Posture switch + { + "war" => "denied", + "hostile" => "restricted", + _ => relation.TradeAccessPolicy, + }; + } + + private static FactionEconomySnapshot BuildRegionalEconomy(SimulationWorld world, string factionId, IReadOnlyCollection systemIds) + { + var snapshot = new FactionEconomySnapshot(); + foreach (var station in world.Stations.Where(station => station.FactionId == factionId && systemIds.Contains(station.SystemId, StringComparer.Ordinal))) + { + foreach (var (itemId, amount) in station.Inventory) + { + snapshot.GetCommodity(itemId).OnHand += amount; + } + + foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station)) + { + var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey); + if (recipe is null) + { + continue; + } + + var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe); + var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f); + foreach (var input in recipe.Inputs) + { + snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond; + } + foreach (var output in recipe.Outputs) + { + snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond; + } + } + } + + foreach (var order in world.MarketOrders.Where(order => order.FactionId == factionId)) + { + var relatedSystemId = world.Stations.FirstOrDefault(station => station.Id == order.StationId)?.SystemId + ?? world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId)?.SystemId; + if (relatedSystemId is null || !systemIds.Contains(relatedSystemId, StringComparer.Ordinal)) + { + continue; + } + + var commodity = snapshot.GetCommodity(order.ItemId); + if (order.Kind == MarketOrderKinds.Buy) + { + commodity.BuyBacklog += order.RemainingAmount; + } + else if (order.Kind == MarketOrderKinds.Sell) + { + commodity.SellBacklog += order.RemainingAmount; + } + } + + foreach (var site in world.ConstructionSites.Where(site => site.FactionId == factionId && systemIds.Contains(site.SystemId, StringComparer.Ordinal))) + { + foreach (var required in site.RequiredItems) + { + var remaining = MathF.Max(0f, required.Value - (site.DeliveredItems.TryGetValue(required.Key, out var delivered) ? delivered : 0f)); + if (remaining > 0.01f) + { + snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining; + } + } + } + + return snapshot; + } + + private static List> BuildConnectedComponents(IReadOnlyCollection systems, IReadOnlyCollection routes) + { + var remaining = systems.ToHashSet(StringComparer.Ordinal); + var adjacency = routes + .SelectMany(route => new[] + { + (route.SourceSystemId, route.DestinationSystemId), + (route.DestinationSystemId, route.SourceSystemId), + }) + .GroupBy(entry => entry.Item1, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Select(entry => entry.Item2).ToList(), StringComparer.Ordinal); + var components = new List>(); + + while (remaining.Count > 0) + { + var start = remaining.OrderBy(id => id, StringComparer.Ordinal).First(); + var frontier = new Queue(); + frontier.Enqueue(start); + remaining.Remove(start); + var component = new List(); + + while (frontier.Count > 0) + { + var current = frontier.Dequeue(); + component.Add(current); + foreach (var neighbor in adjacency.GetValueOrDefault(current, [])) + { + if (remaining.Remove(neighbor)) + { + frontier.Enqueue(neighbor); + } + } + } + + components.Add(component); + } + + return components; + } + + private static string ResolveRegionKind(IReadOnlyCollection stations, FactionEconomySnapshot economy) + { + if (stations.Any(station => station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal))) + { + return "shipbuilding-region"; + } + + if (stations.Count(station => StationSimulationService.DetermineStationRole(station) == "refinery") >= 2) + { + return "industrial-core"; + } + + if (economy.Commodities.Any(entry => entry.Value.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low)) + { + return "frontier-sustainment"; + } + + return stations.Count <= 2 ? "extraction-region" : "balanced-region"; + } + + private static float EstimateSystemStrategicValue(SimulationWorld world, string systemId) + { + var stationValue = world.Stations.Count(station => station.SystemId == systemId) * 30f; + var constructionValue = world.ConstructionSites.Count(site => site.SystemId == systemId && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed) * 18f; + var nodeValue = world.Nodes.Count(node => node.SystemId == systemId) * 8f; + return stationValue + constructionValue + nodeValue; + } + + private static string BuildRelationId(string factionAId, string factionBId) => + BuildPairId("relation", factionAId, factionBId); + + private static string BuildPairId(string prefix, string leftId, string rightId) + { + return string.Compare(leftId, rightId, StringComparison.Ordinal) <= 0 + ? $"{prefix}-{leftId}-{rightId}" + : $"{prefix}-{rightId}-{leftId}"; + } +} diff --git a/apps/backend/GlobalUsings.cs b/apps/backend/GlobalUsings.cs index 48ddc05..ff76e34 100644 --- a/apps/backend/GlobalUsings.cs +++ b/apps/backend/GlobalUsings.cs @@ -4,11 +4,15 @@ global using SpaceGame.Api.Economy.Runtime; global using SpaceGame.Api.Factions.AI; global using SpaceGame.Api.Factions.Contracts; global using SpaceGame.Api.Factions.Runtime; +global using SpaceGame.Api.Geopolitics.Contracts; +global using SpaceGame.Api.Geopolitics.Runtime; +global using SpaceGame.Api.Geopolitics.Simulation; global using SpaceGame.Api.Industry.Planning; -global using SpaceGame.Api.Shared.AI; +global using SpaceGame.Api.PlayerFaction.Contracts; +global using SpaceGame.Api.PlayerFaction.Runtime; +global using SpaceGame.Api.PlayerFaction.Simulation; global using SpaceGame.Api.Shared.Contracts; global using SpaceGame.Api.Shared.Runtime; -global using SpaceGame.Api.Ships.AI; global using SpaceGame.Api.Ships.Contracts; global using SpaceGame.Api.Ships.Runtime; global using SpaceGame.Api.Ships.Simulation; diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs index d7c95ce..78993d9 100644 --- a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -219,6 +219,11 @@ internal static class FactionIndustryPlanner return; } + if (!CanEstablishExpansionSite(world, factionId, project)) + { + return; + } + var nowUtc = DateTimeOffset.UtcNow; var claimId = $"claim-{factionId}-{project.CelestialId}"; if (world.Claims.All(candidate => candidate.Id != claimId)) @@ -303,7 +308,8 @@ internal static class FactionIndustryPlanner .GroupBy(order => order.ItemId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal); - if (CommanderPlanningService.FactionCommanderHasIssuedTask(world, factionId, FactionIssuedTaskKind.ProduceShips, "military")) + var threatAssessment = CommanderPlanningService.FindFactionThreatAssessment(world, factionId); + if (threatAssessment is not null && threatAssessment.EnemyFactionCount > 0) { demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; @@ -451,7 +457,8 @@ internal static class FactionIndustryPlanner .Where(celestial => celestial.Kind == SpatialNodeKind.LagrangePoint && celestial.OccupyingStructureId is null - && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) + && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) + && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) .OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) .FirstOrDefault(); } @@ -462,7 +469,8 @@ internal static class FactionIndustryPlanner .Where(celestial => celestial.Kind == SpatialNodeKind.LagrangePoint && celestial.OccupyingStructureId is null - && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) + && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed) + && IsExpansionSystemEligible(world, factionId, celestial.SystemId)) .OrderByDescending(celestial => world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))) @@ -482,7 +490,80 @@ internal static class FactionIndustryPlanner var factionPresence = world.Stations.Count(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal) && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)); - return resourceScore + (factionPresence * 5_000f); + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, celestial.SystemId); + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, celestial.SystemId); + var strategicProfile = world.Geopolitics?.Territory.StrategicProfiles.FirstOrDefault(profile => profile.SystemId == celestial.SystemId); + var pressure = world.Geopolitics?.Territory.Pressures + .Where(entry => entry.SystemId == celestial.SystemId && entry.FactionId == factionId) + .OrderByDescending(entry => entry.HostileInfluence) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + var controlBias = string.Equals(controlState?.ControllerFactionId, factionId, StringComparison.Ordinal) + ? 12_000f + : string.Equals(controlState?.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) + ? 4_000f + : 0f; + var regionBias = region is null + ? 0f + : region.Kind switch + { + "core-industry" => 4_500f, + "shipbuilding" => 3_250f, + "trade-hub" => 2_250f, + "corridor" => 1_500f, + _ => 1_000f, + }; + var securityPenalty = ((pressure?.HostileInfluence ?? 0f) * 14f) + + ((strategicProfile?.TerritorialPressure ?? 0f) * 9f) + + ((world.Geopolitics is null ? 0f : GeopoliticalSimulationService.GetSystemRouteRisk(world, celestial.SystemId, factionId)) * 250f); + return resourceScore + + (factionPresence * 5_000f) + + controlBias + + regionBias + - securityPenalty; + } + + private static bool IsExpansionSystemEligible(SimulationWorld world, string factionId, string systemId) + { + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); + if (controlState is null) + { + return true; + } + + var authorityFactionId = controlState.ControllerFactionId ?? controlState.PrimaryClaimantFactionId; + if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + { + return true; + } + + if (!GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId)) + { + return false; + } + + return !GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId); + } + + private static bool CanEstablishExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) + { + if (!IsExpansionSystemEligible(world, factionId, project.SystemId)) + { + return false; + } + + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, project.SystemId); + if (controlState?.IsContested == true) + { + return false; + } + + var pressure = world.Geopolitics?.Territory.Pressures + .Where(entry => entry.SystemId == project.SystemId && entry.FactionId == factionId) + .OrderByDescending(entry => entry.CorridorRisk + entry.HostileInfluence) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .FirstOrDefault(); + return pressure is null || (pressure.CorridorRisk < 0.8f && pressure.HostileInfluence < 1.2f); } private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId) diff --git a/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs new file mode 100644 index 0000000..5193053 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/CreatePlayerOrganizationHandler.cs @@ -0,0 +1,32 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class CreatePlayerOrganizationHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/organizations"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerOrganizationCommandRequest request, CancellationToken cancellationToken) + { + try + { + var snapshot = worldService.CreatePlayerOrganization(request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs new file mode 100644 index 0000000..072f0b3 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/DeletePlayerDirectiveHandler.cs @@ -0,0 +1,29 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class DeletePlayerDirectiveRequest +{ + public string DirectiveId { get; set; } = string.Empty; +} + +public sealed class DeletePlayerDirectiveHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Delete("/api/player-faction/directives/{directiveId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(DeletePlayerDirectiveRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.DeletePlayerDirective(request.DirectiveId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs new file mode 100644 index 0000000..9b8cba5 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/DeletePlayerOrganizationHandler.cs @@ -0,0 +1,37 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class DeletePlayerOrganizationRequest +{ + public string OrganizationId { get; set; } = string.Empty; +} + +public sealed class DeletePlayerOrganizationHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Delete("/api/player-faction/organizations/{organizationId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(DeletePlayerOrganizationRequest request, CancellationToken cancellationToken) + { + try + { + var snapshot = worldService.DeletePlayerOrganization(request.OrganizationId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs new file mode 100644 index 0000000..9f3474e --- /dev/null +++ b/apps/backend/PlayerFaction/Api/GetPlayerFactionHandler.cs @@ -0,0 +1,24 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class GetPlayerFactionHandler(WorldService worldService) : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/player-faction"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var snapshot = worldService.GetPlayerFaction(); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs new file mode 100644 index 0000000..89ba4ef --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerOrganizationMembershipHandler.cs @@ -0,0 +1,40 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpdatePlayerOrganizationMembershipHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/player-faction/organizations/{organizationId}/membership"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerOrganizationMembershipCommandRequest request, CancellationToken cancellationToken) + { + try + { + var organizationId = Route("organizationId"); + if (string.IsNullOrWhiteSpace(organizationId)) + { + AddError("organizationId route parameter is required."); + await SendErrorsAsync(cancellation: cancellationToken); + return; + } + + var snapshot = worldService.UpdatePlayerOrganizationMembership(organizationId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs new file mode 100644 index 0000000..4f14538 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpdatePlayerStrategicIntentHandler.cs @@ -0,0 +1,24 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpdatePlayerStrategicIntentHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/player-faction/strategic-intent"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerStrategicIntentCommandRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.UpdatePlayerStrategicIntent(request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs new file mode 100644 index 0000000..a46132c --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAssignmentHandler.cs @@ -0,0 +1,31 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerAssignmentHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/player-faction/assets/{assetId}/assignment"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerAssetAssignmentCommandRequest request, CancellationToken cancellationToken) + { + var assetId = Route("assetId"); + if (string.IsNullOrWhiteSpace(assetId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + var snapshot = worldService.UpsertPlayerAssignment(assetId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs new file mode 100644 index 0000000..53e3d47 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerAutomationPolicyHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerAutomationPolicyHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/automation-policies"); + Put("/api/player-faction/automation-policies/{automationPolicyId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerAutomationPolicyCommandRequest request, CancellationToken cancellationToken) + { + var automationPolicyId = Route("automationPolicyId"); + var snapshot = worldService.UpsertPlayerAutomationPolicy(string.IsNullOrWhiteSpace(automationPolicyId) ? null : automationPolicyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs new file mode 100644 index 0000000..2d62106 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerDirectiveHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerDirectiveHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/directives"); + Put("/api/player-faction/directives/{directiveId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerDirectiveCommandRequest request, CancellationToken cancellationToken) + { + var directiveId = Route("directiveId"); + var snapshot = worldService.UpsertPlayerDirective(string.IsNullOrWhiteSpace(directiveId) ? null : directiveId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs new file mode 100644 index 0000000..64eb361 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerPolicyHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerPolicyHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/policies"); + Put("/api/player-faction/policies/{policyId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerPolicyCommandRequest request, CancellationToken cancellationToken) + { + var policyId = Route("policyId"); + var snapshot = worldService.UpsertPlayerPolicy(string.IsNullOrWhiteSpace(policyId) ? null : policyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs new file mode 100644 index 0000000..0972141 --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerProductionProgramHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerProductionProgramHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/production-programs"); + Put("/api/player-faction/production-programs/{productionProgramId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerProductionProgramCommandRequest request, CancellationToken cancellationToken) + { + var productionProgramId = Route("productionProgramId"); + var snapshot = worldService.UpsertPlayerProductionProgram(string.IsNullOrWhiteSpace(productionProgramId) ? null : productionProgramId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs new file mode 100644 index 0000000..7c40afe --- /dev/null +++ b/apps/backend/PlayerFaction/Api/UpsertPlayerReinforcementPolicyHandler.cs @@ -0,0 +1,26 @@ +using FastEndpoints; + +namespace SpaceGame.Api.PlayerFaction.Api; + +public sealed class UpsertPlayerReinforcementPolicyHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/player-faction/reinforcement-policies"); + Put("/api/player-faction/reinforcement-policies/{reinforcementPolicyId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(PlayerReinforcementPolicyCommandRequest request, CancellationToken cancellationToken) + { + var reinforcementPolicyId = Route("reinforcementPolicyId"); + var snapshot = worldService.UpsertPlayerReinforcementPolicy(string.IsNullOrWhiteSpace(reinforcementPolicyId) ? null : reinforcementPolicyId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs new file mode 100644 index 0000000..8bce83f --- /dev/null +++ b/apps/backend/PlayerFaction/Contracts/PlayerFaction.cs @@ -0,0 +1,271 @@ +namespace SpaceGame.Api.PlayerFaction.Contracts; + +public sealed record PlayerAssetRegistrySnapshot( + IReadOnlyList ShipIds, + IReadOnlyList StationIds, + IReadOnlyList CommanderIds, + IReadOnlyList ClaimIds, + IReadOnlyList ConstructionSiteIds, + IReadOnlyList PolicySetIds, + IReadOnlyList MarketOrderIds, + IReadOnlyList FleetIds, + IReadOnlyList TaskForceIds, + IReadOnlyList StationGroupIds, + IReadOnlyList EconomicRegionIds, + IReadOnlyList FrontIds, + IReadOnlyList ReserveIds); + +public sealed record PlayerStrategicIntentSnapshot( + string StrategicPosture, + string EconomicPosture, + string MilitaryPosture, + string LogisticsPosture, + float DesiredReserveRatio, + bool AllowDelegatedCombatAutomation, + bool AllowDelegatedEconomicAutomation, + string? Notes); + +public sealed record PlayerFleetSnapshot( + string Id, + string Label, + string Status, + string Role, + string? CommanderId, + string? FrontId, + string? HomeSystemId, + string? HomeStationId, + string? PolicyId, + string? AutomationPolicyId, + string? ReinforcementPolicyId, + IReadOnlyList AssetIds, + IReadOnlyList TaskForceIds, + IReadOnlyList DirectiveIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerTaskForceSnapshot( + string Id, + string Label, + string Status, + string Role, + string? FleetId, + string? CommanderId, + string? FrontId, + string? PolicyId, + string? AutomationPolicyId, + IReadOnlyList AssetIds, + IReadOnlyList DirectiveIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerStationGroupSnapshot( + string Id, + string Label, + string Status, + string Role, + string? EconomicRegionId, + string? PolicyId, + string? AutomationPolicyId, + IReadOnlyList StationIds, + IReadOnlyList DirectiveIds, + IReadOnlyList FocusItemIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerEconomicRegionSnapshot( + string Id, + string Label, + string Status, + string Role, + string? SharedEconomicRegionId, + string? PolicyId, + string? AutomationPolicyId, + IReadOnlyList SystemIds, + IReadOnlyList StationGroupIds, + IReadOnlyList DirectiveIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerFrontSnapshot( + string Id, + string Label, + string Status, + float Priority, + string Posture, + string? SharedFrontLineId, + string? TargetFactionId, + IReadOnlyList SystemIds, + IReadOnlyList FleetIds, + IReadOnlyList ReserveIds, + IReadOnlyList DirectiveIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerReserveGroupSnapshot( + string Id, + string Label, + string Status, + string ReserveKind, + string? HomeSystemId, + string? PolicyId, + IReadOnlyList AssetIds, + IReadOnlyList FrontIds, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerFactionPolicySnapshot( + string Id, + string Label, + string ScopeKind, + string? ScopeId, + string? PolicySetId, + bool AllowDelegatedCombat, + bool AllowDelegatedTrade, + float ReserveCreditsRatio, + float ReserveMilitaryRatio, + string TradeAccessPolicy, + string DockingAccessPolicy, + string ConstructionAccessPolicy, + string OperationalRangePolicy, + string CombatEngagementPolicy, + bool AvoidHostileSystems, + float FleeHullRatio, + IReadOnlyList BlacklistedSystemIds, + string? Notes, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerAutomationPolicySnapshot( + string Id, + string Label, + string ScopeKind, + string? ScopeId, + bool Enabled, + string BehaviorKind, + bool UseOrders, + string? StagingOrderKind, + int MaxSystemRange, + bool KnownStationsOnly, + float Radius, + float WaitSeconds, + string? PreferredItemId, + string? Notes, + IReadOnlyList RepeatOrders, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerReinforcementPolicySnapshot( + string Id, + string Label, + string ScopeKind, + string? ScopeId, + string ShipKind, + int DesiredAssetCount, + int MinimumReserveCount, + bool AutoTransferReserves, + bool AutoQueueProduction, + string? SourceReserveId, + string? TargetFrontId, + string? Notes, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerProductionProgramSnapshot( + string Id, + string Label, + string Status, + string Kind, + string? TargetShipKind, + string? TargetModuleId, + string? TargetItemId, + int TargetCount, + int CurrentCount, + string? StationGroupId, + string? ReinforcementPolicyId, + string? Notes, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerDirectiveSnapshot( + string Id, + string Label, + string Status, + string Kind, + string ScopeKind, + string ScopeId, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? HomeSystemId, + string? HomeStationId, + string? SourceStationId, + string? DestinationStationId, + string BehaviorKind, + bool UseOrders, + string? StagingOrderKind, + string? ItemId, + string? PreferredNodeId, + string? PreferredConstructionSiteId, + string? PreferredModuleId, + int Priority, + float Radius, + float WaitSeconds, + int MaxSystemRange, + bool KnownStationsOnly, + IReadOnlyList PatrolPoints, + IReadOnlyList RepeatOrders, + string? PolicyId, + string? AutomationPolicyId, + string? Notes, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerAssignmentSnapshot( + string Id, + string AssetKind, + string AssetId, + string? FleetId, + string? TaskForceId, + string? StationGroupId, + string? EconomicRegionId, + string? FrontId, + string? ReserveId, + string? DirectiveId, + string? PolicyId, + string? AutomationPolicyId, + string Role, + string Status, + DateTimeOffset UpdatedAtUtc); + +public sealed record PlayerDecisionLogEntrySnapshot( + string Id, + string Kind, + string Summary, + string? RelatedEntityKind, + string? RelatedEntityId, + DateTimeOffset OccurredAtUtc); + +public sealed record PlayerAlertSnapshot( + string Id, + string Kind, + string Severity, + string Summary, + string? AssetKind, + string? AssetId, + string? RelatedDirectiveId, + string Status, + DateTimeOffset CreatedAtUtc); + +public sealed record PlayerFactionSnapshot( + string Id, + string Label, + string SovereignFactionId, + string Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + PlayerAssetRegistrySnapshot AssetRegistry, + PlayerStrategicIntentSnapshot StrategicIntent, + IReadOnlyList Fleets, + IReadOnlyList TaskForces, + IReadOnlyList StationGroups, + IReadOnlyList EconomicRegions, + IReadOnlyList Fronts, + IReadOnlyList Reserves, + IReadOnlyList Policies, + IReadOnlyList AutomationPolicies, + IReadOnlyList ReinforcementPolicies, + IReadOnlyList ProductionPrograms, + IReadOnlyList Directives, + IReadOnlyList Assignments, + IReadOnlyList DecisionLog, + IReadOnlyList Alerts); diff --git a/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs new file mode 100644 index 0000000..2605909 --- /dev/null +++ b/apps/backend/PlayerFaction/Contracts/PlayerFactionCommands.cs @@ -0,0 +1,140 @@ +namespace SpaceGame.Api.PlayerFaction.Contracts; + +public sealed record PlayerOrganizationCommandRequest( + string Kind, + string Label, + string? ParentOrganizationId, + string? FrontId, + string? HomeSystemId, + string? HomeStationId, + string? PolicyId, + string? AutomationPolicyId, + string? ReinforcementPolicyId, + string? TargetFactionId, + float? Priority, + string? Role, + string? ReserveKind, + IReadOnlyList? SystemIds, + IReadOnlyList? FocusItemIds, + string? Notes); + +public sealed record PlayerOrganizationMembershipCommandRequest( + IReadOnlyList? AssetIds, + IReadOnlyList? ChildOrganizationIds, + IReadOnlyList? SystemIds, + IReadOnlyList? FrontIds, + bool Replace = false); + +public sealed record PlayerDirectiveCommandRequest( + string Label, + string Kind, + string ScopeKind, + string ScopeId, + string BehaviorKind, + bool UseOrders, + string? StagingOrderKind, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? HomeSystemId, + string? HomeStationId, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? PreferredNodeId, + string? PreferredConstructionSiteId, + string? PreferredModuleId, + int Priority, + float? Radius, + float? WaitSeconds, + int? MaxSystemRange, + bool? KnownStationsOnly, + IReadOnlyList? PatrolPoints, + IReadOnlyList? RepeatOrders, + string? PolicyId, + string? AutomationPolicyId, + string? Notes); + +public sealed record PlayerPolicyCommandRequest( + string Label, + string ScopeKind, + string? ScopeId, + string? PolicySetId, + bool AllowDelegatedCombat, + bool AllowDelegatedTrade, + float ReserveCreditsRatio, + float ReserveMilitaryRatio, + string? Notes, + string? TradeAccessPolicy, + string? DockingAccessPolicy, + string? ConstructionAccessPolicy, + string? OperationalRangePolicy, + string? CombatEngagementPolicy, + bool? AvoidHostileSystems, + float? FleeHullRatio, + IReadOnlyList? BlacklistedSystemIds); + +public sealed record PlayerAutomationPolicyCommandRequest( + string Label, + string ScopeKind, + string? ScopeId, + bool Enabled, + string BehaviorKind, + bool UseOrders, + string? StagingOrderKind, + int MaxSystemRange, + bool KnownStationsOnly, + float Radius, + float WaitSeconds, + string? PreferredItemId, + string? Notes, + IReadOnlyList? RepeatOrders); + +public sealed record PlayerReinforcementPolicyCommandRequest( + string Label, + string ScopeKind, + string? ScopeId, + string ShipKind, + int DesiredAssetCount, + int MinimumReserveCount, + bool AutoTransferReserves, + bool AutoQueueProduction, + string? SourceReserveId, + string? TargetFrontId, + string? Notes); + +public sealed record PlayerProductionProgramCommandRequest( + string Label, + string Kind, + string? TargetShipKind, + string? TargetModuleId, + string? TargetItemId, + int TargetCount, + string? StationGroupId, + string? ReinforcementPolicyId, + string? Notes); + +public sealed record PlayerAssetAssignmentCommandRequest( + string AssetKind, + string AssetId, + string? FleetId, + string? TaskForceId, + string? StationGroupId, + string? EconomicRegionId, + string? FrontId, + string? ReserveId, + string? DirectiveId, + string? PolicyId, + string? AutomationPolicyId, + string Role, + bool ClearConflicts = true); + +public sealed record PlayerStrategicIntentCommandRequest( + string StrategicPosture, + string EconomicPosture, + string MilitaryPosture, + string LogisticsPosture, + float DesiredReserveRatio, + bool AllowDelegatedCombatAutomation, + bool AllowDelegatedEconomicAutomation, + string? Notes); diff --git a/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs new file mode 100644 index 0000000..b6954db --- /dev/null +++ b/apps/backend/PlayerFaction/Runtime/PlayerFactionRuntimeModels.cs @@ -0,0 +1,306 @@ +namespace SpaceGame.Api.PlayerFaction.Runtime; + +public sealed class PlayerFactionRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public required string SovereignFactionId { get; set; } + public string Status { get; set; } = "active"; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public PlayerAssetRegistryRuntime AssetRegistry { get; set; } = new(); + public PlayerStrategicIntentRuntime StrategicIntent { get; set; } = new(); + public List Fleets { get; } = []; + public List TaskForces { get; } = []; + public List StationGroups { get; } = []; + public List EconomicRegions { get; } = []; + public List Fronts { get; } = []; + public List Reserves { get; } = []; + public List Policies { get; } = []; + public List AutomationPolicies { get; } = []; + public List ReinforcementPolicies { get; } = []; + public List ProductionPrograms { get; } = []; + public List Directives { get; } = []; + public List Assignments { get; } = []; + public List DecisionLog { get; } = []; + public List Alerts { get; } = []; + public string LastDeltaSignature { get; set; } = string.Empty; +} + +public sealed class PlayerAssetRegistryRuntime +{ + public HashSet ShipIds { get; } = new(StringComparer.Ordinal); + public HashSet StationIds { get; } = new(StringComparer.Ordinal); + public HashSet CommanderIds { get; } = new(StringComparer.Ordinal); + public HashSet ClaimIds { get; } = new(StringComparer.Ordinal); + public HashSet ConstructionSiteIds { get; } = new(StringComparer.Ordinal); + public HashSet PolicySetIds { get; } = new(StringComparer.Ordinal); + public HashSet MarketOrderIds { get; } = new(StringComparer.Ordinal); + public HashSet FleetIds { get; } = new(StringComparer.Ordinal); + public HashSet TaskForceIds { get; } = new(StringComparer.Ordinal); + public HashSet StationGroupIds { get; } = new(StringComparer.Ordinal); + public HashSet EconomicRegionIds { get; } = new(StringComparer.Ordinal); + public HashSet FrontIds { get; } = new(StringComparer.Ordinal); + public HashSet ReserveIds { get; } = new(StringComparer.Ordinal); +} + +public sealed class PlayerStrategicIntentRuntime +{ + public string StrategicPosture { get; set; } = "balanced"; + public string EconomicPosture { get; set; } = "delegated"; + public string MilitaryPosture { get; set; } = "layered-defense"; + public string LogisticsPosture { get; set; } = "stable"; + public float DesiredReserveRatio { get; set; } = 0.2f; + public bool AllowDelegatedCombatAutomation { get; set; } = true; + public bool AllowDelegatedEconomicAutomation { get; set; } = true; + public string? Notes { get; set; } +} + +public sealed class PlayerFleetRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "general-purpose"; + public string? CommanderId { get; set; } + public string? FrontId { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string? ReinforcementPolicyId { get; set; } + public List AssetIds { get; } = []; + public List TaskForceIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerTaskForceRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "task-force"; + public string? FleetId { get; set; } + public string? CommanderId { get; set; } + public string? FrontId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List AssetIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerStationGroupRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "industrial-group"; + public string? EconomicRegionId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List StationIds { get; } = []; + public List DirectiveIds { get; } = []; + public List FocusItemIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerEconomicRegionRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Role { get; set; } = "balanced-region"; + public string? SharedEconomicRegionId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public List SystemIds { get; } = []; + public List StationGroupIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerFrontRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public float Priority { get; set; } = 50f; + public string Posture { get; set; } = "hold"; + public string? SharedFrontLineId { get; set; } + public string? TargetFactionId { get; set; } + public List SystemIds { get; } = []; + public List FleetIds { get; } = []; + public List ReserveIds { get; } = []; + public List DirectiveIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerReserveGroupRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "ready"; + public string ReserveKind { get; set; } = "military"; + public string? HomeSystemId { get; set; } + public string? PolicyId { get; set; } + public List AssetIds { get; } = []; + public List FrontIds { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerFactionPolicyRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public string? PolicySetId { get; set; } + public bool AllowDelegatedCombat { get; set; } = true; + public bool AllowDelegatedTrade { get; set; } = true; + public float ReserveCreditsRatio { get; set; } = 0.2f; + public float ReserveMilitaryRatio { get; set; } = 0.2f; + public string TradeAccessPolicy { get; set; } = "owner-and-allies"; + public string DockingAccessPolicy { get; set; } = "owner-and-allies"; + public string ConstructionAccessPolicy { get; set; } = "owner-only"; + public string OperationalRangePolicy { get; set; } = "unrestricted"; + public string CombatEngagementPolicy { get; set; } = "defensive"; + public bool AvoidHostileSystems { get; set; } = true; + public float FleeHullRatio { get; set; } = 0.35f; + public HashSet BlacklistedSystemIds { get; } = new(StringComparer.Ordinal); + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerAutomationPolicyRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public bool Enabled { get; set; } = true; + public string BehaviorKind { get; set; } = "idle"; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public float Radius { get; set; } = 24f; + public float WaitSeconds { get; set; } = 3f; + public string? PreferredItemId { get; set; } + public string? Notes { get; set; } + public List RepeatOrders { get; } = []; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerReinforcementPolicyRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string ScopeKind { get; set; } = "player-faction"; + public string? ScopeId { get; set; } + public string ShipKind { get; set; } = "military"; + public int DesiredAssetCount { get; set; } + public int MinimumReserveCount { get; set; } + public bool AutoTransferReserves { get; set; } = true; + public bool AutoQueueProduction { get; set; } = true; + public string? SourceReserveId { get; set; } + public string? TargetFrontId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerProductionProgramRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Kind { get; set; } = "ship-production"; + public string? TargetShipKind { get; set; } + public string? TargetModuleId { get; set; } + public string? TargetItemId { get; set; } + public int TargetCount { get; set; } + public int CurrentCount { get; set; } + public string? StationGroupId { get; set; } + public string? ReinforcementPolicyId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerDirectiveRuntime +{ + public required string Id { get; init; } + public required string Label { get; set; } + public string Status { get; set; } = "active"; + public string Kind { get; set; } = "hold"; + public string ScopeKind { get; set; } = "asset"; + public string ScopeId { get; set; } = string.Empty; + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string BehaviorKind { get; set; } = "idle"; + public bool UseOrders { get; set; } + public string? StagingOrderKind { get; set; } + public string? ItemId { get; set; } + public string? PreferredNodeId { get; set; } + public string? PreferredConstructionSiteId { get; set; } + public string? PreferredModuleId { get; set; } + public int Priority { get; set; } = 50; + public float Radius { get; set; } = 24f; + public float WaitSeconds { get; set; } = 3f; + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public List PatrolPoints { get; } = []; + public List RepeatOrders { get; } = []; + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string? Notes { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerAssignmentRuntime +{ + public required string Id { get; init; } + public required string AssetKind { get; set; } + public required string AssetId { get; set; } + public string? FleetId { get; set; } + public string? TaskForceId { get; set; } + public string? StationGroupId { get; set; } + public string? EconomicRegionId { get; set; } + public string? FrontId { get; set; } + public string? ReserveId { get; set; } + public string? DirectiveId { get; set; } + public string? PolicyId { get; set; } + public string? AutomationPolicyId { get; set; } + public string Role { get; set; } = "line"; + public string Status { get; set; } = "active"; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerDecisionLogEntryRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Summary { get; set; } + public string? RelatedEntityKind { get; set; } + public string? RelatedEntityId { get; set; } + public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PlayerAlertRuntime +{ + public required string Id { get; init; } + public required string Kind { get; set; } + public required string Severity { get; set; } + public required string Summary { get; set; } + public string? AssetKind { get; set; } + public string? AssetId { get; set; } + public string? RelatedDirectiveId { get; set; } + public string Status { get; set; } = "open"; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs new file mode 100644 index 0000000..a47336f --- /dev/null +++ b/apps/backend/PlayerFaction/Simulation/PlayerFactionService.cs @@ -0,0 +1,2441 @@ +namespace SpaceGame.Api.PlayerFaction.Simulation; + +internal sealed class PlayerFactionService +{ + private const int MaxDecisionEntries = 64; + private const int MaxAlerts = 32; + private const string PlayerFactionDomainId = "player-faction"; + + internal static bool IsPlayerFaction(SimulationWorld world, string factionId) => + world.PlayerFaction is not null && string.Equals(world.PlayerFaction.SovereignFactionId, factionId, StringComparison.Ordinal); + + internal PlayerFactionRuntime EnsureDomain(SimulationWorld world) + { + if (world.PlayerFaction is not null) + { + return world.PlayerFaction; + } + + var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal)) + ?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); + + world.PlayerFaction = new PlayerFactionRuntime + { + Id = PlayerFactionDomainId, + Label = $"{sovereignFaction.Label} Command", + SovereignFactionId = sovereignFaction.Id, + CreatedAtUtc = world.GeneratedAtUtc, + UpdatedAtUtc = world.GeneratedAtUtc, + }; + + EnsureBaseStructures(world, world.PlayerFaction); + SyncRegistry(world, world.PlayerFaction); + return world.PlayerFaction; + } + + internal void Update(SimulationWorld world, float _deltaSeconds, ICollection events) + { + var player = EnsureDomain(world); + EnsureBaseStructures(world, player); + SyncRegistry(world, player); + PrunePlayerState(world, player); + RefreshGeopoliticalOrganizationContext(world, player); + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + RefreshProductionPrograms(world, player); + ApplyStrategicIntegration(world, player); + ApplyPolicies(world, player); + ApplyAssignmentsAndDirectives(world, player, events); + RefreshAlerts(world, player); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + internal PlayerFactionRuntime CreateOrganization(SimulationWorld world, PlayerOrganizationCommandRequest request) + { + var player = EnsureDomain(world); + var id = CreateDomainId(request.Kind, request.Label, ExistingOrganizationIds(player)); + var nowUtc = DateTimeOffset.UtcNow; + + switch (NormalizeKind(request.Kind)) + { + case "fleet": + player.Fleets.Add(new PlayerFleetRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "general-purpose", + FrontId = request.FrontId, + HomeSystemId = request.HomeSystemId, + HomeStationId = request.HomeStationId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + ReinforcementPolicyId = request.ReinforcementPolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.FleetIds.Add(id); + break; + case "task-force": + player.TaskForces.Add(new PlayerTaskForceRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "task-force", + FleetId = request.ParentOrganizationId, + FrontId = request.FrontId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.TaskForceIds.Add(id); + break; + case "station-group": + var stationGroup = new PlayerStationGroupRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "industrial-group", + EconomicRegionId = request.ParentOrganizationId, + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }; + foreach (var itemId in request.FocusItemIds ?? []) + { + stationGroup.FocusItemIds.Add(itemId); + } + player.StationGroups.Add(stationGroup); + player.AssetRegistry.StationGroupIds.Add(id); + break; + case "economic-region": + var region = new PlayerEconomicRegionRuntime + { + Id = id, + Label = request.Label, + Role = request.Role ?? "balanced-region", + PolicyId = request.PolicyId, + AutomationPolicyId = request.AutomationPolicyId, + UpdatedAtUtc = nowUtc, + }; + foreach (var systemId in request.SystemIds ?? []) + { + if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) + { + region.SystemIds.Add(systemId); + } + } + player.EconomicRegions.Add(region); + player.AssetRegistry.EconomicRegionIds.Add(id); + break; + case "front": + var front = new PlayerFrontRuntime + { + Id = id, + Label = request.Label, + Priority = request.Priority ?? 50f, + Posture = request.Role ?? "hold", + TargetFactionId = request.TargetFactionId, + UpdatedAtUtc = nowUtc, + }; + foreach (var systemId in request.SystemIds ?? []) + { + if (world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))) + { + front.SystemIds.Add(systemId); + } + } + player.Fronts.Add(front); + player.AssetRegistry.FrontIds.Add(id); + break; + case "reserve": + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = id, + Label = request.Label, + ReserveKind = request.ReserveKind ?? "military", + HomeSystemId = request.HomeSystemId, + PolicyId = request.PolicyId, + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.ReserveIds.Add(id); + break; + default: + throw new InvalidOperationException($"Unsupported organization kind '{request.Kind}'."); + } + + AddDecision(player, "organization-created", $"Created {request.Kind} {request.Label}.", request.Kind, id); + player.UpdatedAtUtc = nowUtc; + return player; + } + + internal PlayerFactionRuntime DeleteOrganization(SimulationWorld world, string organizationId) + { + var player = EnsureDomain(world); + RemoveOrganization(player, organizationId); + player.Assignments.RemoveAll(assignment => + assignment.FleetId == organizationId || + assignment.TaskForceId == organizationId || + assignment.StationGroupId == organizationId || + assignment.EconomicRegionId == organizationId || + assignment.FrontId == organizationId || + assignment.ReserveId == organizationId); + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "organization-deleted", $"Removed organization {organizationId}.", "organization", organizationId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpdateOrganizationMembership(SimulationWorld world, string organizationId, PlayerOrganizationMembershipCommandRequest request) + { + var player = EnsureDomain(world); + var kind = ResolveOrganizationKind(player, organizationId); + switch (kind) + { + case "fleet": + var fleet = player.Fleets.First(entity => entity.Id == organizationId); + UpdateStringList(fleet.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + UpdateStringList(fleet.TaskForceIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.TaskForceIds); + fleet.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "task-force": + var taskForce = player.TaskForces.First(entity => entity.Id == organizationId); + UpdateStringList(taskForce.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + taskForce.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "station-group": + var stationGroup = player.StationGroups.First(entity => entity.Id == organizationId); + UpdateStringList(stationGroup.StationIds, request.AssetIds, request.Replace, player.AssetRegistry.StationIds); + stationGroup.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "economic-region": + var region = player.EconomicRegions.First(entity => entity.Id == organizationId); + UpdateStringList(region.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); + UpdateStringList(region.StationGroupIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.StationGroupIds); + region.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "front": + var front = player.Fronts.First(entity => entity.Id == organizationId); + UpdateStringList(front.SystemIds, request.SystemIds, request.Replace, world.Systems.Select(system => system.Definition.Id)); + UpdateStringList(front.FleetIds, request.ChildOrganizationIds, request.Replace, player.AssetRegistry.FleetIds); + front.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + case "reserve": + var reserve = player.Reserves.First(entity => entity.Id == organizationId); + UpdateStringList(reserve.AssetIds, request.AssetIds, request.Replace, player.AssetRegistry.ShipIds); + UpdateStringList(reserve.FrontIds, request.FrontIds, request.Replace, player.AssetRegistry.FrontIds); + reserve.UpdatedAtUtc = DateTimeOffset.UtcNow; + break; + default: + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); + } + + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "membership-updated", $"Updated membership for {organizationId}.", "organization", organizationId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpsertDirective(SimulationWorld world, string? directiveId, PlayerDirectiveCommandRequest request) + { + var player = EnsureDomain(world); + var directive = directiveId is null + ? null + : player.Directives.FirstOrDefault(candidate => string.Equals(candidate.Id, directiveId, StringComparison.Ordinal)); + if (directive is null) + { + directive = new PlayerDirectiveRuntime + { + Id = directiveId ?? CreateDomainId("directive", request.Label, player.Directives.Select(candidate => candidate.Id)), + Label = request.Label, + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + player.Directives.Add(directive); + } + + directive.Label = request.Label; + directive.Kind = request.Kind; + directive.ScopeKind = request.ScopeKind; + directive.ScopeId = request.ScopeId; + directive.BehaviorKind = request.BehaviorKind; + directive.UseOrders = request.UseOrders; + directive.StagingOrderKind = request.StagingOrderKind; + directive.TargetEntityId = request.TargetEntityId; + directive.TargetSystemId = request.TargetSystemId; + directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + directive.HomeSystemId = request.HomeSystemId; + directive.HomeStationId = request.HomeStationId; + directive.SourceStationId = request.SourceStationId; + directive.DestinationStationId = request.DestinationStationId; + directive.ItemId = request.ItemId; + directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; + directive.PreferredModuleId = request.PreferredModuleId; + directive.Priority = request.Priority; + directive.Radius = MathF.Max(0f, request.Radius ?? directive.Radius); + directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? directive.WaitSeconds); + directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? directive.MaxSystemRange); + directive.KnownStationsOnly = request.KnownStationsOnly ?? directive.KnownStationsOnly; + directive.PatrolPoints.Clear(); + foreach (var point in request.PatrolPoints ?? []) + { + directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); + } + directive.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + directive.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + directive.PolicyId = request.PolicyId; + directive.AutomationPolicyId = request.AutomationPolicyId; + directive.Notes = request.Notes; + directive.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "directive-upserted", $"Updated directive {directive.Label}.", "directive", directive.Id); + player.UpdatedAtUtc = directive.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime DeleteDirective(SimulationWorld world, string directiveId) + { + var player = EnsureDomain(world); + player.Directives.RemoveAll(directive => directive.Id == directiveId); + foreach (var assignment in player.Assignments.Where(assignment => assignment.DirectiveId == directiveId)) + { + assignment.DirectiveId = null; + } + ReconcileDirectiveScopes(player); + AddDecision(player, "directive-deleted", $"Removed directive {directiveId}.", "directive", directiveId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + return player; + } + + internal PlayerFactionRuntime UpsertPolicy(SimulationWorld world, string? policyId, PlayerPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = policyId is null + ? null + : player.Policies.FirstOrDefault(candidate => string.Equals(candidate.Id, policyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerFactionPolicyRuntime + { + Id = policyId ?? CreateDomainId("policy", request.Label, player.Policies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.Policies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.AllowDelegatedCombat = request.AllowDelegatedCombat; + policy.AllowDelegatedTrade = request.AllowDelegatedTrade; + policy.ReserveCreditsRatio = Math.Clamp(request.ReserveCreditsRatio, 0f, 1f); + policy.ReserveMilitaryRatio = Math.Clamp(request.ReserveMilitaryRatio, 0f, 1f); + if (request.TradeAccessPolicy is not null) + { + policy.TradeAccessPolicy = request.TradeAccessPolicy; + } + if (request.DockingAccessPolicy is not null) + { + policy.DockingAccessPolicy = request.DockingAccessPolicy; + } + if (request.ConstructionAccessPolicy is not null) + { + policy.ConstructionAccessPolicy = request.ConstructionAccessPolicy; + } + if (request.OperationalRangePolicy is not null) + { + policy.OperationalRangePolicy = request.OperationalRangePolicy; + } + if (request.CombatEngagementPolicy is not null) + { + policy.CombatEngagementPolicy = request.CombatEngagementPolicy; + } + if (request.AvoidHostileSystems.HasValue) + { + policy.AvoidHostileSystems = request.AvoidHostileSystems.Value; + } + if (request.FleeHullRatio.HasValue) + { + policy.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); + } + if (request.BlacklistedSystemIds is not null) + { + policy.BlacklistedSystemIds.Clear(); + foreach (var systemId in request.BlacklistedSystemIds) + { + policy.BlacklistedSystemIds.Add(systemId); + } + } + policy.Notes = request.Notes; + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var policySet = EnsurePolicySet(world, player, policy, request.PolicySetId); + ApplyPolicySetRequest(policySet, request); + policy.PolicySetId = policySet.Id; + + AddDecision(player, "policy-upserted", $"Updated policy {policy.Label}.", "policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertAutomationPolicy(SimulationWorld world, string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = automationPolicyId is null + ? null + : player.AutomationPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, automationPolicyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerAutomationPolicyRuntime + { + Id = automationPolicyId ?? CreateDomainId("automation", request.Label, player.AutomationPolicies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.AutomationPolicies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.Enabled = request.Enabled; + policy.BehaviorKind = request.BehaviorKind; + policy.UseOrders = request.UseOrders; + policy.StagingOrderKind = request.StagingOrderKind; + policy.MaxSystemRange = Math.Max(0, request.MaxSystemRange); + policy.KnownStationsOnly = request.KnownStationsOnly; + policy.Radius = MathF.Max(0f, request.Radius); + policy.WaitSeconds = MathF.Max(0f, request.WaitSeconds); + policy.PreferredItemId = request.PreferredItemId; + policy.Notes = request.Notes; + policy.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + policy.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "automation-upserted", $"Updated automation policy {policy.Label}.", "automation-policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertReinforcementPolicy(SimulationWorld world, string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) + { + var player = EnsureDomain(world); + var policy = reinforcementPolicyId is null + ? null + : player.ReinforcementPolicies.FirstOrDefault(candidate => string.Equals(candidate.Id, reinforcementPolicyId, StringComparison.Ordinal)); + if (policy is null) + { + policy = new PlayerReinforcementPolicyRuntime + { + Id = reinforcementPolicyId ?? CreateDomainId("reinforcement", request.Label, player.ReinforcementPolicies.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.ReinforcementPolicies.Add(policy); + } + + policy.Label = request.Label; + policy.ScopeKind = request.ScopeKind; + policy.ScopeId = request.ScopeId; + policy.ShipKind = request.ShipKind; + policy.DesiredAssetCount = Math.Max(0, request.DesiredAssetCount); + policy.MinimumReserveCount = Math.Max(0, request.MinimumReserveCount); + policy.AutoTransferReserves = request.AutoTransferReserves; + policy.AutoQueueProduction = request.AutoQueueProduction; + policy.SourceReserveId = request.SourceReserveId; + policy.TargetFrontId = request.TargetFrontId; + policy.Notes = request.Notes; + policy.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "reinforcement-upserted", $"Updated reinforcement policy {policy.Label}.", "reinforcement-policy", policy.Id); + player.UpdatedAtUtc = policy.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertProductionProgram(SimulationWorld world, string? productionProgramId, PlayerProductionProgramCommandRequest request) + { + var player = EnsureDomain(world); + var program = productionProgramId is null + ? null + : player.ProductionPrograms.FirstOrDefault(candidate => string.Equals(candidate.Id, productionProgramId, StringComparison.Ordinal)); + if (program is null) + { + program = new PlayerProductionProgramRuntime + { + Id = productionProgramId ?? CreateDomainId("production", request.Label, player.ProductionPrograms.Select(candidate => candidate.Id)), + Label = request.Label, + }; + player.ProductionPrograms.Add(program); + } + + program.Label = request.Label; + program.Kind = request.Kind; + program.TargetShipKind = request.TargetShipKind; + program.TargetModuleId = request.TargetModuleId; + program.TargetItemId = request.TargetItemId; + program.TargetCount = Math.Max(0, request.TargetCount); + program.StationGroupId = request.StationGroupId; + program.ReinforcementPolicyId = request.ReinforcementPolicyId; + program.Notes = request.Notes; + program.UpdatedAtUtc = DateTimeOffset.UtcNow; + + AddDecision(player, "production-upserted", $"Updated production program {program.Label}.", "production-program", program.Id); + player.UpdatedAtUtc = program.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpsertAssignment(SimulationWorld world, string assetId, PlayerAssetAssignmentCommandRequest request) + { + var player = EnsureDomain(world); + var assignment = player.Assignments.FirstOrDefault(candidate => + string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal) && + string.Equals(candidate.AssetKind, request.AssetKind, StringComparison.Ordinal)); + if (assignment is null) + { + assignment = new PlayerAssignmentRuntime + { + Id = $"assignment-{request.AssetKind}-{assetId}", + AssetKind = request.AssetKind, + AssetId = assetId, + }; + player.Assignments.Add(assignment); + } + + if (request.ClearConflicts) + { + RemoveAssetFromOrganizations(player, request.AssetKind, assetId); + } + + if (request.FleetId is not null) + { + AddAssetToFleet(player, request.FleetId, assetId); + } + if (request.TaskForceId is not null) + { + AddAssetToTaskForce(player, request.TaskForceId, assetId); + } + if (request.StationGroupId is not null) + { + AddAssetToStationGroup(player, request.StationGroupId, assetId); + } + if (request.ReserveId is not null) + { + AddAssetToReserve(player, request.ReserveId, assetId); + } + + assignment.FleetId = request.FleetId; + assignment.TaskForceId = request.TaskForceId; + assignment.StationGroupId = request.StationGroupId; + assignment.EconomicRegionId = request.EconomicRegionId ?? assignment.EconomicRegionId; + assignment.FrontId = request.FrontId ?? assignment.FrontId; + assignment.ReserveId = request.ReserveId; + assignment.DirectiveId = request.DirectiveId; + assignment.PolicyId = request.PolicyId; + assignment.AutomationPolicyId = request.AutomationPolicyId; + assignment.Role = request.Role; + assignment.Status = "active"; + assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; + + ReconcileOrganizationAssignments(world, player); + ReconcileDirectiveScopes(player); + AddDecision(player, "assignment-upserted", $"Assigned {request.AssetKind} {assetId}.", request.AssetKind, assetId); + player.UpdatedAtUtc = assignment.UpdatedAtUtc; + return player; + } + + internal PlayerFactionRuntime UpdateStrategicIntent(SimulationWorld world, PlayerStrategicIntentCommandRequest request) + { + var player = EnsureDomain(world); + player.StrategicIntent.StrategicPosture = request.StrategicPosture; + player.StrategicIntent.EconomicPosture = request.EconomicPosture; + player.StrategicIntent.MilitaryPosture = request.MilitaryPosture; + player.StrategicIntent.LogisticsPosture = request.LogisticsPosture; + player.StrategicIntent.DesiredReserveRatio = Math.Clamp(request.DesiredReserveRatio, 0f, 1f); + player.StrategicIntent.AllowDelegatedCombatAutomation = request.AllowDelegatedCombatAutomation; + player.StrategicIntent.AllowDelegatedEconomicAutomation = request.AllowDelegatedEconomicAutomation; + player.StrategicIntent.Notes = request.Notes; + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + AddDecision(player, "strategic-intent-updated", "Updated player strategic intent.", "player-faction", player.Id); + return player; + } + + internal ShipRuntime? EnqueueDirectShipOrder(SimulationWorld world, string shipId, ShipOrderCommandRequest request) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + if (ship.OrderQueue.Count >= 8) + { + throw new InvalidOperationException("Order queue is full."); + } + + ship.OrderQueue.Add(new ShipOrderRuntime + { + Id = $"order-{ship.Id}-{Guid.NewGuid():N}", + Kind = request.Kind, + Priority = request.Priority, + InterruptCurrentPlan = request.InterruptCurrentPlan, + Label = request.Label, + TargetEntityId = request.TargetEntityId, + TargetSystemId = request.TargetSystemId, + TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z), + SourceStationId = request.SourceStationId, + DestinationStationId = request.DestinationStationId, + ItemId = request.ItemId, + NodeId = request.NodeId, + ConstructionSiteId = request.ConstructionSiteId, + ModuleId = request.ModuleId, + WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, request.Radius ?? 0f), + MaxSystemRange = request.MaxSystemRange, + KnownStationsOnly = request.KnownStationsOnly ?? false, + }); + + AddDecision(player, "ship-order-enqueued", $"Queued {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + ship.ControlSourceKind = "player-order"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = request.Label ?? request.Kind; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-enqueued"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + internal ShipRuntime? RemoveDirectShipOrder(SimulationWorld world, string shipId, string orderId) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var removed = ship.OrderQueue.RemoveAll(order => order.Id == orderId); + if (removed > 0) + { + AddDecision(player, "ship-order-removed", $"Removed order {orderId} from {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + ship.ControlSourceKind = ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + ? "player-order" + : "player-manual"; + ship.ControlSourceId = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + ship.ControlReason = ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Label ?? order.Kind) + .FirstOrDefault() + ?? "manual-player-control"; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-order-removed"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + internal ShipRuntime? ConfigureDirectShipBehavior(SimulationWorld world, string shipId, ShipDefaultBehaviorCommandRequest request) + { + var player = EnsureDomain(world); + if (!player.AssetRegistry.ShipIds.Contains(shipId)) + { + return null; + } + + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); + if (ship is null) + { + return null; + } + + var directiveId = $"player-directive-ship-{shipId}"; + var directive = player.Directives.FirstOrDefault(candidate => candidate.Id == directiveId); + if (directive is null) + { + directive = new PlayerDirectiveRuntime + { + Id = directiveId, + Label = $"Direct control {ship.Definition.Label}", + ScopeKind = "ship", + ScopeId = shipId, + Kind = "direct-control", + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + player.Directives.Add(directive); + } + + directive.Label = $"Direct control {ship.Definition.Label}"; + directive.Kind = "direct-control"; + directive.ScopeKind = "ship"; + directive.ScopeId = shipId; + directive.BehaviorKind = request.Kind; + directive.UseOrders = false; + directive.StagingOrderKind = null; + directive.TargetEntityId = request.TargetEntityId; + directive.TargetSystemId = request.AreaSystemId; + directive.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); + directive.HomeSystemId = request.HomeSystemId ?? ship.SystemId; + directive.HomeStationId = request.HomeStationId; + directive.SourceStationId = request.HomeStationId; + directive.DestinationStationId = null; + directive.ItemId = request.PreferredItemId; + directive.PreferredNodeId = request.PreferredNodeId; + directive.PreferredConstructionSiteId = request.PreferredConstructionSiteId; + directive.PreferredModuleId = request.PreferredModuleId; + directive.Priority = 100; + directive.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius); + directive.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds); + directive.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange); + directive.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly; + directive.PatrolPoints.Clear(); + foreach (var point in request.PatrolPoints ?? []) + { + directive.PatrolPoints.Add(new Vector3(point.X, point.Y, point.Z)); + } + directive.RepeatOrders.Clear(); + foreach (var template in request.RepeatOrders ?? []) + { + directive.RepeatOrders.Add(new ShipOrderTemplateRuntime + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = MathF.Max(0f, template.WaitSeconds ?? 0f), + Radius = MathF.Max(0f, template.Radius ?? 0f), + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly ?? false, + }); + } + directive.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var assignment = GetOrCreateAssignment(player, "ship", shipId); + assignment.DirectiveId = directive.Id; + assignment.Status = "active"; + assignment.UpdatedAtUtc = directive.UpdatedAtUtc; + + ApplyBehavior(ship.DefaultBehavior, BuildDirectiveBehavior(ship, directive, null)); + ship.ControlSourceKind = "player-directive"; + ship.ControlSourceId = directive.Id; + ship.ControlReason = directive.Label; + AddDecision(player, "ship-behavior-configured", $"Configured {request.Kind} for {ship.Definition.Label}.", "ship", shipId); + player.UpdatedAtUtc = directive.UpdatedAtUtc; + ship.NeedsReplan = true; + ship.LastReplanReason = "player-behavior-configured"; + ship.LastDeltaSignature = string.Empty; + return ship; + } + + private static void EnsureBaseStructures(SimulationWorld world, PlayerFactionRuntime player) + { + if (player.Policies.Count == 0) + { + var sovereign = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, player.SovereignFactionId, StringComparison.Ordinal)); + player.Policies.Add(new PlayerFactionPolicyRuntime + { + Id = "player-core-policy", + Label = "Core Empire Policy", + PolicySetId = sovereign?.DefaultPolicySetId, + }); + + if (sovereign?.DefaultPolicySetId is { } defaultPolicySetId + && world.Policies.FirstOrDefault(policy => policy.Id == defaultPolicySetId) is { } defaultPolicySet) + { + CopyPolicySetToPlayerPolicy(defaultPolicySet, player.Policies[0]); + } + } + + if (player.AutomationPolicies.Count == 0) + { + player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime + { + Id = "player-core-automation", + Label = "Core Automation", + BehaviorKind = "idle", + }); + } + + if (player.Reserves.Count == 0) + { + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = "player-core-reserve", + Label = "Strategic Reserve", + ReserveKind = "military", + }); + player.AssetRegistry.ReserveIds.Add("player-core-reserve"); + } + } + + private static void SyncRegistry(SimulationWorld world, PlayerFactionRuntime player) + { + SyncSet(player.AssetRegistry.ShipIds, world.Ships.Where(ship => ship.FactionId == player.SovereignFactionId).Select(ship => ship.Id)); + SyncSet(player.AssetRegistry.StationIds, world.Stations.Where(station => station.FactionId == player.SovereignFactionId).Select(station => station.Id)); + SyncSet(player.AssetRegistry.CommanderIds, world.Commanders.Where(commander => commander.FactionId == player.SovereignFactionId).Select(commander => commander.Id)); + SyncSet(player.AssetRegistry.ClaimIds, world.Claims.Where(claim => claim.FactionId == player.SovereignFactionId).Select(claim => claim.Id)); + SyncSet(player.AssetRegistry.ConstructionSiteIds, world.ConstructionSites.Where(site => site.FactionId == player.SovereignFactionId).Select(site => site.Id)); + SyncSet(player.AssetRegistry.PolicySetIds, world.Policies.Where(policy => policy.OwnerId == player.SovereignFactionId || player.Policies.Any(entry => entry.PolicySetId == policy.Id)).Select(policy => policy.Id)); + SyncSet(player.AssetRegistry.MarketOrderIds, world.MarketOrders.Where(order => order.FactionId == player.SovereignFactionId).Select(order => order.Id)); + SyncSet(player.AssetRegistry.FleetIds, player.Fleets.Select(fleet => fleet.Id)); + SyncSet(player.AssetRegistry.TaskForceIds, player.TaskForces.Select(taskForce => taskForce.Id)); + SyncSet(player.AssetRegistry.StationGroupIds, player.StationGroups.Select(group => group.Id)); + SyncSet(player.AssetRegistry.EconomicRegionIds, player.EconomicRegions.Select(region => region.Id)); + SyncSet(player.AssetRegistry.FrontIds, player.Fronts.Select(front => front.Id)); + SyncSet(player.AssetRegistry.ReserveIds, player.Reserves.Select(reserve => reserve.Id)); + } + + private static void PrunePlayerState(SimulationWorld world, PlayerFactionRuntime player) + { + var shipIds = player.AssetRegistry.ShipIds; + var stationIds = player.AssetRegistry.StationIds; + var frontIds = player.AssetRegistry.FrontIds; + var fleetIds = player.AssetRegistry.FleetIds; + var reserveIds = player.AssetRegistry.ReserveIds; + var taskForceIds = player.AssetRegistry.TaskForceIds; + var stationGroupIds = player.AssetRegistry.StationGroupIds; + var regionIds = player.AssetRegistry.EconomicRegionIds; + var directiveIds = player.Directives.Select(directive => directive.Id).ToHashSet(StringComparer.Ordinal); + var policyIds = player.Policies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); + var automationIds = player.AutomationPolicies.Select(policy => policy.Id).ToHashSet(StringComparer.Ordinal); + + foreach (var fleet in player.Fleets) + { + fleet.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + fleet.TaskForceIds.RemoveAll(taskForceId => !taskForceIds.Contains(taskForceId)); + fleet.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var taskForce in player.TaskForces) + { + taskForce.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + taskForce.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + if (taskForce.FleetId is not null && !fleetIds.Contains(taskForce.FleetId)) + { + taskForce.FleetId = null; + } + } + + foreach (var group in player.StationGroups) + { + group.StationIds.RemoveAll(stationId => !stationIds.Contains(stationId)); + group.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + if (group.EconomicRegionId is not null && !regionIds.Contains(group.EconomicRegionId)) + { + group.EconomicRegionId = null; + } + } + + foreach (var region in player.EconomicRegions) + { + region.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); + region.StationGroupIds.RemoveAll(groupId => !stationGroupIds.Contains(groupId)); + region.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var front in player.Fronts) + { + front.SystemIds.RemoveAll(systemId => !world.Systems.Any(system => string.Equals(system.Definition.Id, systemId, StringComparison.Ordinal))); + front.FleetIds.RemoveAll(fleetId => !fleetIds.Contains(fleetId)); + front.ReserveIds.RemoveAll(reserveId => !reserveIds.Contains(reserveId)); + front.DirectiveIds.RemoveAll(directiveId => !directiveIds.Contains(directiveId)); + } + + foreach (var reserve in player.Reserves) + { + reserve.AssetIds.RemoveAll(assetId => !shipIds.Contains(assetId)); + reserve.FrontIds.RemoveAll(frontId => !frontIds.Contains(frontId)); + } + + player.Assignments.RemoveAll(assignment => + (assignment.AssetKind == "ship" && !shipIds.Contains(assignment.AssetId)) || + (assignment.AssetKind == "station" && !stationIds.Contains(assignment.AssetId))); + + foreach (var assignment in player.Assignments) + { + if (assignment.FleetId is not null && !fleetIds.Contains(assignment.FleetId)) + { + assignment.FleetId = null; + } + if (assignment.TaskForceId is not null && !taskForceIds.Contains(assignment.TaskForceId)) + { + assignment.TaskForceId = null; + } + if (assignment.StationGroupId is not null && !stationGroupIds.Contains(assignment.StationGroupId)) + { + assignment.StationGroupId = null; + } + if (assignment.EconomicRegionId is not null && !regionIds.Contains(assignment.EconomicRegionId)) + { + assignment.EconomicRegionId = null; + } + if (assignment.FrontId is not null && !frontIds.Contains(assignment.FrontId)) + { + assignment.FrontId = null; + } + if (assignment.ReserveId is not null && !reserveIds.Contains(assignment.ReserveId)) + { + assignment.ReserveId = null; + } + if (assignment.DirectiveId is not null && !directiveIds.Contains(assignment.DirectiveId)) + { + assignment.DirectiveId = null; + } + if (assignment.PolicyId is not null && !policyIds.Contains(assignment.PolicyId)) + { + assignment.PolicyId = null; + } + if (assignment.AutomationPolicyId is not null && !automationIds.Contains(assignment.AutomationPolicyId)) + { + assignment.AutomationPolicyId = null; + } + } + } + + private static void ApplyPolicies(SimulationWorld world, PlayerFactionRuntime player) + { + foreach (var policy in player.Policies) + { + if (policy.PolicySetId is null) + { + continue; + } + + if (world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } policySet) + { + policySet.TradeAccessPolicy = policy.TradeAccessPolicy; + policySet.DockingAccessPolicy = policy.DockingAccessPolicy; + policySet.ConstructionAccessPolicy = policy.ConstructionAccessPolicy; + policySet.OperationalRangePolicy = policy.OperationalRangePolicy; + policySet.CombatEngagementPolicy = policy.CombatEngagementPolicy; + policySet.FleeHullRatio = Math.Clamp(policy.FleeHullRatio, 0.05f, 0.95f); + policySet.AvoidHostileSystems = policy.AvoidHostileSystems; + + policySet.BlacklistedSystemIds.Clear(); + foreach (var systemId in policy.BlacklistedSystemIds) + { + policySet.BlacklistedSystemIds.Add(systemId); + } + } + } + } + + private static void ApplyAssignmentsAndDirectives(SimulationWorld world, PlayerFactionRuntime player, ICollection events) + { + var factionCommander = world.Commanders.FirstOrDefault(commander => + commander.Kind == CommanderKind.Faction && + string.Equals(commander.FactionId, player.SovereignFactionId, StringComparison.Ordinal)); + if (factionCommander is null) + { + return; + } + + var fleetCommanders = EnsureFleetCommanders(world, player, factionCommander); + var taskForceCommanders = EnsureTaskForceCommanders(world, player, factionCommander, fleetCommanders); + var assignmentsByAsset = player.Assignments + .Where(assignment => assignment.Status == "active") + .GroupBy(assignment => $"{assignment.AssetKind}:{assignment.AssetId}", StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.OrderByDescending(item => item.UpdatedAtUtc).First(), StringComparer.Ordinal); + + foreach (var ship in world.Ships.Where(candidate => candidate.FactionId == player.SovereignFactionId)) + { + if (ship.CommanderId is null) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); + if (commander is null) + { + continue; + } + + var assignment = ResolveAssignment(assignmentsByAsset, "ship", ship.Id); + var directive = ResolveDirective(player, assignment, "ship", ship.Id); + var automation = ResolveAutomation(player, assignment, directive, "ship", ship.Id); + var policy = ResolvePolicy(player, assignment, directive, "ship", ship.Id); + + commander.ParentCommanderId = ResolveParentCommanderId(factionCommander, assignment, fleetCommanders, taskForceCommanders); + commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; + ship.PolicySetId = commander.PolicySetId; + var changed = ApplyDirectiveToShip(commander, ship, directive, automation, assignment); + if (changed && directive is not null) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "player-directive", $"{ship.Definition.Label} aligned to player directive {directive.Label}.", DateTimeOffset.UtcNow, "player", "universe", ship.Id)); + } + } + + foreach (var station in world.Stations.Where(candidate => candidate.FactionId == player.SovereignFactionId)) + { + if (station.CommanderId is null) + { + continue; + } + + var commander = world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); + if (commander is null) + { + continue; + } + + var assignment = ResolveAssignment(assignmentsByAsset, "station", station.Id); + var directive = ResolveDirective(player, assignment, "station", station.Id); + var policy = ResolvePolicy(player, assignment, directive, "station", station.Id); + commander.PolicySetId = policy?.PolicySetId ?? factionCommander.PolicySetId; + station.PolicySetId = commander.PolicySetId; + commander.Assignment = directive is null && assignment is null + ? null + : new CommanderAssignmentRuntime + { + ObjectiveId = directive?.Id ?? assignment?.StationGroupId ?? $"player-station-{station.Id}", + Kind = directive?.Kind ?? "player-station-control", + BehaviorKind = directive?.BehaviorKind ?? assignment?.Role ?? "station-control", + Status = directive?.Status ?? assignment?.Status ?? "active", + Priority = directive?.Priority ?? 40f, + HomeSystemId = directive?.HomeSystemId ?? station.SystemId, + HomeStationId = directive?.HomeStationId ?? station.Id, + TargetSystemId = directive?.TargetSystemId, + TargetEntityId = directive?.TargetEntityId, + TargetPosition = directive?.TargetPosition, + ItemId = directive?.ItemId, + Notes = directive?.Notes ?? assignment?.Role, + UpdatedAtUtc = directive?.UpdatedAtUtc ?? assignment?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, + }; + } + } + + private static Dictionary EnsureFleetCommanders(SimulationWorld world, PlayerFactionRuntime player, CommanderRuntime factionCommander) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var fleet in player.Fleets) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.Fleet && + candidate.FactionId == player.SovereignFactionId && + string.Equals(candidate.ControlledEntityId, fleet.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-player-fleet-{fleet.Id}", + Kind = CommanderKind.Fleet, + FactionId = player.SovereignFactionId, + ControlledEntityId = fleet.Id, + Doctrine = "player-fleet-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 5, Coordination = 4, Strategy = 4 }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = factionCommander.Id; + commander.PolicySetId = ResolvePolicySetId(world, player, fleet.PolicyId) ?? factionCommander.PolicySetId; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = fleet.Id, + Kind = "player-fleet", + BehaviorKind = fleet.Role, + Status = fleet.Status, + Priority = 80f, + HomeSystemId = fleet.HomeSystemId, + HomeStationId = fleet.HomeStationId, + Notes = fleet.Label, + UpdatedAtUtc = fleet.UpdatedAtUtc, + }; + fleet.CommanderId = commander.Id; + map[fleet.Id] = commander; + } + return map; + } + + private static Dictionary EnsureTaskForceCommanders( + SimulationWorld world, + PlayerFactionRuntime player, + CommanderRuntime factionCommander, + IReadOnlyDictionary fleetCommanders) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var taskForce in player.TaskForces) + { + var commander = world.Commanders.FirstOrDefault(candidate => + candidate.Kind == CommanderKind.TaskGroup && + candidate.FactionId == player.SovereignFactionId && + string.Equals(candidate.ControlledEntityId, taskForce.Id, StringComparison.Ordinal)); + if (commander is null) + { + commander = new CommanderRuntime + { + Id = $"commander-player-task-force-{taskForce.Id}", + Kind = CommanderKind.TaskGroup, + FactionId = player.SovereignFactionId, + ControlledEntityId = taskForce.Id, + Doctrine = "player-task-force-control", + Skills = new CommanderSkillProfileRuntime { Leadership = 4, Coordination = 4, Strategy = 4 }, + }; + world.Commanders.Add(commander); + } + + commander.ParentCommanderId = taskForce.FleetId is not null && fleetCommanders.TryGetValue(taskForce.FleetId, out var fleetCommander) + ? fleetCommander.Id + : factionCommander.Id; + commander.PolicySetId = ResolvePolicySetId(world, player, taskForce.PolicyId) ?? factionCommander.PolicySetId; + commander.Assignment = new CommanderAssignmentRuntime + { + ObjectiveId = taskForce.Id, + Kind = "player-task-force", + BehaviorKind = taskForce.Role, + Status = taskForce.Status, + Priority = 75f, + Notes = taskForce.Label, + UpdatedAtUtc = taskForce.UpdatedAtUtc, + }; + taskForce.CommanderId = commander.Id; + map[taskForce.Id] = commander; + } + return map; + } + + private static string ResolveParentCommanderId( + CommanderRuntime factionCommander, + PlayerAssignmentRuntime? assignment, + IReadOnlyDictionary fleetCommanders, + IReadOnlyDictionary taskForceCommanders) + { + if (assignment?.TaskForceId is not null && taskForceCommanders.TryGetValue(assignment.TaskForceId, out var taskForceCommander)) + { + return taskForceCommander.Id; + } + + if (assignment?.FleetId is not null && fleetCommanders.TryGetValue(assignment.FleetId, out var fleetCommander)) + { + return fleetCommander.Id; + } + + return factionCommander.Id; + } + + private static PlayerAssignmentRuntime? ResolveAssignment( + IReadOnlyDictionary assignmentsByAsset, + string assetKind, + string assetId) => + assignmentsByAsset.GetValueOrDefault($"{assetKind}:{assetId}"); + + private static PlayerDirectiveRuntime? ResolveDirective(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, string assetKind, string assetId) + { + if (assignment?.DirectiveId is not null) + { + return player.Directives.FirstOrDefault(directive => directive.Id == assignment.DirectiveId); + } + + return SelectScopedDirective( + player.Directives.Where(directive => directive.Status == "active"), + player, + assignment, + assetKind, + assetId); + } + + private static PlayerAutomationPolicyRuntime? ResolveAutomation(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) + { + var automationId = assignment?.AutomationPolicyId ?? directive?.AutomationPolicyId; + if (automationId is not null) + { + return player.AutomationPolicies.FirstOrDefault(policy => policy.Id == automationId); + } + + return SelectScopedAutomationPolicy(player, assignment, assetKind, assetId) + ?? player.AutomationPolicies.FirstOrDefault(policy => policy.Id == "player-core-automation"); + } + + private static PlayerFactionPolicyRuntime? ResolvePolicy(PlayerFactionRuntime player, PlayerAssignmentRuntime? assignment, PlayerDirectiveRuntime? directive, string assetKind, string assetId) + { + var policyId = assignment?.PolicyId ?? directive?.PolicyId; + if (policyId is not null) + { + return player.Policies.FirstOrDefault(policy => policy.Id == policyId); + } + + return SelectScopedFactionPolicy(player, assignment, assetKind, assetId) + ?? player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); + } + + private static bool ApplyDirectiveToShip( + CommanderRuntime commander, + ShipRuntime ship, + PlayerDirectiveRuntime? directive, + PlayerAutomationPolicyRuntime? automation, + PlayerAssignmentRuntime? assignment) + { + var desiredAssignment = BuildDirectiveAssignment(ship, directive, automation, assignment); + var desiredBehavior = BuildDirectiveBehavior(ship, directive, automation); + var hasBehaviorSource = directive is not null || automation is not null; + var desiredControlSourceKind = directive is not null + ? "player-directive" + : automation is not null + ? "player-automation" + : ship.OrderQueue.Any(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + ? "player-order" + : "player-manual"; + var desiredControlSourceId = directive?.Id + ?? automation?.Id + ?? ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Id) + .FirstOrDefault(); + var desiredControlReason = directive?.Label + ?? automation?.Label + ?? ship.OrderQueue + .Where(order => !order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => order.Label ?? order.Kind) + .FirstOrDefault() + ?? (hasBehaviorSource ? "delegated-player-control" : "manual-player-control"); + + var assignmentChanged = !AssignmentsEqual(commander.Assignment, desiredAssignment); + var behaviorChanged = hasBehaviorSource && !DefaultBehaviorsEqual(ship.DefaultBehavior, desiredBehavior!); + var ordersChanged = ReconcileDirectiveOrders(ship, directive, automation); + var controlChanged = + !string.Equals(ship.ControlSourceKind, desiredControlSourceKind, StringComparison.Ordinal) + || !string.Equals(ship.ControlSourceId, desiredControlSourceId, StringComparison.Ordinal) + || !string.Equals(ship.ControlReason, desiredControlReason, StringComparison.Ordinal); + + if (assignmentChanged) + { + commander.Assignment = desiredAssignment; + } + + if (behaviorChanged && desiredBehavior is not null) + { + ApplyBehavior(ship.DefaultBehavior, desiredBehavior); + } + + if (directive is null && automation is null) + { + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + var surfaceChanged = assignmentChanged || ordersChanged || controlChanged; + if (surfaceChanged) + { + ship.LastDeltaSignature = string.Empty; + } + + if (assignmentChanged || ordersChanged) + { + ship.NeedsReplan = true; + ship.LastReplanReason = assignmentChanged + ? "player-assignment-updated" + : ordersChanged + ? "player-order-updated" + : "player-control-updated"; + } + + return surfaceChanged; + } + + ship.ControlSourceKind = desiredControlSourceKind; + ship.ControlSourceId = desiredControlSourceId; + ship.ControlReason = desiredControlReason; + var changed = assignmentChanged || behaviorChanged || ordersChanged || controlChanged; + if (changed) + { + ship.LastDeltaSignature = string.Empty; + } + + if (assignmentChanged || behaviorChanged || ordersChanged) + { + ship.NeedsReplan = true; + ship.LastReplanReason = assignmentChanged + ? "player-assignment-updated" + : behaviorChanged + ? "player-behavior-updated" + : ordersChanged + ? "player-order-updated" + : "player-control-updated"; + } + + return changed; + } + + private static DefaultBehaviorRuntime BuildDirectiveBehavior(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) + { + return new DefaultBehaviorRuntime + { + Kind = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind, + HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId ?? ship.SystemId, + HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, + AreaSystemId = directive?.TargetSystemId ?? directive?.HomeSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId, + TargetEntityId = directive?.TargetEntityId, + PreferredItemId = directive?.ItemId ?? automation?.PreferredItemId ?? ship.DefaultBehavior.PreferredItemId, + PreferredNodeId = directive?.PreferredNodeId ?? ship.DefaultBehavior.PreferredNodeId, + PreferredConstructionSiteId = directive?.PreferredConstructionSiteId ?? ship.DefaultBehavior.PreferredConstructionSiteId, + PreferredModuleId = directive?.PreferredModuleId ?? ship.DefaultBehavior.PreferredModuleId, + TargetPosition = directive?.TargetPosition, + WaitSeconds = directive?.WaitSeconds ?? automation?.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds, + Radius = directive?.Radius ?? automation?.Radius ?? ship.DefaultBehavior.Radius, + MaxSystemRange = directive?.MaxSystemRange ?? automation?.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange, + KnownStationsOnly = directive?.KnownStationsOnly ?? automation?.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly, + PatrolPoints = directive?.PatrolPoints.Select(point => point).ToList() ?? ship.DefaultBehavior.PatrolPoints.Select(point => point).ToList(), + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + RepeatOrders = directive?.RepeatOrders.Select(CloneTemplate).ToList() + ?? automation?.RepeatOrders.Select(CloneTemplate).ToList() + ?? ship.DefaultBehavior.RepeatOrders.Select(CloneTemplate).ToList(), + RepeatIndex = ship.DefaultBehavior.RepeatIndex, + }; + } + + private static bool ReconcileDirectiveOrders(ShipRuntime ship, PlayerDirectiveRuntime? directive, PlayerAutomationPolicyRuntime? automation) + { + var aiOrderId = directive is null ? null : $"player-order-{directive.Id}"; + var changed = ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("player-order-", StringComparison.Ordinal) && order.Id != aiOrderId) > 0; + + var useOrders = directive?.UseOrders ?? automation?.UseOrders ?? false; + if (!useOrders || directive is null || string.IsNullOrWhiteSpace(directive.StagingOrderKind)) + { + return changed; + } + + var desiredOrder = new ShipOrderRuntime + { + Id = aiOrderId!, + Kind = directive.StagingOrderKind!, + Priority = Math.Max(0, directive.Priority), + InterruptCurrentPlan = true, + Label = directive.Label, + TargetEntityId = directive.TargetEntityId, + TargetSystemId = directive.TargetSystemId, + TargetPosition = directive.TargetPosition, + SourceStationId = directive.SourceStationId ?? directive.HomeStationId, + DestinationStationId = directive.DestinationStationId, + ItemId = directive.ItemId, + NodeId = directive.PreferredNodeId, + ConstructionSiteId = directive.PreferredConstructionSiteId, + ModuleId = directive.PreferredModuleId, + WaitSeconds = directive.WaitSeconds, + Radius = directive.Radius, + MaxSystemRange = directive.MaxSystemRange, + KnownStationsOnly = directive.KnownStationsOnly, + }; + + var existing = ship.OrderQueue.FirstOrDefault(order => order.Id == aiOrderId); + if (existing is null) + { + ship.OrderQueue.Add(desiredOrder); + return true; + } + + if (!ShipOrdersEqual(existing, desiredOrder)) + { + ship.OrderQueue.Remove(existing); + ship.OrderQueue.Add(desiredOrder); + return true; + } + + return changed; + } + + private static CommanderAssignmentRuntime? BuildDirectiveAssignment( + ShipRuntime ship, + PlayerDirectiveRuntime? directive, + PlayerAutomationPolicyRuntime? automation, + PlayerAssignmentRuntime? assignment) + { + if (directive is null && automation is null) + { + return null; + } + + var behavior = directive?.BehaviorKind ?? automation?.BehaviorKind ?? ship.DefaultBehavior.Kind; + return new CommanderAssignmentRuntime + { + ObjectiveId = directive?.Id ?? assignment?.DirectiveId ?? $"automation-{ship.Id}", + Kind = directive?.Kind ?? "player-automation", + BehaviorKind = behavior, + Status = directive?.Status ?? "active", + Priority = directive?.Priority ?? 50f, + HomeSystemId = directive?.HomeSystemId ?? ship.DefaultBehavior.HomeSystemId, + HomeStationId = directive?.HomeStationId ?? ship.DefaultBehavior.HomeStationId, + TargetSystemId = directive?.TargetSystemId, + TargetEntityId = directive?.TargetEntityId, + TargetPosition = directive?.TargetPosition, + ItemId = directive?.ItemId, + Notes = directive?.Notes ?? automation?.Notes, + UpdatedAtUtc = directive?.UpdatedAtUtc ?? automation?.UpdatedAtUtc ?? DateTimeOffset.UtcNow, + }; + } + + private static void ApplyBehavior(DefaultBehaviorRuntime target, DefaultBehaviorRuntime source) + { + target.Kind = source.Kind; + target.HomeSystemId = source.HomeSystemId; + target.HomeStationId = source.HomeStationId; + target.AreaSystemId = source.AreaSystemId; + target.TargetEntityId = source.TargetEntityId; + target.PreferredItemId = source.PreferredItemId; + target.PreferredNodeId = source.PreferredNodeId; + target.PreferredConstructionSiteId = source.PreferredConstructionSiteId; + target.PreferredModuleId = source.PreferredModuleId; + target.TargetPosition = source.TargetPosition; + target.WaitSeconds = source.WaitSeconds; + target.Radius = source.Radius; + target.MaxSystemRange = source.MaxSystemRange; + target.KnownStationsOnly = source.KnownStationsOnly; + target.PatrolPoints = source.PatrolPoints.Select(point => point).ToList(); + target.PatrolIndex = source.PatrolIndex; + target.RepeatOrders = source.RepeatOrders.Select(CloneTemplate).ToList(); + target.RepeatIndex = source.RepeatIndex; + } + + private static bool DefaultBehaviorsEqual(DefaultBehaviorRuntime left, DefaultBehaviorRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.AreaSystemId, right.AreaSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.PreferredItemId, right.PreferredItemId, StringComparison.Ordinal) + && string.Equals(left.PreferredNodeId, right.PreferredNodeId, StringComparison.Ordinal) + && string.Equals(left.PreferredConstructionSiteId, right.PreferredConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.PreferredModuleId, right.PreferredModuleId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly + && left.PatrolPoints.SequenceEqual(right.PatrolPoints) + && left.RepeatOrders.Count == right.RepeatOrders.Count + && left.RepeatOrders.Zip(right.RepeatOrders, ShipOrderTemplatesEqual).All(equal => equal); + + private static bool ShipOrderTemplatesEqual(ShipOrderTemplateRuntime left, ShipOrderTemplateRuntime right) => + string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static bool ShipOrdersEqual(ShipOrderRuntime left, ShipOrderRuntime right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && left.Priority == right.Priority + && left.InterruptCurrentPlan == right.InterruptCurrentPlan + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.SourceStationId, right.SourceStationId, StringComparison.Ordinal) + && string.Equals(left.DestinationStationId, right.DestinationStationId, StringComparison.Ordinal) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal) + && string.Equals(left.NodeId, right.NodeId, StringComparison.Ordinal) + && string.Equals(left.ConstructionSiteId, right.ConstructionSiteId, StringComparison.Ordinal) + && string.Equals(left.ModuleId, right.ModuleId, StringComparison.Ordinal) + && left.WaitSeconds.Equals(right.WaitSeconds) + && left.Radius.Equals(right.Radius) + && left.MaxSystemRange == right.MaxSystemRange + && left.KnownStationsOnly == right.KnownStationsOnly; + + private static bool AssignmentsEqual(CommanderAssignmentRuntime? left, CommanderAssignmentRuntime? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return string.Equals(left.ObjectiveId, right.ObjectiveId, StringComparison.Ordinal) + && string.Equals(left.CampaignId, right.CampaignId, StringComparison.Ordinal) + && string.Equals(left.TheaterId, right.TheaterId, StringComparison.Ordinal) + && string.Equals(left.Kind, right.Kind, StringComparison.Ordinal) + && string.Equals(left.BehaviorKind, right.BehaviorKind, StringComparison.Ordinal) + && string.Equals(left.Status, right.Status, StringComparison.Ordinal) + && left.Priority.Equals(right.Priority) + && string.Equals(left.HomeSystemId, right.HomeSystemId, StringComparison.Ordinal) + && string.Equals(left.HomeStationId, right.HomeStationId, StringComparison.Ordinal) + && string.Equals(left.TargetSystemId, right.TargetSystemId, StringComparison.Ordinal) + && string.Equals(left.TargetEntityId, right.TargetEntityId, StringComparison.Ordinal) + && Nullable.Equals(left.TargetPosition, right.TargetPosition) + && string.Equals(left.ItemId, right.ItemId, StringComparison.Ordinal); + } + + private static ShipOrderTemplateRuntime CloneTemplate(ShipOrderTemplateRuntime template) => new() + { + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition, + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds, + Radius = template.Radius, + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly, + }; + + private static void ReconcileOrganizationAssignments(SimulationWorld world, PlayerFactionRuntime player) + { + var fleetMemberships = new Dictionary>(StringComparer.Ordinal); + var taskForceMemberships = new Dictionary>(StringComparer.Ordinal); + var stationGroupMemberships = new Dictionary>(StringComparer.Ordinal); + var reserveMemberships = new Dictionary>(StringComparer.Ordinal); + + foreach (var fleet in player.Fleets) + { + foreach (var assetId in fleet.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(fleetMemberships, assetId, fleet.Id); + GetOrCreateAssignment(player, "ship", assetId); + } + } + + foreach (var taskForce in player.TaskForces) + { + foreach (var assetId in taskForce.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(taskForceMemberships, assetId, taskForce.Id); + GetOrCreateAssignment(player, "ship", assetId); + } + } + + foreach (var group in player.StationGroups) + { + foreach (var stationId in group.StationIds.Where(player.AssetRegistry.StationIds.Contains)) + { + AddMembership(stationGroupMemberships, stationId, group.Id); + GetOrCreateAssignment(player, "station", stationId); + } + } + + foreach (var reserve in player.Reserves) + { + foreach (var assetId in reserve.AssetIds.Where(player.AssetRegistry.ShipIds.Contains)) + { + AddMembership(reserveMemberships, assetId, reserve.Id); + GetOrCreateAssignment(player, "ship", assetId); + } + } + + foreach (var assignment in player.Assignments) + { + if (assignment.AssetKind == "ship") + { + assignment.FleetId = SelectSingleMembership(fleetMemberships, assignment.AssetId); + assignment.TaskForceId = SelectSingleMembership(taskForceMemberships, assignment.AssetId); + assignment.ReserveId = SelectSingleMembership(reserveMemberships, assignment.AssetId); + + if (assignment.TaskForceId is not null + && player.TaskForces.FirstOrDefault(taskForce => taskForce.Id == assignment.TaskForceId) is { FleetId: not null } taskForce) + { + assignment.FleetId ??= taskForce.FleetId; + } + + if (assignment.FleetId is not null) + { + assignment.FrontId = player.Fronts + .Where(front => front.FleetIds.Contains(assignment.FleetId, StringComparer.Ordinal)) + .OrderByDescending(front => front.Priority) + .ThenBy(front => front.Id, StringComparer.Ordinal) + .Select(front => front.Id) + .FirstOrDefault() + ?? assignment.FrontId; + } + else if (assignment.ReserveId is not null) + { + assignment.FrontId = player.Fronts + .Where(front => front.ReserveIds.Contains(assignment.ReserveId, StringComparer.Ordinal)) + .OrderByDescending(front => front.Priority) + .ThenBy(front => front.Id, StringComparer.Ordinal) + .Select(front => front.Id) + .FirstOrDefault() + ?? player.Reserves.FirstOrDefault(reserve => reserve.Id == assignment.ReserveId)?.FrontIds + .OrderBy(id => id, StringComparer.Ordinal) + .FirstOrDefault() + ?? assignment.FrontId; + } + } + else if (assignment.AssetKind == "station") + { + assignment.StationGroupId = SelectSingleMembership(stationGroupMemberships, assignment.AssetId); + if (assignment.StationGroupId is not null + && player.StationGroups.FirstOrDefault(group => group.Id == assignment.StationGroupId) is { EconomicRegionId: not null } stationGroup) + { + assignment.EconomicRegionId = stationGroup.EconomicRegionId; + } + } + } + + foreach (var assignment in player.Assignments) + { + assignment.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + } + + private static void ReconcileDirectiveScopes(PlayerFactionRuntime player) + { + foreach (var fleet in player.Fleets) + { + fleet.DirectiveIds.Clear(); + } + foreach (var taskForce in player.TaskForces) + { + taskForce.DirectiveIds.Clear(); + } + foreach (var group in player.StationGroups) + { + group.DirectiveIds.Clear(); + } + foreach (var region in player.EconomicRegions) + { + region.DirectiveIds.Clear(); + } + foreach (var front in player.Fronts) + { + front.DirectiveIds.Clear(); + } + + foreach (var directive in player.Directives.Where(directive => directive.Status == "active")) + { + switch (directive.ScopeKind) + { + case "fleet": + player.Fleets.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "task-force": + player.TaskForces.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "station-group": + player.StationGroups.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "economic-region": + player.EconomicRegions.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + case "front": + player.Fronts.FirstOrDefault(entity => entity.Id == directive.ScopeId)?.DirectiveIds.Add(directive.Id); + break; + } + } + } + + private static void RefreshProductionPrograms(SimulationWorld world, PlayerFactionRuntime player) + { + foreach (var program in player.ProductionPrograms) + { + if (!string.IsNullOrWhiteSpace(program.TargetShipKind)) + { + program.CurrentCount = world.Ships.Count(ship => + ship.FactionId == player.SovereignFactionId && + string.Equals(ship.Definition.Kind, program.TargetShipKind, StringComparison.Ordinal)); + } + else + { + program.CurrentCount = 0; + } + } + } + + private static void ApplyStrategicIntegration(SimulationWorld world, PlayerFactionRuntime player) + { + var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == player.SovereignFactionId); + if (faction is null) + { + return; + } + + var corePolicy = player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy"); + faction.Doctrine.StrategicPosture = player.StrategicIntent.StrategicPosture; + faction.Doctrine.EconomicPosture = player.StrategicIntent.EconomicPosture; + faction.Doctrine.MilitaryPosture = player.StrategicIntent.MilitaryPosture; + faction.Doctrine.ReserveCreditsRatio = corePolicy?.ReserveCreditsRatio ?? faction.Doctrine.ReserveCreditsRatio; + faction.Doctrine.ReserveMilitaryRatio = corePolicy?.ReserveMilitaryRatio ?? faction.Doctrine.ReserveMilitaryRatio; + } + + private static void RefreshGeopoliticalOrganizationContext(SimulationWorld world, PlayerFactionRuntime player) + { + var regions = world.Geopolitics?.EconomyRegions.Regions + .Where(region => string.Equals(region.FactionId, player.SovereignFactionId, StringComparison.Ordinal)) + .ToList() ?? []; + var fronts = world.Geopolitics?.Territory.FrontLines + .Where(front => front.FactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal)) + .ToList() ?? []; + + foreach (var region in player.EconomicRegions) + { + if (region.SystemIds.Count == 0) + { + region.SystemIds.AddRange( + region.StationGroupIds + .SelectMany(groupId => player.StationGroups.FirstOrDefault(group => group.Id == groupId)?.StationIds ?? []) + .Select(stationId => world.Stations.FirstOrDefault(station => station.Id == stationId)?.SystemId) + .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + var matchedRegion = regions + .Select(candidate => new + { + Region = candidate, + Overlap = candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Count(), + }) + .OrderByDescending(entry => entry.Overlap) + .ThenBy(entry => entry.Region.Id, StringComparer.Ordinal) + .Select(entry => entry.Region) + .FirstOrDefault(); + region.SharedEconomicRegionId = matchedRegion?.Id; + if (matchedRegion is null) + { + continue; + } + + if (region.SystemIds.Count == 0) + { + region.SystemIds.AddRange(matchedRegion.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + if (string.Equals(region.Role, "balanced-region", StringComparison.Ordinal)) + { + region.Role = matchedRegion.Kind; + } + } + + foreach (var front in player.Fronts) + { + if (front.SystemIds.Count == 0) + { + var fleetSystems = front.FleetIds + .SelectMany(fleetId => player.Fleets.FirstOrDefault(fleet => fleet.Id == fleetId)?.AssetIds ?? []) + .Select(assetId => world.Ships.FirstOrDefault(ship => ship.Id == assetId)?.SystemId) + .Where(systemId => !string.IsNullOrWhiteSpace(systemId)) + .Cast() + .Distinct(StringComparer.Ordinal) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .ToList(); + front.SystemIds.AddRange(fleetSystems); + } + + var matchedFront = fronts + .Select(candidate => new + { + Front = candidate, + Overlap = candidate.SystemIds.Intersect(front.SystemIds, StringComparer.Ordinal).Count(), + TargetBias = front.TargetFactionId is not null && candidate.FactionIds.Contains(front.TargetFactionId, StringComparer.Ordinal) ? 1 : 0, + }) + .OrderByDescending(entry => entry.Overlap + entry.TargetBias) + .ThenBy(entry => entry.Front.Id, StringComparer.Ordinal) + .Select(entry => entry.Front) + .FirstOrDefault(); + front.SharedFrontLineId = matchedFront?.Id; + if (matchedFront is null) + { + continue; + } + + if (front.SystemIds.Count == 0) + { + front.SystemIds.AddRange(matchedFront.SystemIds.OrderBy(systemId => systemId, StringComparer.Ordinal)); + } + + front.TargetFactionId ??= matchedFront.FactionIds.FirstOrDefault(id => !string.Equals(id, player.SovereignFactionId, StringComparison.Ordinal)); + } + } + + private static PlayerDirectiveRuntime? SelectScopedDirective( + IEnumerable directives, + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + directives + .Where(directive => ScopeMatches(player, directive.ScopeKind, directive.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(directive => ScopePriority(directive.ScopeKind)) + .ThenByDescending(directive => directive.Priority) + .ThenByDescending(directive => directive.UpdatedAtUtc) + .ThenBy(directive => directive.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static PlayerAutomationPolicyRuntime? SelectScopedAutomationPolicy( + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + player.AutomationPolicies + .Where(policy => policy.Enabled && ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) + .ThenByDescending(policy => policy.UpdatedAtUtc) + .ThenBy(policy => policy.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static PlayerFactionPolicyRuntime? SelectScopedFactionPolicy( + PlayerFactionRuntime player, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) => + player.Policies + .Where(policy => ScopeMatches(player, policy.ScopeKind, policy.ScopeId, assignment, assetKind, assetId)) + .OrderByDescending(policy => ScopePriority(policy.ScopeKind)) + .ThenByDescending(policy => policy.UpdatedAtUtc) + .ThenBy(policy => policy.Id, StringComparer.Ordinal) + .FirstOrDefault(); + + private static bool ScopeMatches( + PlayerFactionRuntime player, + string scopeKind, + string? scopeId, + PlayerAssignmentRuntime? assignment, + string assetKind, + string assetId) + { + return scopeKind switch + { + "player-faction" => string.IsNullOrWhiteSpace(scopeId) + || string.Equals(scopeId, player.Id, StringComparison.Ordinal) + || string.Equals(scopeId, player.SovereignFactionId, StringComparison.Ordinal), + "asset" => string.Equals(scopeId, assetId, StringComparison.Ordinal), + "ship" => assetKind == "ship" && string.Equals(scopeId, assetId, StringComparison.Ordinal), + "station" => assetKind == "station" && string.Equals(scopeId, assetId, StringComparison.Ordinal), + "fleet" => string.Equals(scopeId, assignment?.FleetId, StringComparison.Ordinal), + "task-force" => string.Equals(scopeId, assignment?.TaskForceId, StringComparison.Ordinal), + "station-group" => string.Equals(scopeId, assignment?.StationGroupId, StringComparison.Ordinal), + "economic-region" => string.Equals(scopeId, assignment?.EconomicRegionId, StringComparison.Ordinal), + "front" => string.Equals(scopeId, assignment?.FrontId, StringComparison.Ordinal), + "reserve" => string.Equals(scopeId, assignment?.ReserveId, StringComparison.Ordinal), + _ => false, + }; + } + + private static int ScopePriority(string scopeKind) => scopeKind switch + { + "ship" or "station" or "asset" => 100, + "task-force" => 90, + "fleet" or "station-group" or "reserve" => 80, + "economic-region" or "front" => 70, + "player-faction" => 10, + _ => 0, + }; + + private static PlayerAssignmentRuntime GetOrCreateAssignment(PlayerFactionRuntime player, string assetKind, string assetId) + { + var assignment = player.Assignments.FirstOrDefault(candidate => + string.Equals(candidate.AssetKind, assetKind, StringComparison.Ordinal) && + string.Equals(candidate.AssetId, assetId, StringComparison.Ordinal)); + if (assignment is not null) + { + return assignment; + } + + assignment = new PlayerAssignmentRuntime + { + Id = $"assignment-{assetKind}-{assetId}", + AssetKind = assetKind, + AssetId = assetId, + }; + player.Assignments.Add(assignment); + return assignment; + } + + private static void AddMembership(Dictionary> memberships, string assetId, string organizationId) + { + if (!memberships.TryGetValue(assetId, out var values)) + { + values = []; + memberships[assetId] = values; + } + + if (!values.Contains(organizationId, StringComparer.Ordinal)) + { + values.Add(organizationId); + } + } + + private static string? SelectSingleMembership(Dictionary> memberships, string assetId) => + memberships.TryGetValue(assetId, out var values) + ? values.OrderBy(value => value, StringComparer.Ordinal).FirstOrDefault() + : null; + + private static void RemoveAssetFromOrganizations(PlayerFactionRuntime player, string assetKind, string assetId) + { + foreach (var fleet in player.Fleets) + { + fleet.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + foreach (var taskForce in player.TaskForces) + { + taskForce.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + foreach (var reserve in player.Reserves) + { + reserve.AssetIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + if (assetKind == "station") + { + foreach (var group in player.StationGroups) + { + group.StationIds.RemoveAll(id => string.Equals(id, assetId, StringComparison.Ordinal)); + } + } + } + + private static void AddAssetToFleet(PlayerFactionRuntime player, string fleetId, string assetId) + { + var fleet = player.Fleets.FirstOrDefault(entity => entity.Id == fleetId) + ?? throw new InvalidOperationException($"Unknown fleet '{fleetId}'."); + if (!fleet.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + fleet.AssetIds.Add(assetId); + } + } + + private static void AddAssetToTaskForce(PlayerFactionRuntime player, string taskForceId, string assetId) + { + var taskForce = player.TaskForces.FirstOrDefault(entity => entity.Id == taskForceId) + ?? throw new InvalidOperationException($"Unknown task force '{taskForceId}'."); + if (!taskForce.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + taskForce.AssetIds.Add(assetId); + } + } + + private static void AddAssetToStationGroup(PlayerFactionRuntime player, string groupId, string assetId) + { + var group = player.StationGroups.FirstOrDefault(entity => entity.Id == groupId) + ?? throw new InvalidOperationException($"Unknown station group '{groupId}'."); + if (!group.StationIds.Contains(assetId, StringComparer.Ordinal)) + { + group.StationIds.Add(assetId); + } + } + + private static void AddAssetToReserve(PlayerFactionRuntime player, string reserveId, string assetId) + { + var reserve = player.Reserves.FirstOrDefault(entity => entity.Id == reserveId) + ?? throw new InvalidOperationException($"Unknown reserve '{reserveId}'."); + if (!reserve.AssetIds.Contains(assetId, StringComparer.Ordinal)) + { + reserve.AssetIds.Add(assetId); + } + } + + private static void RefreshAlerts(SimulationWorld world, PlayerFactionRuntime player) + { + player.Alerts.Clear(); + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => player.Fleets.Count(fleet => fleet.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-fleet-{shipId}", + Kind = "conflicting-fleet-membership", + Severity = "warning", + Summary = $"Ship {shipId} belongs to multiple fleets.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => player.TaskForces.Count(taskForce => taskForce.AssetIds.Contains(shipId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-task-force-{shipId}", + Kind = "conflicting-task-force-membership", + Severity = "warning", + Summary = $"Ship {shipId} belongs to multiple task forces.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var stationId in player.AssetRegistry.StationIds + .Where(stationId => player.StationGroups.Count(group => group.StationIds.Contains(stationId, StringComparer.Ordinal)) > 1) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(4)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-conflict-station-group-{stationId}", + Kind = "conflicting-station-group-membership", + Severity = "warning", + Summary = $"Station {stationId} belongs to multiple station groups.", + AssetKind = "station", + AssetId = stationId, + }); + } + + foreach (var shipId in player.AssetRegistry.ShipIds + .Where(shipId => !player.Assignments.Any(assignment => assignment.AssetKind == "ship" && assignment.AssetId == shipId)) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(10)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-unassigned-ship-{shipId}", + Kind = "unassigned-ship", + Severity = "warning", + Summary = $"Ship {shipId} has no player assignment.", + AssetKind = "ship", + AssetId = shipId, + }); + } + + foreach (var stationId in player.AssetRegistry.StationIds + .Where(stationId => !player.Assignments.Any(assignment => assignment.AssetKind == "station" && assignment.AssetId == stationId)) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-unassigned-station-{stationId}", + Kind = "unassigned-station", + Severity = "info", + Summary = $"Station {stationId} is not part of a player station group.", + AssetKind = "station", + AssetId = stationId, + }); + } + + foreach (var directive in player.Directives.Where(directive => + directive.Status == "active" && + !player.Assignments.Any(assignment => assignment.DirectiveId == directive.Id)).Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-orphan-directive-{directive.Id}", + Kind = "orphan-directive", + Severity = "warning", + Summary = $"Directive {directive.Label} is not assigned to any asset or group.", + RelatedDirectiveId = directive.Id, + }); + } + + foreach (var policy in player.ReinforcementPolicies + .Where(policy => policy.DesiredAssetCount > 0) + .OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Take(6)) + { + var available = world.Ships.Count(ship => + ship.FactionId == player.SovereignFactionId && + string.Equals(ship.Definition.Kind, policy.ShipKind, StringComparison.Ordinal)); + if (available < policy.DesiredAssetCount) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-reinforcement-{policy.Id}", + Kind = "reinforcement-deficit", + Severity = "warning", + Summary = $"Reinforcement policy {policy.Label} is short {policy.DesiredAssetCount - available} {policy.ShipKind} assets.", + }); + } + } + + foreach (var program in player.ProductionPrograms + .Where(program => program.TargetCount > 0 && program.CurrentCount < program.TargetCount) + .OrderBy(program => program.Id, StringComparer.Ordinal) + .Take(6)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-production-{program.Id}", + Kind = "production-program-deficit", + Severity = "info", + Summary = $"Production program {program.Label} is at {program.CurrentCount}/{program.TargetCount}.", + }); + } + + foreach (var systemId in world.Geopolitics?.Territory.ControlStates + .Where(state => state.IsContested + && (string.Equals(state.ControllerFactionId, player.SovereignFactionId, StringComparison.Ordinal) + || string.Equals(state.PrimaryClaimantFactionId, player.SovereignFactionId, StringComparison.Ordinal) + || state.ClaimantFactionIds.Contains(player.SovereignFactionId, StringComparer.Ordinal))) + .Select(state => state.SystemId) + .Where(systemId => player.Fronts.All(front => !front.SystemIds.Contains(systemId, StringComparer.Ordinal))) + .OrderBy(systemId => systemId, StringComparer.Ordinal) + .Take(4) ?? []) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-contested-system-{systemId}", + Kind = "uncovered-contested-system", + Severity = "warning", + Summary = $"Contested player system {systemId} is not covered by a player front.", + }); + } + + foreach (var region in player.EconomicRegions.Take(6)) + { + var sharedRegion = world.Geopolitics?.EconomyRegions.Regions.FirstOrDefault(candidate => + string.Equals(candidate.FactionId, player.SovereignFactionId, StringComparison.Ordinal) + && candidate.SystemIds.Intersect(region.SystemIds, StringComparer.Ordinal).Any()); + if (sharedRegion is null) + { + continue; + } + + var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Severity) + .ThenBy(candidate => candidate.ItemId, StringComparer.Ordinal) + .FirstOrDefault(); + var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(candidate => string.Equals(candidate.RegionId, sharedRegion.Id, StringComparison.Ordinal)); + if (bottleneck is not null && bottleneck.Severity >= 2.5f) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-region-bottleneck-{region.Id}-{bottleneck.ItemId}", + Kind = "economic-region-bottleneck", + Severity = "warning", + Summary = $"Region {region.Label} is bottlenecked on {bottleneck.ItemId}.", + }); + } + if ((security?.SupplyRisk ?? 0f) >= 0.55f) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-region-risk-{region.Id}", + Kind = "economic-region-risk", + Severity = "warning", + Summary = $"Region {region.Label} has elevated logistics risk.", + }); + } + } + + foreach (var front in player.Fronts + .Where(front => !string.IsNullOrWhiteSpace(front.TargetFactionId)) + .Take(6)) + { + var relation = GeopoliticalSimulationService.FindRelation(world, player.SovereignFactionId, front.TargetFactionId!); + if (relation is not null + && relation.Posture is not "hostile" and not "war" + && front.Priority >= 60f + && !string.Equals(front.Posture, "hold", StringComparison.Ordinal)) + { + player.Alerts.Add(new PlayerAlertRuntime + { + Id = $"alert-front-posture-{front.Id}", + Kind = "front-diplomatic-misalignment", + Severity = "info", + Summary = $"Front {front.Label} targets {front.TargetFactionId} while diplomatic posture is {relation.Posture}.", + }); + } + } + + while (player.Alerts.Count > MaxAlerts) + { + player.Alerts.RemoveAt(player.Alerts.Count - 1); + } + } + + private static void AddDecision(PlayerFactionRuntime player, string kind, string summary, string? relatedKind, string? relatedId) + { + player.DecisionLog.Insert(0, new PlayerDecisionLogEntryRuntime + { + Id = $"player-decision-{Guid.NewGuid():N}", + Kind = kind, + Summary = summary, + RelatedEntityKind = relatedKind, + RelatedEntityId = relatedId, + OccurredAtUtc = DateTimeOffset.UtcNow, + }); + + while (player.DecisionLog.Count > MaxDecisionEntries) + { + player.DecisionLog.RemoveAt(player.DecisionLog.Count - 1); + } + } + + private static PolicySetRuntime EnsurePolicySet(SimulationWorld world, PlayerFactionRuntime player, PlayerFactionPolicyRuntime policy, string? requestedPolicySetId) + { + if (requestedPolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == requestedPolicySetId) is { } existing) + { + return existing; + } + + if (policy.PolicySetId is not null && world.Policies.FirstOrDefault(candidate => candidate.Id == policy.PolicySetId) is { } current) + { + return current; + } + + var created = new PolicySetRuntime + { + Id = $"policy-player-{policy.Id}", + OwnerKind = "player-faction-policy", + OwnerId = policy.Id, + }; + world.Policies.Add(created); + player.AssetRegistry.PolicySetIds.Add(created.Id); + return created; + } + + private static void CopyPolicySetToPlayerPolicy(PolicySetRuntime policySet, PlayerFactionPolicyRuntime policy) + { + policy.TradeAccessPolicy = policySet.TradeAccessPolicy; + policy.DockingAccessPolicy = policySet.DockingAccessPolicy; + policy.ConstructionAccessPolicy = policySet.ConstructionAccessPolicy; + policy.OperationalRangePolicy = policySet.OperationalRangePolicy; + policy.CombatEngagementPolicy = policySet.CombatEngagementPolicy; + policy.AvoidHostileSystems = policySet.AvoidHostileSystems; + policy.FleeHullRatio = policySet.FleeHullRatio; + policy.BlacklistedSystemIds.Clear(); + foreach (var systemId in policySet.BlacklistedSystemIds) + { + policy.BlacklistedSystemIds.Add(systemId); + } + } + + private static void ApplyPolicySetRequest(PolicySetRuntime policySet, PlayerPolicyCommandRequest request) + { + if (request.TradeAccessPolicy is not null) + { + policySet.TradeAccessPolicy = request.TradeAccessPolicy; + } + if (request.DockingAccessPolicy is not null) + { + policySet.DockingAccessPolicy = request.DockingAccessPolicy; + } + if (request.ConstructionAccessPolicy is not null) + { + policySet.ConstructionAccessPolicy = request.ConstructionAccessPolicy; + } + if (request.OperationalRangePolicy is not null) + { + policySet.OperationalRangePolicy = request.OperationalRangePolicy; + } + if (request.CombatEngagementPolicy is not null) + { + policySet.CombatEngagementPolicy = request.CombatEngagementPolicy; + } + if (request.AvoidHostileSystems.HasValue) + { + policySet.AvoidHostileSystems = request.AvoidHostileSystems.Value; + } + if (request.FleeHullRatio.HasValue) + { + policySet.FleeHullRatio = Math.Clamp(request.FleeHullRatio.Value, 0f, 1f); + } + policySet.BlacklistedSystemIds.Clear(); + foreach (var systemId in request.BlacklistedSystemIds ?? []) + { + policySet.BlacklistedSystemIds.Add(systemId); + } + } + + private static string? ResolvePolicySetId(SimulationWorld world, PlayerFactionRuntime player, string? policyId) + { + if (policyId is null) + { + return player.Policies.FirstOrDefault(policy => policy.Id == "player-core-policy")?.PolicySetId; + } + + return player.Policies.FirstOrDefault(policy => policy.Id == policyId)?.PolicySetId + ?? world.Policies.FirstOrDefault(policy => policy.Id == policyId)?.Id; + } + + private static void RemoveOrganization(PlayerFactionRuntime player, string organizationId) + { + if (player.Fleets.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.FleetIds.Remove(organizationId); + return; + } + if (player.TaskForces.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.TaskForceIds.Remove(organizationId); + return; + } + if (player.StationGroups.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.StationGroupIds.Remove(organizationId); + return; + } + if (player.EconomicRegions.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.EconomicRegionIds.Remove(organizationId); + return; + } + if (player.Fronts.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.FrontIds.Remove(organizationId); + return; + } + if (player.Reserves.RemoveAll(entity => entity.Id == organizationId) > 0) + { + player.AssetRegistry.ReserveIds.Remove(organizationId); + return; + } + + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); + } + + private static string ResolveOrganizationKind(PlayerFactionRuntime player, string organizationId) + { + if (player.Fleets.Any(entity => entity.Id == organizationId)) return "fleet"; + if (player.TaskForces.Any(entity => entity.Id == organizationId)) return "task-force"; + if (player.StationGroups.Any(entity => entity.Id == organizationId)) return "station-group"; + if (player.EconomicRegions.Any(entity => entity.Id == organizationId)) return "economic-region"; + if (player.Fronts.Any(entity => entity.Id == organizationId)) return "front"; + if (player.Reserves.Any(entity => entity.Id == organizationId)) return "reserve"; + throw new InvalidOperationException($"Unknown organization '{organizationId}'."); + } + + private static IEnumerable ExistingOrganizationIds(PlayerFactionRuntime player) => + player.Fleets.Select(entity => entity.Id) + .Concat(player.TaskForces.Select(entity => entity.Id)) + .Concat(player.StationGroups.Select(entity => entity.Id)) + .Concat(player.EconomicRegions.Select(entity => entity.Id)) + .Concat(player.Fronts.Select(entity => entity.Id)) + .Concat(player.Reserves.Select(entity => entity.Id)); + + private static string NormalizeKind(string value) => + value.Trim().ToLowerInvariant(); + + private static string CreateDomainId(string prefix, string label, IEnumerable existingIds) + { + var slug = new string(label + .Trim() + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-') + .ToArray()) + .Trim('-'); + if (string.IsNullOrWhiteSpace(slug)) + { + slug = prefix; + } + + var candidate = $"{prefix}-{slug}"; + var known = existingIds.ToHashSet(StringComparer.Ordinal); + if (!known.Contains(candidate)) + { + return candidate; + } + + var suffix = 2; + while (known.Contains($"{candidate}-{suffix}")) + { + suffix += 1; + } + return $"{candidate}-{suffix}"; + } + + private static void UpdateStringList(List target, IEnumerable? requested, bool replace, IEnumerable allowedValues) + { + if (replace) + { + target.Clear(); + } + if (requested is null) + { + return; + } + + var allowed = allowedValues.ToHashSet(StringComparer.Ordinal); + foreach (var value in requested.Where(value => !string.IsNullOrWhiteSpace(value))) + { + if (allowed.Contains(value) && !target.Contains(value, StringComparer.Ordinal)) + { + target.Add(value); + } + } + } + + private static void SyncSet(HashSet target, IEnumerable source) + { + target.Clear(); + foreach (var value in source.Where(value => !string.IsNullOrWhiteSpace(value))) + { + target.Add(value); + } + } +} diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index a32abdf..da4ded2 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -1,9 +1,9 @@ using FastEndpoints; +using FastEndpoints.Swagger; using SpaceGame.Api.Universe.Simulation; var builder = WebApplication.CreateBuilder(args); -builder.WebHost.UseUrls("http://127.0.0.1:5079"); builder.Services.AddCors((options) => { options.AddDefaultPolicy((policy) => @@ -17,6 +17,7 @@ builder.Services.AddCors((options) => builder.Services.Configure(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.AddFastEndpoints(); +builder.Services.SwaggerDocument(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -25,5 +26,6 @@ var app = builder.Build(); app.UseCors(); app.UseFastEndpoints(); +app.UseSwaggerGen(); app.Run(); diff --git a/apps/backend/Properties/launchSettings.json b/apps/backend/Properties/launchSettings.json index d49908c..b661353 100644 --- a/apps/backend/Properties/launchSettings.json +++ b/apps/backend/Properties/launchSettings.json @@ -5,16 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:0", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:0;http://localhost:0", + "applicationUrl": "http://0.0.0.0:5079", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/apps/backend/Shared/AI/GoapCore.cs b/apps/backend/Shared/AI/GoapCore.cs deleted file mode 100644 index a46481f..0000000 --- a/apps/backend/Shared/AI/GoapCore.cs +++ /dev/null @@ -1,92 +0,0 @@ - -namespace SpaceGame.Api.Shared.AI; - -public abstract class GoapAction -{ - public abstract string Name { get; } - public abstract float Cost { get; } - public abstract bool CheckPreconditions(TState state); - public abstract TState ApplyEffects(TState state); - public abstract void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander); -} - -public abstract class GoapGoal -{ - public abstract string Name { get; } - public abstract bool IsSatisfied(TState state); - public abstract float ComputePriority(TState state, SimulationWorld world, CommanderRuntime commander); -} - -public sealed class GoapPlan -{ - public static readonly GoapPlan Empty = new() { Actions = [], TotalCost = 0f }; - - public required IReadOnlyList> Actions { get; init; } - public required float TotalCost { get; init; } - public int CurrentStep { get; set; } - - public GoapAction? CurrentAction => CurrentStep < Actions.Count ? Actions[CurrentStep] : null; - public bool IsComplete => CurrentStep >= Actions.Count; - public void Advance() => CurrentStep++; -} - -public sealed class GoapPlanner -{ - private readonly Func cloneState; - - public GoapPlanner(Func cloneState) - { - this.cloneState = cloneState; - } - - public GoapPlan? Plan( - TState initialState, - GoapGoal goal, - IReadOnlyList> availableActions) - { - if (goal.IsSatisfied(initialState)) - { - return GoapPlan.Empty; - } - - var openSet = new PriorityQueue(); - openSet.Enqueue(new PlanNode(cloneState(initialState), [], 0f), 0f); - - const int MaxIterations = 256; - var iterations = 0; - - while (openSet.Count > 0 && iterations++ < MaxIterations) - { - var current = openSet.Dequeue(); - - if (goal.IsSatisfied(current.State)) - { - return new GoapPlan - { - Actions = current.Actions, - TotalCost = current.Cost, - }; - } - - foreach (var action in availableActions) - { - if (!action.CheckPreconditions(current.State)) - { - continue; - } - - var newState = action.ApplyEffects(cloneState(current.State)); - var newCost = current.Cost + action.Cost; - var newActions = new List>(current.Actions) { action }; - openSet.Enqueue(new PlanNode(newState, newActions, newCost), newCost); - } - } - - return null; - } - - private sealed record PlanNode( - TState State, - IReadOnlyList> Actions, - float Cost); -} diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index dcf3827..2ec343e 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -12,14 +12,47 @@ public enum WorkStatus { Pending, Active, + Blocked, Completed, + Failed, + Interrupted, } public enum OrderStatus { Queued, - Accepted, + Active, Completed, + Cancelled, + Failed, + Interrupted, +} + +public enum AiPlanStatus +{ + Planned, + Running, + Blocked, + Completed, + Failed, + Interrupted, +} + +public enum AiPlanStepStatus +{ + Planned, + Running, + Blocked, + Completed, + Failed, + Interrupted, +} + +public enum AiPlanSourceKind +{ + Rule, + Order, + DefaultBehavior, } public enum ShipState @@ -49,22 +82,8 @@ public enum ShipState Blocked, Undocking, EngagingTarget, -} - -public enum ControllerTaskKind -{ - Idle, - Travel, - Extract, - Dock, - Load, - Unload, - DeliverConstruction, - BuildConstructionSite, - AttackTarget, - - ConstructModule, - Undock, + HoldingPosition, + Fleeing, } public static class SpaceLayerKinds @@ -95,37 +114,39 @@ public static class CommanderKind public static class ShipTaskKinds { - public const string Idle = "idle"; - public const string LocalMove = "local-move"; - public const string WarpToNode = "warp-to-node"; - public const string UseStargate = "use-stargate"; - public const string UseFtl = "use-ftl"; + public const string HoldPosition = "hold-position"; + public const string Travel = "travel"; + public const string FollowTarget = "follow-target"; + public const string MineNode = "mine-node"; public const string Dock = "dock"; public const string Undock = "undock"; public const string LoadCargo = "load-cargo"; public const string UnloadCargo = "unload-cargo"; - - public const string MineNode = "mine-node"; - public const string HarvestGas = "harvest-gas"; - public const string DeliverToStation = "deliver-to-station"; - public const string ClaimLagrangePoint = "claim-lagrange-point"; + public const string TransferCargoToShip = "transfer-cargo-to-ship"; + public const string SalvageWreck = "salvage-wreck"; + public const string DeliverConstruction = "deliver-construction"; + public const string ConstructModule = "construct-module"; public const string BuildConstructionSite = "build-construction-site"; - public const string EscortTarget = "escort-target"; public const string AttackTarget = "attack-target"; - public const string DefendCelestial = "defend-celestial"; - public const string Retreat = "retreat"; - public const string HoldPosition = "hold-position"; + public const string Flee = "flee"; + public const string Wait = "wait"; } public static class ShipOrderKinds { - public const string DirectMove = "direct-move"; - public const string TravelToNode = "travel-to-node"; + public const string Move = "move"; public const string DockAtStation = "dock-at-station"; - public const string DeliverCargo = "deliver-cargo"; + public const string DockAndWait = "dock-and-wait"; + public const string FlyAndWait = "fly-and-wait"; + public const string FlyToObject = "fly-to-object"; + public const string FollowShip = "follow-ship"; + public const string TradeRoute = "trade-route"; + public const string MineAndDeliver = "mine-and-deliver"; public const string BuildAtSite = "build-at-site"; public const string AttackTarget = "attack-target"; public const string HoldPosition = "hold-position"; + public const string RepeatOrders = "repeat-orders"; + public const string Flee = "flee"; } public static class ClaimStateKinds @@ -174,18 +195,54 @@ public static class SimulationEnumMappings { WorkStatus.Pending => "pending", WorkStatus.Active => "active", + WorkStatus.Blocked => "blocked", WorkStatus.Completed => "completed", + WorkStatus.Failed => "failed", + WorkStatus.Interrupted => "interrupted", _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), }; public static string ToContractValue(this OrderStatus status) => status switch { OrderStatus.Queued => "queued", - OrderStatus.Accepted => "accepted", + OrderStatus.Active => "active", OrderStatus.Completed => "completed", + OrderStatus.Cancelled => "cancelled", + OrderStatus.Failed => "failed", + OrderStatus.Interrupted => "interrupted", _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), }; + public static string ToContractValue(this AiPlanStatus status) => status switch + { + AiPlanStatus.Planned => "planned", + AiPlanStatus.Running => "running", + AiPlanStatus.Blocked => "blocked", + AiPlanStatus.Completed => "completed", + AiPlanStatus.Failed => "failed", + AiPlanStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + public static string ToContractValue(this AiPlanStepStatus status) => status switch + { + AiPlanStepStatus.Planned => "planned", + AiPlanStepStatus.Running => "running", + AiPlanStepStatus.Blocked => "blocked", + AiPlanStepStatus.Completed => "completed", + AiPlanStepStatus.Failed => "failed", + AiPlanStepStatus.Interrupted => "interrupted", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; + + public static string ToContractValue(this AiPlanSourceKind kind) => kind switch + { + AiPlanSourceKind.Rule => "rule", + AiPlanSourceKind.Order => "order", + AiPlanSourceKind.DefaultBehavior => "default-behavior", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + public static string ToContractValue(this ShipState state) => state switch { ShipState.Idle => "idle", @@ -213,23 +270,8 @@ public static class SimulationEnumMappings ShipState.Blocked => "blocked", ShipState.Undocking => "undocking", ShipState.EngagingTarget => "engaging-target", + ShipState.HoldingPosition => "holding-position", + ShipState.Fleeing => "fleeing", _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), }; - - public static string ToContractValue(this ControllerTaskKind kind) => kind switch - { - ControllerTaskKind.Idle => "idle", - ControllerTaskKind.Travel => "travel", - ControllerTaskKind.Extract => "extract", - ControllerTaskKind.Dock => "dock", - ControllerTaskKind.Load => "load", - ControllerTaskKind.Unload => "unload", - ControllerTaskKind.DeliverConstruction => "deliver-construction", - ControllerTaskKind.BuildConstructionSite => "build-construction-site", - ControllerTaskKind.AttackTarget => "attack-target", - - ControllerTaskKind.ConstructModule => "construct-module", - ControllerTaskKind.Undock => "undock", - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), - }; } diff --git a/apps/backend/Ships/AI/IShipBehaviorState.cs b/apps/backend/Ships/AI/IShipBehaviorState.cs deleted file mode 100644 index 344f8e8..0000000 --- a/apps/backend/Ships/AI/IShipBehaviorState.cs +++ /dev/null @@ -1,11 +0,0 @@ - -namespace SpaceGame.Api.Ships.AI; - -internal interface IShipBehaviorState -{ - string Kind { get; } - - void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world); - - void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent); -} diff --git a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs b/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs deleted file mode 100644 index a503b65..0000000 --- a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs +++ /dev/null @@ -1,41 +0,0 @@ - -namespace SpaceGame.Api.Ships.AI; - -internal sealed class ShipBehaviorStateMachine -{ - private readonly IReadOnlyDictionary states; - private readonly IShipBehaviorState fallbackState; - - private ShipBehaviorStateMachine(IReadOnlyDictionary states, IShipBehaviorState fallbackState) - { - this.states = states; - this.fallbackState = fallbackState; - } - - public static ShipBehaviorStateMachine CreateDefault() - { - var idleState = new IdleShipBehaviorState(); - var knownStates = new IShipBehaviorState[] - { - idleState, - new PatrolShipBehaviorState(), - new AttackTargetShipBehaviorState(), - new TradeHaulShipBehaviorState(), - new ResourceHarvestShipBehaviorState("auto-mine", null, "mining"), - new ConstructStationShipBehaviorState(), - }; - - return new ShipBehaviorStateMachine( - knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal), - idleState); - } - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => - Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world); - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) => - Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent); - - private IShipBehaviorState Resolve(string kind) => - states.TryGetValue(kind, out var state) ? state : fallbackState; -} diff --git a/apps/backend/Ships/AI/ShipBehaviorStates.cs b/apps/backend/Ships/AI/ShipBehaviorStates.cs deleted file mode 100644 index 7536283..0000000 --- a/apps/backend/Ships/AI/ShipBehaviorStates.cs +++ /dev/null @@ -1,186 +0,0 @@ - -namespace SpaceGame.Api.Ships.AI; - -internal sealed class IdleShipBehaviorState : IShipBehaviorState -{ - public string Kind => "idle"; - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) - { - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Idle, - Threshold = world.Balance.ArrivalThreshold, - Status = WorkStatus.Pending, - }; - } - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - } -} - -internal sealed class PatrolShipBehaviorState : IShipBehaviorState -{ - public string Kind => "patrol"; - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) - { - if (ship.DefaultBehavior.PatrolPoints.Count == 0) - { - ship.DefaultBehavior.Kind = "idle"; - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Idle, - Threshold = world.Balance.ArrivalThreshold, - Status = WorkStatus.Pending, - }; - return; - } - - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], - TargetSystemId = ship.SystemId, - Threshold = 18f, - }; - } - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0) - { - ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count; - } - } -} - -internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState -{ - private readonly string? resourceItemId; - private readonly string requiredModule; - - public ResourceHarvestShipBehaviorState(string kind, string? resourceItemId, string requiredModule) - { - Kind = kind; - this.resourceItemId = resourceItemId; - this.requiredModule = requiredModule; - } - - public string Kind { get; } - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => - engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule); - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-node", "arrived"): - ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract"; - break; - case ("extract", "cargo-full"): - ship.DefaultBehavior.Phase = "travel-to-station"; - break; - case ("extract", "node-depleted"): - ship.DefaultBehavior.Phase = "travel-to-node"; - ship.DefaultBehavior.NodeId = null; - break; - case ("travel-to-station", "arrived"): - ship.DefaultBehavior.Phase = "dock"; - break; - case ("dock", "docked"): - ship.DefaultBehavior.Phase = "unload"; - break; - case ("unload", "unloaded"): - ship.DefaultBehavior.Phase = "undock"; - break; - case ("undock", "undocked"): - ship.DefaultBehavior.Phase = "travel-to-node"; - ship.DefaultBehavior.NodeId = null; - break; - } - } -} - -internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState -{ - public string Kind => "construct-station"; - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => - engine.PlanStationConstruction(ship, world); - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-station", "arrived"): - ship.DefaultBehavior.Phase = "deliver-to-site"; - break; - case ("deliver-to-site", "construction-delivered"): - ship.DefaultBehavior.Phase = "build-site"; - break; - case ("construct-module", "module-constructed"): - case ("build-site", "site-constructed"): - ship.DefaultBehavior.Phase = "travel-to-station"; - ship.DefaultBehavior.ModuleId = null; - break; - } - } -} - -internal sealed class AttackTargetShipBehaviorState : IShipBehaviorState -{ - public string Kind => "attack-target"; - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => - engine.PlanAttackTarget(ship, world); - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - if (controllerEvent is "target-destroyed" or "target-lost") - { - ship.DefaultBehavior.TargetEntityId = null; - } - } -} - -internal sealed class TradeHaulShipBehaviorState : IShipBehaviorState -{ - public string Kind => "trade-haul"; - - public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => - engine.PlanTransportHaul(ship, world); - - public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - switch (ship.DefaultBehavior.Phase, controllerEvent) - { - case ("travel-to-source", "arrived"): - ship.DefaultBehavior.Phase = "dock-source"; - break; - case ("dock-source", "docked"): - ship.DefaultBehavior.Phase = "load"; - break; - case ("load", "loaded"): - ship.DefaultBehavior.Phase = "undock-from-source"; - break; - case ("undock-from-source", "undocked"): - ship.DefaultBehavior.Phase = "travel-to-destination"; - break; - case ("travel-to-destination", "arrived"): - ship.DefaultBehavior.Phase = "dock-destination"; - break; - case ("dock-destination", "docked"): - ship.DefaultBehavior.Phase = "unload"; - break; - case ("unload", "unloaded"): - ship.DefaultBehavior.Phase = "undock-from-destination"; - break; - case ("undock-from-destination", "undocked"): - ship.DefaultBehavior.Phase = "travel-to-source"; - break; - } - } -} diff --git a/apps/backend/Ships/AI/ShipController.cs b/apps/backend/Ships/AI/ShipController.cs deleted file mode 100644 index 06c5aa9..0000000 --- a/apps/backend/Ships/AI/ShipController.cs +++ /dev/null @@ -1,227 +0,0 @@ - -namespace SpaceGame.Api.Ships.AI; - -// ─── Planning State ──────────────────────────────────────────────────────────── - -public sealed class ShipPlanningState -{ - public string ShipKind { get; set; } = string.Empty; - public bool HasMiningCapability { get; set; } - public bool FactionWantsOre { get; set; } - public bool FactionWantsExpansion { get; set; } - public bool FactionWantsCombat { get; set; } - public bool FactionNeedsShipyard { get; set; } - public string? TargetEnemySystemId { get; set; } - public string? TargetEnemyEntityId { get; set; } - public string? TradeItemId { get; set; } - public string? TradeSourceStationId { get; set; } - public string? TradeDestinationStationId { get; set; } - public string? CurrentObjective { get; set; } - - public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone(); -} - -// ─── Goals ───────────────────────────────────────────────────────────────────── - -// A ship should always have an assigned objective. The planner picks the best one. -public sealed class AssignObjectiveGoal : GoapGoal -{ - public override string Name => "assign-objective"; - - public override bool IsSatisfied(ShipPlanningState state) => state.CurrentObjective is not null; - - public override float ComputePriority(ShipPlanningState state, SimulationWorld world, CommanderRuntime commander) => - 100f; -} - -// ─── Actions ─────────────────────────────────────────────────────────────────── - -public sealed class SetMiningObjectiveAction : GoapAction -{ - public override string Name => "set-mining-objective"; - public override float Cost => 1f; - - public override bool CheckPreconditions(ShipPlanningState state) => - state.HasMiningCapability && state.FactionWantsOre; - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "auto-mine"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "auto-mine", StringComparison.Ordinal)) - { - return; - } - - ship.DefaultBehavior.Kind = "auto-mine"; - ship.DefaultBehavior.Phase = null; - ship.DefaultBehavior.NodeId = null; - } -} - -public sealed class SetPatrolObjectiveAction : GoapAction -{ - public override string Name => "set-patrol-objective"; - public override float Cost => 2f; - - public override bool CheckPreconditions(ShipPlanningState state) => - string.Equals(state.ShipKind, "military", StringComparison.Ordinal); - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "patrol"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "patrol", StringComparison.Ordinal)) - { - return; - } - - if (ship.DefaultBehavior.PatrolPoints.Count == 0) - { - var station = world.Stations.FirstOrDefault(s => - s.FactionId == ship.FactionId && - string.Equals(s.SystemId, ship.SystemId, StringComparison.Ordinal)); - - if (station is not null) - { - var radius = station.Radius + 90f; - ship.DefaultBehavior.PatrolPoints.AddRange( - [ - new Vector3(station.Position.X + radius, station.Position.Y, station.Position.Z), - new Vector3(station.Position.X, station.Position.Y, station.Position.Z + radius), - new Vector3(station.Position.X - radius, station.Position.Y, station.Position.Z), - new Vector3(station.Position.X, station.Position.Y, station.Position.Z - radius), - ]); - } - } - - ship.DefaultBehavior.Kind = "patrol"; - } -} - -public sealed class SetAttackObjectiveAction : GoapAction -{ - public override string Name => "set-attack-objective"; - public override float Cost => 1f; - - public override bool CheckPreconditions(ShipPlanningState state) => - string.Equals(state.ShipKind, "military", StringComparison.Ordinal) - && state.FactionWantsCombat - && state.TargetEnemyEntityId is not null; - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "attack-target"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null) - { - return; - } - - ship.DefaultBehavior.Kind = "attack-target"; - ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior?.AreaSystemId ?? ship.DefaultBehavior.AreaSystemId; - ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; - ship.DefaultBehavior.Phase = null; - } -} - -public sealed class SetConstructionObjectiveAction : GoapAction -{ - public override string Name => "set-construction-objective"; - public override float Cost => 1f; - - public override bool CheckPreconditions(ShipPlanningState state) => - string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) - && (state.FactionWantsExpansion || state.FactionNeedsShipyard); - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "construct-station"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "construct-station", StringComparison.Ordinal)) - { - return; - } - - ship.DefaultBehavior.Kind = "construct-station"; - ship.DefaultBehavior.Phase = null; - } -} - -public sealed class SetTradeObjectiveAction : GoapAction -{ - public override string Name => "set-trade-objective"; - public override float Cost => 1f; - - public override bool CheckPreconditions(ShipPlanningState state) => - string.Equals(state.ShipKind, "transport", StringComparison.Ordinal) - && state.TradeItemId is not null - && state.TradeSourceStationId is not null - && state.TradeDestinationStationId is not null; - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "trade-haul"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null || commander.ActiveBehavior is null) - { - return; - } - - ship.DefaultBehavior.Kind = "trade-haul"; - ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId; - ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId; - ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId; - ship.DefaultBehavior.Phase ??= "travel-to-source"; - } -} - -public sealed class SetIdleObjectiveAction : GoapAction -{ - public override string Name => "set-idle-objective"; - public override float Cost => 10f; - - public override bool CheckPreconditions(ShipPlanningState state) => true; - - public override ShipPlanningState ApplyEffects(ShipPlanningState state) - { - state.CurrentObjective = "idle"; - return state; - } - - public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) - { - var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId); - if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "idle", StringComparison.Ordinal)) - { - return; - } - - ship.DefaultBehavior.Kind = "idle"; - } -} diff --git a/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs new file mode 100644 index 0000000..c1d21ed --- /dev/null +++ b/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs @@ -0,0 +1,39 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class EnqueueShipOrderHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Post("/api/ships/{shipId}/orders"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ShipOrderCommandRequest request, CancellationToken cancellationToken) + { + var shipId = Route("shipId"); + if (string.IsNullOrWhiteSpace(shipId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + try + { + var snapshot = worldService.EnqueueShipOrder(shipId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } + catch (InvalidOperationException ex) + { + AddError(ex.Message); + await SendErrorsAsync(cancellation: cancellationToken); + } + } +} diff --git a/apps/backend/Ships/Api/RemoveShipOrderHandler.cs b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs new file mode 100644 index 0000000..299b4ab --- /dev/null +++ b/apps/backend/Ships/Api/RemoveShipOrderHandler.cs @@ -0,0 +1,30 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class RemoveShipOrderRequest +{ + public string ShipId { get; set; } = string.Empty; + public string OrderId { get; set; } = string.Empty; +} + +public sealed class RemoveShipOrderHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Delete("/api/ships/{shipId}/orders/{orderId}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RemoveShipOrderRequest request, CancellationToken cancellationToken) + { + var snapshot = worldService.RemoveShipOrder(request.ShipId, request.OrderId); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs new file mode 100644 index 0000000..5c14122 --- /dev/null +++ b/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs @@ -0,0 +1,31 @@ +using FastEndpoints; + +namespace SpaceGame.Api.Ships.Api; + +public sealed class UpdateShipDefaultBehaviorHandler(WorldService worldService) : Endpoint +{ + public override void Configure() + { + Put("/api/ships/{shipId}/default-behavior"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ShipDefaultBehaviorCommandRequest request, CancellationToken cancellationToken) + { + var shipId = Route("shipId"); + if (string.IsNullOrWhiteSpace(shipId)) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + var snapshot = worldService.UpdateShipDefaultBehavior(shipId, request); + if (snapshot is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(snapshot, cancellationToken); + } +} diff --git a/apps/backend/Ships/Contracts/ShipCommands.cs b/apps/backend/Ships/Contracts/ShipCommands.cs new file mode 100644 index 0000000..a7a0ef5 --- /dev/null +++ b/apps/backend/Ships/Contracts/ShipCommands.cs @@ -0,0 +1,55 @@ +namespace SpaceGame.Api.Ships.Contracts; + +public sealed record ShipOrderCommandRequest( + string Kind, + int Priority, + bool InterruptCurrentPlan, + string? Label, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? NodeId, + string? ConstructionSiteId, + string? ModuleId, + float? WaitSeconds, + float? Radius, + int? MaxSystemRange, + bool? KnownStationsOnly); + +public sealed record ShipOrderTemplateCommandRequest( + string Kind, + string? Label, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? NodeId, + string? ConstructionSiteId, + string? ModuleId, + float? WaitSeconds, + float? Radius, + int? MaxSystemRange, + bool? KnownStationsOnly); + +public sealed record ShipDefaultBehaviorCommandRequest( + string Kind, + string? HomeSystemId, + string? HomeStationId, + string? AreaSystemId, + string? TargetEntityId, + string? PreferredItemId, + string? PreferredNodeId, + string? PreferredConstructionSiteId, + string? PreferredModuleId, + Vector3Dto? TargetPosition, + float? WaitSeconds, + float? Radius, + int? MaxSystemRange, + bool? KnownStationsOnly, + IReadOnlyList? PatrolPoints, + IReadOnlyList? RepeatOrders); diff --git a/apps/backend/Ships/Contracts/Ships.cs b/apps/backend/Ships/Contracts/Ships.cs index 27d14b3..a7e245a 100644 --- a/apps/backend/Ships/Contracts/Ships.cs +++ b/apps/backend/Ships/Contracts/Ships.cs @@ -1,5 +1,132 @@ namespace SpaceGame.Api.Ships.Contracts; +public sealed record ShipSkillProfileSnapshot( + int Navigation, + int Trade, + int Mining, + int Combat, + int Construction); + +public sealed record ShipOrderSnapshot( + string Id, + string Kind, + string Status, + int Priority, + bool InterruptCurrentPlan, + DateTimeOffset CreatedAtUtc, + string? Label, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? NodeId, + string? ConstructionSiteId, + string? ModuleId, + float WaitSeconds, + float Radius, + int? MaxSystemRange, + bool KnownStationsOnly, + string? FailureReason); + +public sealed record ShipOrderTemplateSnapshot( + string Kind, + string? Label, + string? TargetEntityId, + string? TargetSystemId, + Vector3Dto? TargetPosition, + string? SourceStationId, + string? DestinationStationId, + string? ItemId, + string? NodeId, + string? ConstructionSiteId, + string? ModuleId, + float WaitSeconds, + float Radius, + int? MaxSystemRange, + bool KnownStationsOnly); + +public sealed record DefaultBehaviorSnapshot( + string Kind, + string? HomeSystemId, + string? HomeStationId, + string? AreaSystemId, + string? TargetEntityId, + string? PreferredItemId, + string? PreferredNodeId, + string? PreferredConstructionSiteId, + string? PreferredModuleId, + Vector3Dto? TargetPosition, + float WaitSeconds, + float Radius, + int MaxSystemRange, + bool KnownStationsOnly, + IReadOnlyList PatrolPoints, + int PatrolIndex, + IReadOnlyList RepeatOrders, + int RepeatIndex); + +public sealed record ShipAssignmentSnapshot( + string CommanderId, + string? ParentCommanderId, + string Kind, + string BehaviorKind, + string Status, + string? ObjectiveId, + string? CampaignId, + string? TheaterId, + float Priority, + string? HomeSystemId, + string? HomeStationId, + string? TargetSystemId, + string? TargetEntityId, + Vector3Dto? TargetPosition, + string? ItemId, + string? Notes, + DateTimeOffset? UpdatedAtUtc); + +public sealed record ShipSubTaskSnapshot( + string Id, + string Kind, + string Status, + string Summary, + string? TargetEntityId, + string? TargetSystemId, + string? TargetNodeId, + Vector3Dto? TargetPosition, + string? ItemId, + string? ModuleId, + float Threshold, + float Amount, + float Progress, + float ElapsedSeconds, + float TotalSeconds, + string? BlockingReason); + +public sealed record ShipPlanStepSnapshot( + string Id, + string Kind, + string Status, + string Summary, + string? BlockingReason, + int CurrentSubTaskIndex, + IReadOnlyList SubTasks); + +public sealed record ShipPlanSnapshot( + string Id, + string SourceKind, + string SourceId, + string Kind, + string Status, + string Summary, + int CurrentStepIndex, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + string? InterruptReason, + string? FailureReason, + IReadOnlyList Steps); + public sealed record ShipSnapshot( string Id, string Label, @@ -10,24 +137,29 @@ public sealed record ShipSnapshot( Vector3Dto LocalVelocity, Vector3Dto TargetLocalPosition, string State, - string? OrderKind, - string DefaultBehaviorKind, - string? BehaviorPhase, - string ControllerTaskKind, - string? CommanderObjective, + IReadOnlyList OrderQueue, + DefaultBehaviorSnapshot DefaultBehavior, + ShipAssignmentSnapshot? Assignment, + ShipSkillProfileSnapshot Skills, + ShipPlanSnapshot? ActivePlan, + string? CurrentStepId, + IReadOnlyList ActiveSubTasks, + string ControlSourceKind, + string? ControlSourceId, + string? ControlReason, + string? LastReplanReason, + string? LastAccessFailureReason, string? CelestialId, string? DockedStationId, string? CommanderId, string? PolicySetId, float CargoCapacity, - float TravelSpeed, string TravelSpeedUnit, IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History, - ShipActionProgressSnapshot? CurrentAction, ShipSpatialStateSnapshot SpatialState); public sealed record ShipDelta( @@ -40,30 +172,31 @@ public sealed record ShipDelta( Vector3Dto LocalVelocity, Vector3Dto TargetLocalPosition, string State, - string? OrderKind, - string DefaultBehaviorKind, - string? BehaviorPhase, - string ControllerTaskKind, - string? CommanderObjective, + IReadOnlyList OrderQueue, + DefaultBehaviorSnapshot DefaultBehavior, + ShipAssignmentSnapshot? Assignment, + ShipSkillProfileSnapshot Skills, + ShipPlanSnapshot? ActivePlan, + string? CurrentStepId, + IReadOnlyList ActiveSubTasks, + string ControlSourceKind, + string? ControlSourceId, + string? ControlReason, + string? LastReplanReason, + string? LastAccessFailureReason, string? CelestialId, string? DockedStationId, string? CommanderId, string? PolicySetId, float CargoCapacity, - float TravelSpeed, string TravelSpeedUnit, IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History, - ShipActionProgressSnapshot? CurrentAction, ShipSpatialStateSnapshot SpatialState); -public sealed record ShipActionProgressSnapshot( - string Label, - float Progress); - public sealed record ShipSpatialStateSnapshot( string SpaceLayer, string CurrentSystemId, diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index 2cd2f84..49b51d5 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -1,4 +1,3 @@ - namespace SpaceGame.Api.Ships.Runtime; public sealed class ShipRuntime @@ -12,56 +11,147 @@ public sealed class ShipRuntime public required ShipSpatialStateRuntime SpatialState { get; set; } public Vector3 Velocity { get; set; } = Vector3.Zero; public ShipState State { get; set; } = ShipState.Idle; - public ShipOrderRuntime? Order { get; set; } public required DefaultBehaviorRuntime DefaultBehavior { get; set; } - public required ControllerTaskRuntime ControllerTask { get; set; } - public float ActionTimer { get; set; } + public List OrderQueue { get; } = []; + public ShipPlanRuntime? ActivePlan { get; set; } + public required ShipSkillProfileRuntime Skills { get; set; } + public bool NeedsReplan { get; set; } = true; + public float ReplanCooldownSeconds { get; set; } public Dictionary Inventory { get; } = new(StringComparer.Ordinal); - public string? DockedStationId { get; set; } public int? AssignedDockingPadIndex { get; set; } public string? CommanderId { get; set; } public string? PolicySetId { get; set; } + public string ControlSourceKind { get; set; } = "unassigned"; + public string? ControlSourceId { get; set; } + public string? ControlReason { get; set; } + public string? LastReplanReason { get; set; } + public string? LastAccessFailureReason { get; set; } public float Health { get; set; } - public string? TrackedActionKey { get; set; } - public float TrackedActionTotal { get; set; } + public HashSet KnownStationIds { get; } = new(StringComparer.Ordinal); public List History { get; } = []; public string LastSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class ShipSkillProfileRuntime +{ + public int Navigation { get; set; } + public int Trade { get; set; } + public int Mining { get; set; } + public int Combat { get; set; } + public int Construction { get; set; } +} + public sealed class ShipOrderRuntime { + public required string Id { get; init; } public required string Kind { get; init; } - public OrderStatus Status { get; set; } = OrderStatus.Accepted; - public required string DestinationSystemId { get; init; } - public required Vector3 DestinationPosition { get; init; } + public OrderStatus Status { get; set; } = OrderStatus.Queued; + public int Priority { get; set; } + public bool InterruptCurrentPlan { get; set; } = true; + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public string? Label { get; set; } + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string? ItemId { get; set; } + public string? NodeId { get; set; } + public string? ConstructionSiteId { get; set; } + public string? ModuleId { get; set; } + public float WaitSeconds { get; set; } + public float Radius { get; set; } + public int? MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } + public string? FailureReason { get; set; } } public sealed class DefaultBehaviorRuntime { public required string Kind { get; set; } + public string? HomeSystemId { get; set; } + public string? HomeStationId { get; set; } public string? AreaSystemId { get; set; } public string? TargetEntityId { get; set; } - public string? ItemId { get; set; } - public string? StationId { get; set; } - public string? RefineryId { get; set; } - public string? NodeId { get; set; } - public string? ModuleId { get; set; } - public string? Phase { get; set; } + public string? PreferredItemId { get; set; } + public string? PreferredNodeId { get; set; } + public string? PreferredConstructionSiteId { get; set; } + public string? PreferredModuleId { get; set; } + public Vector3? TargetPosition { get; set; } + public float WaitSeconds { get; set; } = 3f; + public float Radius { get; set; } = 24f; + public int MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } public List PatrolPoints { get; set; } = []; public int PatrolIndex { get; set; } + public List RepeatOrders { get; set; } = []; + public int RepeatIndex { get; set; } } -public sealed class ControllerTaskRuntime +public sealed class ShipOrderTemplateRuntime { - public required ControllerTaskKind Kind { get; set; } + public required string Kind { get; init; } + public string? Label { get; set; } + public string? TargetEntityId { get; set; } + public string? TargetSystemId { get; set; } + public Vector3? TargetPosition { get; set; } + public string? SourceStationId { get; set; } + public string? DestinationStationId { get; set; } + public string? ItemId { get; set; } + public string? NodeId { get; set; } + public string? ConstructionSiteId { get; set; } + public string? ModuleId { get; set; } + public float WaitSeconds { get; set; } + public float Radius { get; set; } + public int? MaxSystemRange { get; set; } + public bool KnownStationsOnly { get; set; } +} + +public sealed class ShipPlanRuntime +{ + public required string Id { get; init; } + public required AiPlanSourceKind SourceKind { get; init; } + public required string SourceId { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } + public AiPlanStatus Status { get; set; } = AiPlanStatus.Planned; + public int CurrentStepIndex { get; set; } + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public string? InterruptReason { get; set; } + public string? FailureReason { get; set; } + public List Steps { get; } = []; +} + +public sealed class ShipPlanStepRuntime +{ + public required string Id { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } + public AiPlanStepStatus Status { get; set; } = AiPlanStepStatus.Planned; + public int CurrentSubTaskIndex { get; set; } + public string? BlockingReason { get; set; } + public List SubTasks { get; } = []; +} + +public sealed class ShipSubTaskRuntime +{ + public required string Id { get; init; } + public required string Kind { get; init; } + public required string Summary { get; set; } public WorkStatus Status { get; set; } = WorkStatus.Pending; - public string? CommanderId { get; set; } public string? TargetEntityId { get; set; } public string? TargetSystemId { get; set; } public string? TargetNodeId { get; set; } public Vector3? TargetPosition { get; set; } - public float Threshold { get; set; } public string? ItemId { get; set; } + public string? ModuleId { get; set; } + public float Threshold { get; set; } + public float Amount { get; set; } + public float ElapsedSeconds { get; set; } + public float TotalSeconds { get; set; } + public float Progress { get; set; } + public string? BlockingReason { get; set; } } diff --git a/apps/backend/Ships/Simulation/ShipAiService.cs b/apps/backend/Ships/Simulation/ShipAiService.cs new file mode 100644 index 0000000..a4b59c1 --- /dev/null +++ b/apps/backend/Ships/Simulation/ShipAiService.cs @@ -0,0 +1,2708 @@ +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; +using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; +using static SpaceGame.Api.Stations.Simulation.StationSimulationService; + +namespace SpaceGame.Api.Ships.Simulation; + +internal sealed class ShipAiService +{ + private const float WarpEngageDistanceKilometers = 250_000f; + private const float FrigateDps = 7f; + private const float DestroyerDps = 12f; + private const float CruiserDps = 18f; + private const float CapitalDps = 26f; + + internal void UpdateShip(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + { + if (ship.ReplanCooldownSeconds > 0f) + { + ship.ReplanCooldownSeconds = MathF.Max(0f, ship.ReplanCooldownSeconds - deltaSeconds); + } + + var previousState = ship.State; + var previousPlanId = ship.ActivePlan?.Id; + var previousStepId = GetCurrentStep(ship.ActivePlan)?.Id; + + EnsurePlan(world, ship, events); + ExecutePlan(world, ship, deltaSeconds, events); + TrackHistory(ship); + EmitStateEvents(ship, previousState, previousPlanId, previousStepId, events); + } + + private void EnsurePlan(SimulationWorld world, ShipRuntime ship, ICollection events) + { + var emergencyPlan = BuildEmergencyPlan(world, ship); + if (emergencyPlan is not null) + { + ship.LastReplanReason = "rule-safety"; + ReplacePlan(ship, emergencyPlan, "rule-safety", events); + return; + } + + var topOrder = GetTopOrder(ship); + if (topOrder is not null && topOrder.Status == OrderStatus.Queued) + { + topOrder.Status = OrderStatus.Active; + } + + var desiredSourceKind = topOrder is null ? AiPlanSourceKind.DefaultBehavior : AiPlanSourceKind.Order; + var desiredSourceId = topOrder?.Id ?? ResolveBehaviorSource(world, ship).SourceId; + var currentPlan = ship.ActivePlan; + + if (currentPlan is not null + && currentPlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed and not AiPlanStatus.Interrupted + && currentPlan.SourceKind == desiredSourceKind + && string.Equals(currentPlan.SourceId, desiredSourceId, StringComparison.Ordinal) + && !ship.NeedsReplan) + { + return; + } + + if (ship.ReplanCooldownSeconds > 0f && currentPlan is null) + { + return; + } + + ShipPlanRuntime? nextPlan = desiredSourceKind == AiPlanSourceKind.Order + ? BuildOrderPlan(world, ship, topOrder!) + : BuildBehaviorPlan(world, ship); + + if (nextPlan is null) + { + nextPlan = CreateIdlePlan(ship, desiredSourceKind, desiredSourceId, "No viable plan"); + } + + if (nextPlan.Kind != "idle") + { + ship.LastAccessFailureReason = null; + } + + ReplacePlan(ship, nextPlan, "replanned", events); + } + + private void ExecutePlan(SimulationWorld world, ShipRuntime ship, float deltaSeconds, ICollection events) + { + var plan = ship.ActivePlan; + if (plan is null) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return; + } + + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + CompletePlan(ship, plan, events); + return; + } + + plan.Status = AiPlanStatus.Running; + plan.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var step = plan.Steps[plan.CurrentStepIndex]; + if (step.Status == AiPlanStepStatus.Planned) + { + step.Status = AiPlanStepStatus.Running; + } + + if (step.CurrentSubTaskIndex >= step.SubTasks.Count) + { + CompleteStep(plan, step); + return; + } + + var subTask = step.SubTasks[step.CurrentSubTaskIndex]; + if (subTask.Status == WorkStatus.Pending) + { + subTask.Status = WorkStatus.Active; + } + + var outcome = UpdateSubTask(world, ship, step, subTask, deltaSeconds); + switch (outcome) + { + case SubTaskOutcome.Active: + step.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStepStatus.Blocked : AiPlanStepStatus.Running; + plan.Status = subTask.Status == WorkStatus.Blocked ? AiPlanStatus.Blocked : AiPlanStatus.Running; + return; + case SubTaskOutcome.Completed: + subTask.Status = WorkStatus.Completed; + subTask.Progress = 1f; + step.CurrentSubTaskIndex += 1; + step.BlockingReason = null; + if (step.CurrentSubTaskIndex >= step.SubTasks.Count) + { + CompleteStep(plan, step); + } + return; + case SubTaskOutcome.Failed: + subTask.Status = WorkStatus.Failed; + step.Status = AiPlanStepStatus.Failed; + plan.Status = AiPlanStatus.Failed; + plan.FailureReason = subTask.BlockingReason ?? "subtask-failed"; + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.5f; + ship.LastReplanReason = plan.FailureReason; + return; + } + } + + private static void CompleteStep(ShipPlanRuntime plan, ShipPlanStepRuntime step) + { + step.Status = AiPlanStepStatus.Completed; + step.BlockingReason = null; + plan.CurrentStepIndex += 1; + if (plan.CurrentStepIndex >= plan.Steps.Count) + { + plan.Status = AiPlanStatus.Completed; + } + } + + private static void CompletePlan(ShipRuntime ship, ShipPlanRuntime plan, ICollection events) + { + plan.Status = AiPlanStatus.Completed; + var completedOrder = plan.SourceKind == AiPlanSourceKind.Order + ? ship.OrderQueue.FirstOrDefault(order => order.Id == plan.SourceId) + : null; + if (completedOrder is not null) + { + completedOrder.Status = OrderStatus.Completed; + ship.OrderQueue.RemoveAll(order => order.Id == completedOrder.Id); + } + else if (plan.SourceKind == AiPlanSourceKind.DefaultBehavior + && string.Equals(ship.DefaultBehavior.Kind, "repeat-orders", StringComparison.Ordinal) + && ship.DefaultBehavior.RepeatOrders.Count > 0) + { + ship.DefaultBehavior.RepeatIndex = (ship.DefaultBehavior.RepeatIndex + 1) % ship.DefaultBehavior.RepeatOrders.Count; + } + + ship.ActivePlan = null; + ship.NeedsReplan = true; + ship.ReplanCooldownSeconds = 0.25f; + ship.LastReplanReason = "plan-completed"; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-completed", $"{ship.Definition.Label} completed {plan.Kind}.", DateTimeOffset.UtcNow)); + } + + private void ReplacePlan(ShipRuntime ship, ShipPlanRuntime nextPlan, string reason, ICollection events) + { + if (ship.ActivePlan is not null && ship.ActivePlan.Status is not AiPlanStatus.Completed and not AiPlanStatus.Failed) + { + ship.ActivePlan.Status = AiPlanStatus.Interrupted; + ship.ActivePlan.InterruptReason = reason; + } + + ship.ActivePlan = nextPlan; + ship.NeedsReplan = false; + ship.ReplanCooldownSeconds = 0f; + ship.LastReplanReason = reason; + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-updated", $"{ship.Definition.Label} planned {nextPlan.Kind}.", DateTimeOffset.UtcNow)); + } + + private ShipPlanRuntime? BuildEmergencyPlan(SimulationWorld world, ShipRuntime ship) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + if (policy is null) + { + return null; + } + + var hullRatio = ship.Definition.MaxHealth <= 0.01f ? 1f : ship.Health / ship.Definition.MaxHealth; + if (hullRatio > policy.FleeHullRatio) + { + return null; + } + + var hostileNearby = world.Ships.Any(candidate => + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + candidate.SystemId == ship.SystemId && + candidate.Position.DistanceTo(ship.Position) <= 200f); + if (!hostileNearby) + { + return null; + } + + var safeStation = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == ship.SystemId ? 1 : 0) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-safety-{Guid.NewGuid():N}", + SourceKind = AiPlanSourceKind.Rule, + SourceId = ShipOrderKinds.Flee, + Kind = "safety-flee", + Summary = "Emergency retreat", + }; + + if (safeStation is null) + { + plan.Steps.Add(CreateStep("step-flee-hold", "hold-position", "Hold position away from hostiles", + [ + CreateSubTask("sub-flee-hold", ShipTaskKinds.HoldPosition, "Hold safe position", ship.SystemId, ship.Position, null, 0f, 3f) + ])); + return plan; + } + + plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station", + [ + CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(world.Balance.ArrivalThreshold, safeStation.Radius + 12f), 0f) + ])); + plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station", + [ + CreateSubTask("sub-flee-dock", ShipTaskKinds.Dock, $"Dock at {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, 4f, 0f) + ])); + return plan; + } + + private ShipPlanRuntime? BuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + return order.Kind switch + { + var kind when string.Equals(kind, ShipOrderKinds.Move, StringComparison.Ordinal) => BuildMovePlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAtStation, StringComparison.Ordinal) => BuildDockOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.DockAndWait, StringComparison.Ordinal) => BuildDockAndWaitOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyAndWait, StringComparison.Ordinal) => BuildFlyAndWaitOrderPlan(ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FlyToObject, StringComparison.Ordinal) => BuildFlyToObjectOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.FollowShip, StringComparison.Ordinal) => BuildFollowShipOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.TradeRoute, StringComparison.Ordinal) => BuildTradeOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal) => BuildMineOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.BuildAtSite, StringComparison.Ordinal) => BuildBuildOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.AttackTarget, StringComparison.Ordinal) => BuildAttackOrderPlan(world, ship, order), + var kind when string.Equals(kind, ShipOrderKinds.HoldPosition, StringComparison.Ordinal) => BuildHoldOrderPlan(ship, order), + _ => null, + }; + } + + private ShipPlanRuntime? BuildBehaviorPlan(SimulationWorld world, ShipRuntime ship) + { + var (behaviorKind, sourceId) = ResolveBehaviorSource(world, ship); + return behaviorKind switch + { + "local-auto-mine" => BuildMiningBehaviorPlan(world, ship, "local-auto-mine", sourceId), + "advanced-auto-mine" => BuildMiningBehaviorPlan(world, ship, "advanced-auto-mine", sourceId), + "expert-auto-mine" => BuildMiningBehaviorPlan(world, ship, "expert-auto-mine", sourceId), + "local-auto-trade" => BuildTradeBehaviorPlan(world, ship, "local-auto-trade", sourceId), + "advanced-auto-trade" => BuildTradeBehaviorPlan(world, ship, "advanced-auto-trade", sourceId), + "fill-shortages" => BuildTradeBehaviorPlan(world, ship, "fill-shortages", sourceId), + "find-build-tasks" => BuildTradeBehaviorPlan(world, ship, "find-build-tasks", sourceId), + "revisit-known-stations" => BuildTradeBehaviorPlan(world, ship, "revisit-known-stations", sourceId), + "supply-fleet" => BuildTradeBehaviorPlan(world, ship, "supply-fleet", sourceId), + "construct-station" => BuildConstructionBehaviorPlan(world, ship, sourceId), + "attack-target" => BuildAttackBehaviorPlan(world, ship, sourceId), + "protect-position" => BuildProtectPositionBehaviorPlan(world, ship, sourceId), + "protect-ship" => BuildProtectShipBehaviorPlan(world, ship, sourceId), + "protect-station" => BuildProtectStationBehaviorPlan(world, ship, sourceId), + "police" => BuildPoliceBehaviorPlan(world, ship, sourceId), + "patrol" => BuildPatrolBehaviorPlan(world, ship, sourceId), + "dock-and-wait" => BuildDockAndWaitBehaviorPlan(world, ship, sourceId), + "fly-and-wait" => BuildFlyAndWaitBehaviorPlan(ship, sourceId), + "fly-to-object" => BuildFlyToObjectBehaviorPlan(world, ship, sourceId), + "follow-ship" => BuildFollowShipBehaviorPlan(world, ship, sourceId), + "hold-position" => BuildBehaviorHoldPositionPlan(ship, sourceId), + "auto-salvage" => BuildAutoSalvageBehaviorPlan(world, ship, sourceId), + "repeat-orders" => BuildRepeatOrdersBehaviorPlan(world, ship, sourceId), + _ => CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Idle"), + }; + } + + private static (string BehaviorKind, string SourceId) ResolveBehaviorSource(SimulationWorld world, ShipRuntime ship) + { + var assignment = ResolveAssignment(world, ship); + return assignment is null + ? (ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind) + : (assignment.BehaviorKind, assignment.ObjectiveId); + } + + private ShipPlanRuntime BuildMovePlan(ShipRuntime ship, ShipOrderRuntime order) + { + var targetSystemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "move", + order.Label ?? "Move order", + [ + CreateStep("step-move", "travel", order.Label ?? "Travel", + [ + CreateSubTask("sub-move-travel", ShipTaskKinds.Travel, order.Label ?? "Travel", targetSystemId, targetPosition, order.TargetEntityId, 0f, 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildDockOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "dock-at-station", + order.Label ?? $"Dock at {station.Label}", + [ + CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}", + [ + CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(world.Balance.ArrivalThreshold, station.Radius + 12f), 0f) + ]), + CreateStep("step-dock", "dock", $"Dock at {station.Label}", + [ + CreateSubTask("sub-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildTradeOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + if (order.SourceStationId is null || order.DestinationStationId is null || order.ItemId is null) + { + order.FailureReason = "trade-order-incomplete"; + return null; + } + + var route = ResolveTradeRoute(world, order.ItemId, order.SourceStationId, order.DestinationStationId); + if (route is null) + { + order.FailureReason = "trade-route-missing"; + return null; + } + + return BuildTradePlan(ship, AiPlanSourceKind.Order, order.Id, route, order.Label ?? route.Summary); + } + + private ShipPlanRuntime? BuildMineOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var homeStation = ResolveStation(world, order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); + var node = ResolveNode(world, order.NodeId); + if (homeStation is null || node is null) + { + order.FailureReason = "mine-order-incomplete"; + return null; + } + + return BuildMiningPlan(ship, AiPlanSourceKind.Order, order.Id, node, homeStation, order.Label ?? $"Mine {node.ItemId}"); + } + + private ShipPlanRuntime? BuildBuildOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (order.ConstructionSiteId ?? order.TargetEntityId)); + if (site is null) + { + order.FailureReason = "construction-site-missing"; + return null; + } + + var supportStation = ResolveSupportStation(world, ship, site); + if (supportStation is null) + { + order.FailureReason = "support-station-missing"; + return null; + } + + return BuildConstructionPlan(ship, AiPlanSourceKind.Order, order.Id, site, supportStation, order.Label ?? $"Build {site.BlueprintId}"); + } + + private ShipPlanRuntime? BuildAttackOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetId = order.TargetEntityId; + if (targetId is null) + { + order.FailureReason = "attack-target-missing"; + return null; + } + + return BuildAttackPlan(ship, AiPlanSourceKind.Order, order.Id, targetId, order.TargetSystemId, order.Label ?? "Attack target"); + } + + private ShipPlanRuntime BuildHoldOrderPlan(ShipRuntime ship, ShipOrderRuntime order) + { + return CreatePlan( + ship, + AiPlanSourceKind.Order, + order.Id, + "hold-position", + order.Label ?? "Hold position", + [ + CreateStep("step-hold", "hold-position", order.Label ?? "Hold position", + [ + CreateSubTask("sub-hold", ShipTaskKinds.HoldPosition, order.Label ?? "Hold position", order.TargetSystemId ?? ship.SystemId, order.TargetPosition ?? ship.Position, order.TargetEntityId, 0f, 3f) + ]) + ]); + } + + private ShipPlanRuntime? BuildDockAndWaitOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var station = ResolveStation(world, order.TargetEntityId ?? order.DestinationStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + order.FailureReason = "station-missing"; + return null; + } + + return BuildDockAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, station, MathF.Max(1f, order.WaitSeconds), order.Label ?? $"Dock and wait at {station.Label}"); + } + + private ShipPlanRuntime BuildFlyAndWaitOrderPlan(ShipRuntime ship, ShipOrderRuntime order) + { + var systemId = order.TargetSystemId ?? ship.SystemId; + var targetPosition = order.TargetPosition ?? ship.Position; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.Order, order.Id, systemId, targetPosition, MathF.Max(1f, order.WaitSeconds), order.Label ?? "Fly and wait"); + } + + private ShipPlanRuntime? BuildFlyToObjectOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetEntityId = order.TargetEntityId; + if (targetEntityId is null) + { + order.FailureReason = "target-missing"; + return null; + } + + var objectTarget = ResolveObjectTarget(world, targetEntityId); + if (objectTarget is null) + { + order.FailureReason = "target-missing"; + return null; + } + + return BuildFlyToObjectPlan(ship, AiPlanSourceKind.Order, order.Id, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, order.Label ?? $"Fly to {targetEntityId}"); + } + + private ShipPlanRuntime? BuildFollowShipOrderPlan(SimulationWorld world, ShipRuntime ship, ShipOrderRuntime order) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == order.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + order.FailureReason = "target-ship-missing"; + return null; + } + + return BuildFollowShipPlan(ship, AiPlanSourceKind.Order, order.Id, targetShip, MathF.Max(order.Radius, 18f), MathF.Max(2f, order.WaitSeconds), order.Label ?? $"Follow {targetShip.Definition.Label}"); + } + + private ShipPlanRuntime? BuildMiningBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (homeStation is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No home station"); + } + + var opportunity = SelectMiningOpportunity(world, ship, homeStation, assignment, behaviorKind); + return opportunity is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No mineable node") + : BuildMiningPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, opportunity.Node, opportunity.DropOffStation, opportunity.Summary); + } + + private ShipPlanRuntime BuildMiningPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ResourceNodeRuntime node, StationRuntime homeStation, string summary) + { + var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); + return CreatePlan( + ship, + sourceKind, + sourceId, + "mine-and-deliver", + summary, + [ + CreateStep("step-mine", "mine", $"Mine {node.ItemId}", + [ + CreateSubTask("sub-mine-travel", ShipTaskKinds.Travel, $"Travel to {node.ItemId} node", node.SystemId, extractionPosition, node.Id, 8f, 0f), + CreateSubTask("sub-mine", ShipTaskKinds.MineNode, $"Mine {node.ItemId}", node.SystemId, extractionPosition, node.Id, 8f, ship.Definition.CargoCapacity) + ]), + CreateStep("step-deliver", "deliver", $"Deliver {node.ItemId} to {homeStation.Label}", + [ + CreateSubTask("sub-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), + CreateSubTask("sub-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity), + CreateSubTask("sub-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(4f, 12f), 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildTradeBehaviorPlan(SimulationWorld world, ShipRuntime ship, string behaviorKind, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (string.Equals(behaviorKind, "supply-fleet", StringComparison.Ordinal)) + { + var fleetPlan = SelectFleetSupplyPlan(world, ship, homeStation); + return fleetPlan is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No fleet to supply") + : BuildFleetSupplyPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, fleetPlan); + } + + var route = SelectTradeRoute(world, ship, homeStation, behaviorKind, ship.DefaultBehavior.KnownStationsOnly); + if (route is not null) + { + return BuildTradePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, route, route.Summary); + } + + if (string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) + && SelectKnownStationVisit(world, ship, homeStation) is { } visitStation) + { + return BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, visitStation, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Revisit {visitStation.Label}"); + } + + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No trade route"); + } + + private ShipPlanRuntime BuildTradePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, TradeRoutePlan route, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "trade-route", + summary, + [ + CreateStep("step-acquire", "acquire-cargo", $"Load {route.ItemId} at {route.SourceStation.Label}", + [ + CreateSubTask("sub-acquire-travel", ShipTaskKinds.Travel, $"Travel to {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(route.SourceStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-acquire-dock", ShipTaskKinds.Dock, $"Dock at {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 4f, 0f), + CreateSubTask("sub-acquire-load", ShipTaskKinds.LoadCargo, $"Load {route.ItemId}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), + CreateSubTask("sub-acquire-undock", ShipTaskKinds.Undock, $"Undock from {route.SourceStation.Label}", route.SourceStation.SystemId, route.SourceStation.Position, route.SourceStation.Id, MathF.Max(4f, 12f), 0f) + ]), + CreateStep("step-deliver", "deliver-cargo", $"Deliver {route.ItemId} to {route.DestinationStation.Label}", + [ + CreateSubTask("sub-route-travel", ShipTaskKinds.Travel, $"Travel to {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(route.DestinationStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-route-dock", ShipTaskKinds.Dock, $"Dock at {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 4f, 0f), + CreateSubTask("sub-route-unload", ShipTaskKinds.UnloadCargo, $"Unload {route.ItemId}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, 0f, ship.Definition.CargoCapacity, itemId: route.ItemId), + CreateSubTask("sub-route-undock", ShipTaskKinds.Undock, $"Undock from {route.DestinationStation.Label}", route.DestinationStation.SystemId, route.DestinationStation.Position, route.DestinationStation.Id, MathF.Max(4f, 12f), 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildFleetSupplyPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, FleetSupplyPlan plan) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "supply-fleet", + plan.Summary, + [ + CreateStep("step-fleet-acquire", "acquire-cargo", $"Load {plan.ItemId} at {plan.SourceStation.Label}", + [ + CreateSubTask("sub-fleet-source-travel", ShipTaskKinds.Travel, $"Travel to {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, MathF.Max(plan.SourceStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-fleet-source-dock", ShipTaskKinds.Dock, $"Dock at {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 4f, 0f), + CreateSubTask("sub-fleet-source-load", ShipTaskKinds.LoadCargo, $"Load {plan.ItemId}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 0f, plan.Amount, itemId: plan.ItemId), + CreateSubTask("sub-fleet-source-undock", ShipTaskKinds.Undock, $"Undock from {plan.SourceStation.Label}", plan.SourceStation.SystemId, plan.SourceStation.Position, plan.SourceStation.Id, 12f, 0f), + ]), + CreateStep("step-fleet-deliver", "deliver-fleet", $"Deliver {plan.ItemId} to {plan.TargetShip.Definition.Label}", + [ + CreateSubTask("sub-fleet-follow", ShipTaskKinds.FollowTarget, $"Rendezvous with {plan.TargetShip.Definition.Label}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, 6f), + CreateSubTask("sub-fleet-transfer", ShipTaskKinds.TransferCargoToShip, $"Transfer {plan.ItemId}", plan.TargetShip.SystemId, plan.TargetShip.Position, plan.TargetShip.Id, plan.Radius, plan.Amount, itemId: plan.ItemId), + ]) + ]); + } + + private ShipPlanRuntime? BuildConstructionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == (assignment?.TargetEntityId ?? ship.DefaultBehavior.PreferredConstructionSiteId)) + ?? world.ConstructionSites + .Where(candidate => candidate.FactionId == ship.FactionId && candidate.State is ConstructionSiteStateKinds.Active or ConstructionSiteStateKinds.Planned) + .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (site is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No construction site"); + } + + var supportStation = ResolveSupportStation(world, ship, site); + return supportStation is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No support station") + : BuildConstructionPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, site, supportStation, $"Build {site.BlueprintId}"); + } + + private ShipPlanRuntime BuildConstructionPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ConstructionSiteRuntime site, StationRuntime supportStation, string summary) + { + var targetPosition = site.StationId is null ? supportStation.Position : supportStation.Position; + return CreatePlan( + ship, + sourceKind, + sourceId, + "construction-support", + summary, + [ + CreateStep("step-construction-deliver", "deliver-materials", $"Deliver materials to {site.Id}", + [ + CreateSubTask("sub-construction-travel", ShipTaskKinds.Travel, $"Travel to support station {supportStation.Label}", supportStation.SystemId, targetPosition, supportStation.Id, MathF.Max(supportStation.Radius + 18f, 18f), 0f), + CreateSubTask("sub-construction-deliver", ShipTaskKinds.DeliverConstruction, $"Deliver materials to {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + ]), + CreateStep("step-construction-build", "build-site", $"Build {site.Id}", + [ + CreateSubTask("sub-construction-build", ShipTaskKinds.BuildConstructionSite, $"Build {site.Id}", site.SystemId, site.CelestialId is null ? supportStation.Position : supportStation.Position, site.Id, 12f, 0f) + ]) + ]); + } + + private ShipPlanRuntime? BuildAttackBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var targetId = assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + if (targetId is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetId, assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId, "Attack target"); + } + + private ShipPlanRuntime BuildAttackPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string? targetSystemId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "attack-target", + summary, + [ + CreateStep("step-attack", "attack-target", summary, + [ + CreateSubTask("sub-attack", ShipTaskKinds.AttackTarget, summary, targetSystemId ?? ship.SystemId, ship.Position, targetEntityId, 26f, 0f) + ]) + ]); + } + + private ShipPlanRuntime BuildPatrolBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var patrolSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var protectPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var patrolThreat = SelectThreatTarget(world, ship, patrolSystemId, protectPosition, MathF.Max(60f, ship.DefaultBehavior.Radius)); + if (patrolThreat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, patrolThreat.EntityId, patrolThreat.SystemId, "Patrol intercept"); + } + + var patrolPoints = ship.DefaultBehavior.PatrolPoints; + Vector3 targetPosition; + string targetSystemId; + if (patrolPoints.Count > 0) + { + var index = ship.DefaultBehavior.PatrolIndex % patrolPoints.Count; + targetPosition = patrolPoints[index]; + ship.DefaultBehavior.PatrolIndex = (index + 1) % patrolPoints.Count; + targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + } + else if (ResolveStation(world, ship.DefaultBehavior.HomeStationId ?? ResolveAssignment(world, ship)?.HomeStationId) is { } homeStation) + { + var patrolRadius = homeStation.Radius + 90f; + targetPosition = new Vector3(homeStation.Position.X + patrolRadius, homeStation.Position.Y, homeStation.Position.Z); + targetSystemId = homeStation.SystemId; + } + else + { + targetPosition = ship.Position; + targetSystemId = ship.SystemId; + } + + return CreatePlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + "patrol", + "Patrol sector", + [ + CreateStep("step-patrol-travel", "travel", "Travel patrol waypoint", + [ + CreateSubTask("sub-patrol-travel", ShipTaskKinds.Travel, "Travel patrol waypoint", targetSystemId, targetPosition, null, 10f, 0f) + ]), + CreateStep("step-patrol-hold", "hold-position", "Hold patrol waypoint", + [ + CreateSubTask("sub-patrol-hold", ShipTaskKinds.HoldPosition, "Hold patrol waypoint", targetSystemId, targetPosition, null, 0f, 2f) + ]) + ]); + } + + private ShipPlanRuntime? BuildPoliceBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var systemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? homeStation?.SystemId ?? ship.SystemId; + var areaPosition = homeStation?.Position ?? ship.DefaultBehavior.TargetPosition ?? ship.Position; + var contact = SelectPoliceContact(world, ship, systemId, areaPosition, MathF.Max(80f, ship.DefaultBehavior.Radius)); + if (contact is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + return contact.Engage + ? BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, "Police engage") + : BuildFollowPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, contact.EntityId, contact.SystemId, contact.Position, MathF.Max(14f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Police inspect"); + } + + private ShipPlanRuntime BuildProtectPositionBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var targetSystemId = assignment?.TargetSystemId ?? ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + var targetPosition = ship.DefaultBehavior.TargetPosition ?? assignment?.TargetPosition ?? ship.Position; + var threat = SelectThreatTarget(world, ship, targetSystemId, targetPosition, MathF.Max(90f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, "Protect position"); + } + + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Protect position"); + } + + private ShipPlanRuntime BuildProtectShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var guardTarget = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); + if (guardTarget is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + var threat = SelectThreatTarget(world, ship, guardTarget.SystemId, guardTarget.Position, MathF.Max(90f, ship.DefaultBehavior.Radius), excludeEntityId: guardTarget.Id); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {guardTarget.Definition.Label}"); + } + + return BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, guardTarget, MathF.Max(18f, ship.DefaultBehavior.Radius * 0.5f), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Escort {guardTarget.Definition.Label}"); + } + + private ShipPlanRuntime BuildProtectStationBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var station = ResolveStation(world, assignment?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + if (station is null) + { + return BuildPatrolBehaviorPlan(world, ship, sourceId); + } + + var threat = SelectThreatTarget(world, ship, station.SystemId, station.Position, MathF.Max(station.Radius + 80f, ship.DefaultBehavior.Radius)); + if (threat is not null) + { + return BuildAttackPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, threat.EntityId, threat.SystemId, $"Protect {station.Label}"); + } + + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station.SystemId, GetFormationPosition(station.Position, ship.Id, MathF.Max(station.Radius + 18f, ship.DefaultBehavior.Radius)), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Guard {station.Label}"); + } + + private ShipPlanRuntime BuildDockAndWaitBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var station = ResolveStation(world, ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId ?? ship.DefaultBehavior.HomeStationId); + return station is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No station to dock") + : BuildDockAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, station, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Dock and wait at {station.Label}"); + } + + private ShipPlanRuntime BuildFlyAndWaitBehaviorPlan(ShipRuntime ship, string sourceId) + { + var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; + var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Fly and wait"); + } + + private ShipPlanRuntime BuildFlyToObjectBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var targetEntityId = ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + var objectTarget = ResolveObjectTarget(world, targetEntityId); + return objectTarget is null || targetEntityId is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No object target") + : BuildFlyToObjectPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, objectTarget.Value.SystemId, objectTarget.Value.Position, targetEntityId, "Fly to object"); + } + + private ShipPlanRuntime BuildFollowShipBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == (ResolveAssignment(world, ship)?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId) && candidate.Health > 0f); + return targetShip is null + ? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No ship to follow") + : BuildFollowShipPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetShip, MathF.Max(16f, ship.DefaultBehavior.Radius), MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), $"Follow {targetShip.Definition.Label}"); + } + + private ShipPlanRuntime BuildBehaviorHoldPositionPlan(ShipRuntime ship, string sourceId) + { + var targetPosition = ship.DefaultBehavior.TargetPosition ?? ship.Position; + var targetSystemId = ship.DefaultBehavior.AreaSystemId ?? ship.SystemId; + return BuildFlyAndWaitPlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, targetSystemId, targetPosition, MathF.Max(2f, ship.DefaultBehavior.WaitSeconds), "Hold position"); + } + + private ShipPlanRuntime BuildAutoSalvageBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + var assignment = ResolveAssignment(world, ship); + var homeStation = ResolveStation(world, assignment?.HomeStationId ?? ship.DefaultBehavior.HomeStationId); + var salvage = SelectSalvageOpportunity(world, ship, homeStation); + if (salvage is null || homeStation is null) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No salvage target"); + } + + var approach = GetFormationPosition(salvage.Wreck.Position, ship.Id, MathF.Max(8f, ship.DefaultBehavior.Radius * 0.25f)); + return CreatePlan( + ship, + AiPlanSourceKind.DefaultBehavior, + sourceId, + "auto-salvage", + salvage.Summary, + [ + CreateStep("step-salvage-collect", "salvage", $"Salvage {salvage.Wreck.ItemId}", + [ + CreateSubTask("sub-salvage-travel", ShipTaskKinds.Travel, $"Travel to wreck {salvage.Wreck.Id}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, 0f), + CreateSubTask("sub-salvage-work", ShipTaskKinds.SalvageWreck, $"Salvage {salvage.Wreck.ItemId}", salvage.Wreck.SystemId, approach, salvage.Wreck.Id, 8f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), + ]), + CreateStep("step-salvage-deliver", "deliver-salvage", $"Deliver salvage to {homeStation.Label}", + [ + CreateSubTask("sub-salvage-deliver-travel", ShipTaskKinds.Travel, $"Travel to {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, MathF.Max(homeStation.Radius + 12f, 12f), 0f), + CreateSubTask("sub-salvage-deliver-dock", ShipTaskKinds.Dock, $"Dock at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 4f, 0f), + CreateSubTask("sub-salvage-deliver-unload", ShipTaskKinds.UnloadCargo, $"Unload salvage at {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 0f, ship.Definition.CargoCapacity, itemId: salvage.Wreck.ItemId), + CreateSubTask("sub-salvage-deliver-undock", ShipTaskKinds.Undock, $"Undock from {homeStation.Label}", homeStation.SystemId, homeStation.Position, homeStation.Id, 12f, 0f), + ]) + ]); + } + + private ShipPlanRuntime BuildRepeatOrdersBehaviorPlan(SimulationWorld world, ShipRuntime ship, string sourceId) + { + if (ship.DefaultBehavior.RepeatOrders.Count == 0) + { + return CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "No repeat orders"); + } + + var template = ship.DefaultBehavior.RepeatOrders[ship.DefaultBehavior.RepeatIndex % ship.DefaultBehavior.RepeatOrders.Count]; + var syntheticOrder = new ShipOrderRuntime + { + Id = $"repeat-{ship.Id}-{ship.DefaultBehavior.RepeatIndex}", + Kind = template.Kind, + Label = template.Label, + TargetEntityId = template.TargetEntityId, + TargetSystemId = template.TargetSystemId, + TargetPosition = template.TargetPosition, + SourceStationId = template.SourceStationId, + DestinationStationId = template.DestinationStationId, + ItemId = template.ItemId, + NodeId = template.NodeId, + ConstructionSiteId = template.ConstructionSiteId, + ModuleId = template.ModuleId, + WaitSeconds = template.WaitSeconds, + Radius = template.Radius, + MaxSystemRange = template.MaxSystemRange, + KnownStationsOnly = template.KnownStationsOnly, + }; + + return BuildOrderPlan(world, ship, syntheticOrder) + ?? CreateIdlePlan(ship, AiPlanSourceKind.DefaultBehavior, sourceId, "Invalid repeat order"); + } + + private ShipPlanRuntime BuildDockAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, StationRuntime station, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "dock-and-wait", + summary, + [ + CreateStep("step-dock-wait-travel", "travel", $"Travel to {station.Label}", + [ + CreateSubTask("sub-dock-wait-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(station.Radius + 12f, 12f), 0f), + CreateSubTask("sub-dock-wait-dock", ShipTaskKinds.Dock, $"Dock at {station.Label}", station.SystemId, station.Position, station.Id, 4f, 0f), + CreateSubTask("sub-dock-wait-hold", ShipTaskKinds.HoldPosition, $"Wait at {station.Label}", station.SystemId, station.Position, station.Id, 0f, waitSeconds), + ]) + ]); + } + + private ShipPlanRuntime BuildFlyAndWaitPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, float waitSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "fly-and-wait", + summary, + [ + CreateStep("step-fly-wait", "fly-and-wait", summary, + [ + CreateSubTask("sub-fly-wait-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, null, 6f, 0f), + CreateSubTask("sub-fly-wait-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, null, 0f, waitSeconds), + ]) + ]); + } + + private ShipPlanRuntime BuildFlyToObjectPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetSystemId, Vector3 targetPosition, string targetEntityId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "fly-to-object", + summary, + [ + CreateStep("step-fly-object", "fly-to-object", summary, + [ + CreateSubTask("sub-fly-object-travel", ShipTaskKinds.Travel, summary, targetSystemId, targetPosition, targetEntityId, 8f, 0f), + CreateSubTask("sub-fly-object-hold", ShipTaskKinds.HoldPosition, summary, targetSystemId, targetPosition, targetEntityId, 0f, MathF.Max(1f, ship.DefaultBehavior.WaitSeconds)), + ]) + ]); + } + + private ShipPlanRuntime BuildFollowShipPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, ShipRuntime targetShip, float radius, float durationSeconds, string summary) + { + return BuildFollowPlan(ship, sourceKind, sourceId, targetShip.Id, targetShip.SystemId, targetShip.Position, radius, durationSeconds, summary); + } + + private ShipPlanRuntime BuildFollowPlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string targetEntityId, string targetSystemId, Vector3 targetPosition, float radius, float durationSeconds, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "follow-ship", + summary, + [ + CreateStep("step-follow", "follow-target", summary, + [ + CreateSubTask("sub-follow", ShipTaskKinds.FollowTarget, summary, targetSystemId, targetPosition, targetEntityId, radius, durationSeconds), + ]) + ]); + } + + private ShipPlanRuntime CreateIdlePlan(ShipRuntime ship, AiPlanSourceKind sourceKind, string sourceId, string summary) + { + return CreatePlan( + ship, + sourceKind, + sourceId, + "idle", + summary, + [ + CreateStep("step-idle", "hold-position", summary, + [ + CreateSubTask("sub-idle", ShipTaskKinds.HoldPosition, summary, ship.SystemId, ship.Position, null, 0f, 1.5f) + ]) + ]); + } + + private static ShipPlanRuntime CreatePlan( + ShipRuntime ship, + AiPlanSourceKind sourceKind, + string sourceId, + string kind, + string summary, + IReadOnlyList steps) + { + var plan = new ShipPlanRuntime + { + Id = $"plan-{ship.Id}-{Guid.NewGuid():N}", + SourceKind = sourceKind, + SourceId = sourceId, + Kind = kind, + Summary = summary, + }; + plan.Steps.AddRange(steps); + return plan; + } + + private static ShipPlanStepRuntime CreateStep(string id, string kind, string summary, IReadOnlyList subTasks) + { + var step = new ShipPlanStepRuntime + { + Id = id, + Kind = kind, + Summary = summary, + }; + step.SubTasks.AddRange(subTasks); + return step; + } + + private static ShipSubTaskRuntime CreateSubTask( + string id, + string kind, + string summary, + string targetSystemId, + Vector3 targetPosition, + string? targetEntityId, + float threshold, + float amount, + string? itemId = null, + string? moduleId = null, + string? targetNodeId = null) => + new() + { + Id = id, + Kind = kind, + Summary = summary, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + TargetEntityId = targetEntityId, + TargetNodeId = targetNodeId, + ItemId = itemId, + ModuleId = moduleId, + Threshold = threshold, + Amount = amount, + }; + + private SubTaskOutcome UpdateSubTask(SimulationWorld world, ShipRuntime ship, ShipPlanStepRuntime step, ShipSubTaskRuntime subTask, float deltaSeconds) + { + return subTask.Kind switch + { + var kind when string.Equals(kind, ShipTaskKinds.Travel, StringComparison.Ordinal) => UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: true), + var kind when string.Equals(kind, ShipTaskKinds.FollowTarget, StringComparison.Ordinal) => UpdateFollowSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Dock, StringComparison.Ordinal) => UpdateDockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.Undock, StringComparison.Ordinal) => UpdateUndockSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.LoadCargo, StringComparison.Ordinal) => UpdateLoadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.UnloadCargo, StringComparison.Ordinal) => UpdateUnloadCargoSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.TransferCargoToShip, StringComparison.Ordinal) => UpdateTransferCargoToShipSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.MineNode, StringComparison.Ordinal) => UpdateMineSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.SalvageWreck, StringComparison.Ordinal) => UpdateSalvageSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.DeliverConstruction, StringComparison.Ordinal) => UpdateDeliverConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.BuildConstructionSite, StringComparison.Ordinal) => UpdateBuildConstructionSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.AttackTarget, StringComparison.Ordinal) => UpdateAttackSubTask(world, ship, subTask, deltaSeconds), + var kind when string.Equals(kind, ShipTaskKinds.HoldPosition, StringComparison.Ordinal) => UpdateHoldSubTask(ship, subTask, deltaSeconds), + _ => SubTaskOutcome.Failed, + }; + } + + private SubTaskOutcome UpdateHoldSubTask(ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = subTask.TargetPosition ?? ship.Position; + ship.Position = ship.Position.MoveToward(ship.TargetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(ship.TargetPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.1f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateFollowSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "follow-target-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 16f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + subTask.BlockingReason = null; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.HoldingPosition; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + return AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.5f, subTask.Amount)) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateTravelSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds, bool completeOnArrival) + { + if (subTask.TargetPosition is null || subTask.TargetSystemId is null) + { + subTask.BlockingReason = "travel-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = ResolveCurrentTargetPosition(world, subTask); + var targetCelestial = ResolveTravelTargetCelestial(world, subTask, targetPosition); + ship.TargetPosition = targetPosition; + + if (ship.SystemId != subTask.TargetSystemId) + { + if (!HasShipCapabilities(ship.Definition, "ftl")) + { + subTask.BlockingReason = "ftl-unavailable"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var destinationEntryCelestial = ResolveSystemEntryCelestial(world, subTask.TargetSystemId); + var destinationEntryPosition = destinationEntryCelestial?.Position ?? targetPosition; + return UpdateFtlTransit(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, destinationEntryPosition, destinationEntryCelestial, completeOnArrival, targetPosition); + } + + var currentCelestial = ResolveCurrentCelestial(world, ship); + if (targetCelestial is not null + && currentCelestial is not null + && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) + { + if (!HasShipCapabilities(ship.Definition, "warp")) + { + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + } + + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + + if (targetCelestial is not null + && ship.Position.DistanceTo(targetPosition) > WarpEngageDistanceKilometers + && HasShipCapabilities(ship.Definition, "warp")) + { + return UpdateWarpTransit(world, ship, subTask, deltaSeconds, targetPosition, targetCelestial, completeOnArrival); + } + + return UpdateLocalTravel(world, ship, subTask, deltaSeconds, subTask.TargetSystemId, targetPosition, targetCelestial, completeOnArrival); + } + + private SubTaskOutcome UpdateAttackSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + var hostileStation = hostileShip is null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) + : null; + if ((hostileShip is not null && hostileShip.FactionId == ship.FactionId) + || (hostileStation is not null && hostileStation.FactionId == ship.FactionId)) + { + subTask.BlockingReason = "friendly-target"; + return SubTaskOutcome.Failed; + } + + if (hostileShip is null && hostileStation is null) + { + return SubTaskOutcome.Completed; + } + + var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; + var targetPosition = hostileShip?.Position ?? hostileStation!.Position; + var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; + subTask.TargetSystemId = targetSystemId; + subTask.TargetPosition = targetPosition; + subTask.Threshold = attackRange; + + if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.EngagingTarget; + ship.TargetPosition = targetPosition; + ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); + var damage = GetShipDamagePerSecond(ship) * deltaSeconds * GetSkillFactor(ship.Skills.Combat); + subTask.Progress = 1f; + + if (hostileShip is not null) + { + hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); + return hostileShip.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + hostileStation!.Health = MathF.Max(0f, hostileStation.Health - (damage * 0.6f)); + return hostileStation.Health <= 0f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateMineSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var node = ResolveNode(world, subTask.TargetEntityId ?? subTask.TargetNodeId); + if (node is null || !CanExtractNode(ship, node, world)) + { + subTask.BlockingReason = "node-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var targetPosition = subTask.TargetPosition ?? GetResourceHoldPosition(node.Position, ship.Id, 20f); + ship.TargetPosition = targetPosition; + if (ship.Position.DistanceTo(targetPosition) > MathF.Max(subTask.Threshold, 8f)) + { + ship.State = ShipState.MiningApproach; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + var cargoAmount = GetShipCargoAmount(ship); + if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) + { + return SubTaskOutcome.Completed; + } + + ship.State = ShipState.Mining; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.MiningCycleSeconds)) + { + return SubTaskOutcome.Active; + } + + var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); + var mined = MathF.Min(world.Balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity); + mined = MathF.Min(mined, node.OreRemaining); + if (mined <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + AddInventory(ship.Inventory, node.ItemId, mined); + node.OreRemaining = MathF.Max(0f, node.OreRemaining - mined); + if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f || node.OreRemaining <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + subTask.ElapsedSeconds = 0f; + return SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateDockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is null) + { + subTask.BlockingReason = "dock-target-missing"; + ship.State = ShipState.Blocked; + return SubTaskOutcome.Failed; + } + + var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); + if (padIndex is null) + { + ship.State = ShipState.AwaitingDock; + ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); + if (ship.Position.DistanceTo(ship.TargetPosition) > 4f) + { + ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + } + + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-for-pad"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.AssignedDockingPadIndex = padIndex; + var padPosition = GetDockingPadPosition(station, padIndex.Value); + ship.TargetPosition = padPosition; + if (ship.Position.DistanceTo(padPosition) > 4f) + { + ship.State = ShipState.DockingApproach; + ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.DockingDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Docked; + ship.DockedStationId = station.Id; + station.DockedShipIds.Add(ship.Id); + ship.KnownStationIds.Add(station.Id); + ship.Position = padPosition; + ship.TargetPosition = padPosition; + return SubTaskOutcome.Completed; + } + + private SubTaskOutcome UpdateUndockSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + return SubTaskOutcome.Completed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; + } + + var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); + ship.TargetPosition = undockTarget; + ship.State = ShipState.Undocking; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.UndockingDuration)) + { + ship.Position = GetShipDockedPosition(ship, station); + return SubTaskOutcome.Active; + } + + ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); + if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f)) + { + return SubTaskOutcome.Active; + } + + station.DockedShipIds.Remove(ship.Id); + ReleaseDockingPad(station, ship.Id); + ship.DockedStationId = null; + ship.AssignedDockingPadIndex = null; + return SubTaskOutcome.Completed; + } + + private SubTaskOutcome UpdateLoadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Loading; + var itemId = subTask.ItemId; + if (itemId is null) + { + return SubTaskOutcome.Completed; + } + + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity; + var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Trade); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId))); + if (moved > 0.01f) + { + RemoveInventory(station.Inventory, itemId, moved); + AddInventory(ship.Inventory, itemId, moved); + } + + var loadedAmount = GetInventoryAmount(ship.Inventory, itemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(loadedAmount / desiredAmount, 0f, 1f); + return availableCapacity <= 0.01f || GetInventoryAmount(station.Inventory, itemId) <= 0.01f || loadedAmount >= desiredAmount - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateUnloadCargoSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + if (ship.DockedStationId is null) + { + subTask.BlockingReason = "not-docked"; + return SubTaskOutcome.Failed; + } + + var station = ResolveStation(world, ship.DockedStationId); + if (station is null) + { + subTask.BlockingReason = "station-missing"; + return SubTaskOutcome.Failed; + } + + ship.TargetPosition = GetShipDockedPosition(ship, station); + ship.Position = ship.TargetPosition; + ship.State = ShipState.Transferring; + var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining)); + + if (subTask.ItemId is not null) + { + var moved = MathF.Min(transferRate * deltaSeconds, GetInventoryAmount(ship.Inventory, subTask.ItemId)); + var accepted = TryAddStationInventory(world, station, subTask.ItemId, moved); + RemoveInventory(ship.Inventory, subTask.ItemId, accepted); + subTask.Progress = subTask.Amount <= 0.01f + ? 1f + : Math.Clamp(1f - (GetInventoryAmount(ship.Inventory, subTask.ItemId) / subTask.Amount), 0f, 1f); + return GetInventoryAmount(ship.Inventory, subTask.ItemId) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + foreach (var (itemId, amount) in ship.Inventory.ToList().OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var moved = MathF.Min(amount, transferRate * deltaSeconds); + var accepted = TryAddStationInventory(world, station, itemId, moved); + RemoveInventory(ship.Inventory, itemId, accepted); + if (accepted > 0.01f) + { + return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + } + + return GetShipCargoAmount(ship) <= 0.01f ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateTransferCargoToShipSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var targetShip = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.Health > 0f); + if (targetShip is null) + { + subTask.BlockingReason = "target-ship-missing"; + return SubTaskOutcome.Failed; + } + + var desiredPosition = GetFormationPosition(targetShip.Position, ship.Id, MathF.Max(subTask.Threshold, 12f)); + subTask.TargetSystemId = targetShip.SystemId; + subTask.TargetPosition = desiredPosition; + if (ship.SystemId != targetShip.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 12f)) + { + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + ship.TargetPosition = desiredPosition; + ship.Position = ship.Position.MoveToward(desiredPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, ship.Position.DistanceTo(desiredPosition))); + if (subTask.ItemId is null) + { + return SubTaskOutcome.Completed; + } + + var targetCapacity = MathF.Max(0f, targetShip.Definition.CargoCapacity - GetShipCargoAmount(targetShip)); + if (targetCapacity <= 0.01f) + { + subTask.BlockingReason = "target-cargo-full"; + return SubTaskOutcome.Failed; + } + + var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation)); + var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId); + var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId))); + if (moved > 0.01f) + { + RemoveInventory(ship.Inventory, subTask.ItemId, moved); + AddInventory(targetShip.Inventory, subTask.ItemId, moved); + } + + var remaining = GetInventoryAmount(ship.Inventory, subTask.ItemId); + subTask.Progress = desiredAmount <= 0.01f ? 1f : Math.Clamp(1f - (remaining / desiredAmount), 0f, 1f); + return remaining <= 0.01f || GetShipCargoAmount(targetShip) >= targetShip.Definition.CargoCapacity - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateSalvageSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId && candidate.RemainingAmount > 0.01f); + if (wreck is null) + { + return SubTaskOutcome.Completed; + } + + var desiredPosition = subTask.TargetPosition ?? GetFormationPosition(wreck.Position, ship.Id, 8f); + ship.TargetPosition = desiredPosition; + if (ship.SystemId != wreck.SystemId || ship.Position.DistanceTo(desiredPosition) > MathF.Max(subTask.Threshold, 8f)) + { + subTask.TargetSystemId = wreck.SystemId; + subTask.TargetPosition = desiredPosition; + return UpdateTravelSubTask(world, ship, subTask, deltaSeconds, completeOnArrival: false); + } + + ship.State = ShipState.Transferring; + var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); + if (remainingCapacity <= 0.01f) + { + return SubTaskOutcome.Completed; + } + + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, world.Balance.MiningCycleSeconds * 0.8f))) + { + return SubTaskOutcome.Active; + } + + var salvageRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade)); + var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount)); + if (recovered > 0.01f) + { + AddInventory(ship.Inventory, wreck.ItemId, recovered); + wreck.RemainingAmount = MathF.Max(0f, wreck.RemainingAmount - recovered); + } + + if (wreck.RemainingAmount <= 0.01f) + { + world.Wrecks.RemoveAll(candidate => candidate.Id == wreck.Id); + } + + subTask.ElapsedSeconds = 0f; + return wreck.RemainingAmount <= 0.01f || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f + ? SubTaskOutcome.Completed + : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateDeliverConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-target-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.DeliveringConstruction; + var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Construction); + foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { + var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); + var remaining = MathF.Max(0f, required.Value - delivered); + if (remaining <= 0.01f) + { + continue; + } + + var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); + var moved = MathF.Min(remaining, MathF.Min(available, transferRate * deltaSeconds)); + if (moved <= 0.01f) + { + continue; + } + + RemoveInventory(station.Inventory, required.Key, moved); + AddInventory(site.Inventory, required.Key, moved); + AddInventory(site.DeliveredItems, required.Key, moved); + break; + } + + subTask.Progress = site.RequiredItems.Count == 0 + ? 1f + : site.RequiredItems.Sum(required => + required.Value <= 0.01f + ? 1f + : Math.Clamp(GetInventoryAmount(site.DeliveredItems, required.Key) / required.Value, 0f, 1f)) / site.RequiredItems.Count; + return IsConstructionSiteReady(world, site) ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateBuildConstructionSubTask(SimulationWorld world, ShipRuntime ship, ShipSubTaskRuntime subTask, float deltaSeconds) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + var station = site is null ? null : ResolveSupportStation(world, ship, site); + if (site is null || station is null || site.BlueprintId is null || site.State is ConstructionSiteStateKinds.Completed or ConstructionSiteStateKinds.Destroyed) + { + subTask.BlockingReason = "construction-site-missing"; + return SubTaskOutcome.Failed; + } + + var supportPosition = ResolveSupportPosition(ship, station, site, world); + if (!IsShipWithinSupportRange(ship, supportPosition, MathF.Max(12f, subTask.Threshold))) + { + ship.State = ShipState.LocalFlight; + ship.TargetPosition = supportPosition; + ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + ship.State = ShipState.WaitingMaterials; + subTask.Status = WorkStatus.Blocked; + subTask.BlockingReason = "waiting-materials"; + return SubTaskOutcome.Active; + } + + subTask.Status = WorkStatus.Active; + subTask.BlockingReason = null; + ship.TargetPosition = supportPosition; + ship.Position = supportPosition; + ship.State = ShipState.Constructing; + site.AssignedConstructorShipIds.Add(ship.Id); + site.Progress += deltaSeconds * GetSkillFactor(ship.Skills.Construction); + subTask.Progress = recipe.Duration <= 0.01f ? 1f : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); + if (site.Progress < recipe.Duration) + { + return SubTaskOutcome.Active; + } + + if (site.StationId is null) + { + CompleteStationFoundation(world, station, site); + } + else + { + AddStationModule(world, station, site.BlueprintId); + PrepareNextConstructionSiteStep(world, station, site); + } + + site.State = ConstructionSiteStateKinds.Completed; + return SubTaskOutcome.Completed; + } + + private static bool AdvanceTimedSubTask(ShipSubTaskRuntime subTask, float deltaSeconds, float requiredSeconds) + { + subTask.TotalSeconds = requiredSeconds; + subTask.ElapsedSeconds += deltaSeconds; + subTask.Progress = requiredSeconds <= 0.01f ? 1f : Math.Clamp(subTask.ElapsedSeconds / requiredSeconds, 0f, 1f); + if (subTask.ElapsedSeconds < requiredSeconds) + { + return false; + } + + subTask.ElapsedSeconds = 0f; + return true; + } + + private SubTaskOutcome UpdateLocalTravel( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 targetPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival) + { + var distance = ship.Position.DistanceTo(targetPosition); + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.Transit = null; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f); + + if (distance <= MathF.Max(subTask.Threshold, world.Balance.ArrivalThreshold)) + { + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + ship.State = ShipState.LocalFlight; + ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); + return SubTaskOutcome.Active; + } + + private SubTaskOutcome UpdateWarpTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + Vector3 targetPosition, + CelestialRuntime targetCelestial, + bool completeOnArrival) + { + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKinds.Warp, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = targetCelestial.Id, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = targetCelestial.Id; + + var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); + if (ship.State != ShipState.Warping) + { + ship.State = ShipState.SpoolingWarp; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, spoolDuration)) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Warping; + } + + var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null + ? ship.Position.DistanceTo(targetPosition) + : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); + ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); + transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); + subTask.Progress = transit.Progress; + if (ship.Position.DistanceTo(targetPosition) > 18f) + { + return SubTaskOutcome.Active; + } + + return CompleteTransitArrival(ship, subTask.TargetSystemId ?? ship.SystemId, targetPosition, targetCelestial, completeOnArrival); + } + + private SubTaskOutcome UpdateFtlTransit( + SimulationWorld world, + ShipRuntime ship, + ShipSubTaskRuntime subTask, + float deltaSeconds, + string targetSystemId, + Vector3 entryPosition, + CelestialRuntime? targetCelestial, + bool completeOnArrival, + Vector3 finalTargetPosition) + { + var destinationNodeId = targetCelestial?.Id; + var transit = ship.SpatialState.Transit; + if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId) + { + transit = new ShipTransitRuntime + { + Regime = MovementRegimeKinds.FtlTransit, + OriginNodeId = ship.SpatialState.CurrentCelestialId, + DestinationNodeId = destinationNodeId, + StartedAtUtc = world.GeneratedAtUtc, + }; + ship.SpatialState.Transit = transit; + subTask.ElapsedSeconds = 0f; + } + + ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; + ship.SpatialState.CurrentCelestialId = null; + ship.SpatialState.DestinationNodeId = destinationNodeId; + + if (ship.State != ShipState.Ftl) + { + ship.State = ShipState.SpoolingFtl; + if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(ship.Definition.SpoolTime, 0.1f))) + { + return SubTaskOutcome.Active; + } + + ship.State = ShipState.Ftl; + } + + var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); + var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); + var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); + transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * GetSkillFactor(ship.Skills.Navigation)) * deltaSeconds / totalDistance)); + subTask.Progress = transit.Progress; + if (transit.Progress < 0.999f) + { + return SubTaskOutcome.Active; + } + + ship.Position = entryPosition; + ship.TargetPosition = finalTargetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private static SubTaskOutcome CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial, bool completeOnArrival) + { + ship.Position = targetPosition; + ship.TargetPosition = targetPosition; + ship.SystemId = targetSystemId; + ship.SpatialState.CurrentSystemId = targetSystemId; + ship.SpatialState.Transit = null; + ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; + ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; + ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; + ship.SpatialState.DestinationNodeId = targetCelestial?.Id; + ship.State = ShipState.Arriving; + return completeOnArrival ? SubTaskOutcome.Completed : SubTaskOutcome.Active; + } + + private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ShipSubTaskRuntime subTask) + { + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (ship is not null) + { + return ship.Position; + } + + var station = ResolveStation(world, subTask.TargetEntityId); + if (station is not null) + { + return station.Position; + } + + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial.Position; + } + + var wreck = world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (wreck is not null) + { + return wreck.Position; + } + } + + return subTask.TargetPosition ?? Vector3.Zero; + } + + private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ShipSubTaskRuntime subTask, Vector3 targetPosition) + { + if (!string.IsNullOrWhiteSpace(subTask.TargetEntityId)) + { + var station = ResolveStation(world, subTask.TargetEntityId); + if (station?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); + } + + var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (site?.CelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + } + + var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId); + if (celestial is not null) + { + return celestial; + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == subTask.TargetEntityId) is { } wreck) + { + return world.Celestials + .Where(candidate => candidate.SystemId == wreck.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(wreck.Position)) + .FirstOrDefault(); + } + } + + return world.Celestials + .Where(candidate => subTask.TargetSystemId is null || candidate.SystemId == subTask.TargetSystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) + .FirstOrDefault(); + } + + private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) + { + if (ship.SpatialState.CurrentCelestialId is not null) + { + return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); + } + + return world.Celestials + .Where(candidate => candidate.SystemId == ship.SystemId) + .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => + world.Celestials.FirstOrDefault(candidate => candidate.SystemId == systemId && candidate.Kind == SpatialNodeKind.Star); + + private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => + world.Systems.FirstOrDefault(candidate => candidate.Definition.Id == systemId)?.Position ?? Vector3.Zero; + + private static float GetLocalTravelSpeed(ShipRuntime ship) => + SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetWarpTravelSpeed(ShipRuntime ship) => + SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed) * GetSkillFactor(ship.Skills.Navigation); + + private static float GetSkillFactor(int skillLevel) => + Math.Clamp(1f + ((skillLevel - 3) * 0.08f), 0.75f, 1.4f); + + private static int GetEffectiveSkillLevel( + SimulationWorld world, + ShipRuntime ship, + Func captainSelector, + Func managerSelector) + { + var captainLevel = captainSelector(ship.Skills); + if (ship.CommanderId is null) + { + return captainLevel; + } + + var shipCommander = world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId); + var manager = shipCommander?.ParentCommanderId is null + ? shipCommander + : world.Commanders.FirstOrDefault(candidate => candidate.Id == shipCommander.ParentCommanderId) ?? shipCommander; + return Math.Clamp((captainLevel + (manager is null ? 3 : managerSelector(manager.Skills)) + 1) / 2, 1, 5); + } + + private static int ResolveBehaviorSystemRange(SimulationWorld world, ShipRuntime ship, string behaviorKind, int explicitRange) + { + if (explicitRange > 0) + { + return explicitRange; + } + + var tradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var miningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + var combatSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Combat, skills => skills.Strategy); + return behaviorKind switch + { + "local-auto-mine" or "local-auto-trade" => 0, + "advanced-auto-mine" => Math.Clamp(1 + ((miningSkill - 1) / 2), 1, 3), + "advanced-auto-trade" => Math.Clamp(1 + ((tradeSkill - 1) / 2), 1, 3), + "expert-auto-mine" => Math.Clamp(2 + ((miningSkill - 1) / 2), 2, Math.Max(world.Systems.Count - 1, 2)), + "fill-shortages" or "find-build-tasks" or "revisit-known-stations" or "supply-fleet" => Math.Clamp(1 + ((tradeSkill + 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + "patrol" or "police" or "protect-position" or "protect-ship" or "protect-station" => Math.Clamp(1 + ((combatSkill - 1) / 2), 1, Math.Max(world.Systems.Count - 1, 1)), + _ => Math.Max(world.Systems.Count - 1, 0), + }; + } + + private static int GetSystemDistanceTier(SimulationWorld world, string originSystemId, string targetSystemId) + { + if (string.Equals(originSystemId, targetSystemId, StringComparison.Ordinal)) + { + return 0; + } + + var originPosition = ResolveSystemGalaxyPosition(world, originSystemId); + return world.Systems + .OrderBy(system => system.Position.DistanceTo(originPosition)) + .ThenBy(system => system.Definition.Id, StringComparer.Ordinal) + .Select(system => system.Definition.Id) + .TakeWhile(systemId => !string.Equals(systemId, targetSystemId, StringComparison.Ordinal)) + .Count(); + } + + private static bool IsWithinSystemRange(SimulationWorld world, string originSystemId, string targetSystemId, int maxRange) => + maxRange < 0 || GetSystemDistanceTier(world, originSystemId, targetSystemId) <= maxRange; + + private static float GetShipDamagePerSecond(ShipRuntime ship) => + ship.Definition.Class switch + { + "frigate" => FrigateDps, + "destroyer" => DestroyerDps, + "cruiser" => CruiserDps, + "capital" => CapitalDps, + _ => 4f, + }; + + private static MiningOpportunity? SelectMiningOpportunity( + SimulationWorld world, + ShipRuntime ship, + StationRuntime homeStation, + CommanderAssignmentRuntime? assignment, + string behaviorKind) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var preferredItemId = assignment?.ItemId ?? ship.DefaultBehavior.PreferredItemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveMiningSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Mining, skills => skills.Coordination); + string? deniedReason = null; + var opportunity = world.Nodes + .Where(node => + { + if (node.OreRemaining <= 0.01f || !CanExtractNode(ship, node, world) || (preferredItemId is not null && !string.Equals(node.ItemId, preferredItemId, StringComparison.Ordinal))) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, node.SystemId, "military", out var reason)) + { + deniedReason ??= reason; + return false; + } + + return IsWithinSystemRange(world, homeStation.SystemId, node.SystemId, rangeBudget); + }) + .Select(node => + { + var buyer = SelectBestDeliveryStation(world, ship, node.ItemId, homeStation, behaviorKind); + var demandScore = GetFactionDemandScore(world, ship.FactionId, node.ItemId); + var distancePenalty = GetSystemDistanceTier(world, homeStation.SystemId, node.SystemId) * 18f; + var routeRiskPenalty = GeopoliticalSimulationService.GetSystemRouteRisk(world, node.SystemId, ship.FactionId) * 30f; + var score = (node.SystemId == homeStation.SystemId ? 55f : 0f) + + (node.OreRemaining * 0.025f) + + (demandScore * (string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal) ? 22f : 12f)) + + (effectiveMiningSkill * 10f) + - distancePenalty + - routeRiskPenalty + - node.Position.DistanceTo(ship.Position); + return new MiningOpportunity(node, buyer, score, $"Mine {node.ItemId} for {buyer.Label}"); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Node.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (opportunity is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return opportunity; + } + + private static TradeRoutePlan? SelectTradeRoute( + SimulationWorld world, + ShipRuntime ship, + StationRuntime? homeStation, + string behaviorKind, + bool knownStationsOnly) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + var stationsById = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .ToDictionary(station => station.Id, StringComparer.Ordinal); + var originSystemId = homeStation?.SystemId ?? ship.SystemId; + var rangeBudget = ResolveBehaviorSystemRange(world, ship, behaviorKind, ship.DefaultBehavior.MaxSystemRange); + var effectiveTradeSkill = GetEffectiveSkillLevel(world, ship, skills => skills.Trade, skills => skills.Coordination); + var requireKnownStations = knownStationsOnly || string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal); + string? deniedReason = null; + + var route = world.MarketOrders + .Where(order => + order.FactionId == ship.FactionId && + order.Kind == MarketOrderKinds.Buy && + order.RemainingAmount > 0.01f) + .Select(order => + { + StationRuntime? destination = null; + ConstructionSiteRuntime? destinationSite = null; + if (order.StationId is not null && stationsById.TryGetValue(order.StationId, out var destinationStation)) + { + destination = destinationStation; + } + else if (order.ConstructionSiteId is not null) + { + destinationSite = world.ConstructionSites.FirstOrDefault(site => site.Id == order.ConstructionSiteId); + if (destinationSite is not null) + { + destination = ResolveSupportStation(world, ship, destinationSite); + } + } + + if (destination is null) + { + return null; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, destination.SystemId, "trade", out var destinationDeniedReason)) + { + deniedReason ??= destinationDeniedReason; + return null; + } + if (!IsWithinSystemRange(world, originSystemId, destination.SystemId, rangeBudget)) + { + return null; + } + if (requireKnownStations + && ship.KnownStationIds.Count > 0 + && !ship.KnownStationIds.Contains(destination.Id) + && (homeStation is null || !string.Equals(destination.Id, homeStation.Id, StringComparison.Ordinal))) + { + return null; + } + if (string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is null) + { + return null; + } + if (!string.Equals(behaviorKind, "find-build-tasks", StringComparison.Ordinal) && destinationSite is not null) + { + return null; + } + + var source = stationsById.Values + .Where(station => + { + if (station.Id == destination.Id || GetInventoryAmount(station.Inventory, order.ItemId) <= GetStationReserveFloor(world, station, order.ItemId) + 1f) + { + return false; + } + + if (!TryCheckSystemAllowed(world, policy, ship.FactionId, station.SystemId, "trade", out var sourceDeniedReason)) + { + deniedReason ??= sourceDeniedReason; + return false; + } + + if (!IsWithinSystemRange(world, originSystemId, station.SystemId, rangeBudget)) + { + return false; + } + + return !requireKnownStations + || ship.KnownStationIds.Count == 0 + || ship.KnownStationIds.Contains(station.Id) + || (homeStation is not null && string.Equals(station.Id, homeStation.Id, StringComparison.Ordinal)); + }) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, order.ItemId) - GetStationReserveFloor(world, station, order.ItemId)) + .ThenByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (source is null) + { + return null; + } + + var shortageBias = string.Equals(behaviorKind, "fill-shortages", StringComparison.Ordinal) + ? GetFactionDemandScore(world, ship.FactionId, order.ItemId) * 35f + : 0f; + var buildBias = destinationSite is null ? 0f : 65f; + var revisitBias = string.Equals(behaviorKind, "revisit-known-stations", StringComparison.Ordinal) && ship.KnownStationIds.Contains(source.Id) && ship.KnownStationIds.Contains(destination.Id) + ? 28f + : 0f; + var regionalNeedBias = GetRegionalCommodityPressure(world, ship.FactionId, destination.SystemId, order.ItemId) * 18f; + var systemRangePenalty = (GetSystemDistanceTier(world, originSystemId, source.SystemId) + GetSystemDistanceTier(world, originSystemId, destination.SystemId)) * 16f; + var riskPenalty = + (GeopoliticalSimulationService.GetSystemRouteRisk(world, source.SystemId, ship.FactionId) + + GeopoliticalSimulationService.GetSystemRouteRisk(world, destination.SystemId, ship.FactionId)) * 22f; + var distanceScore = source.Position.DistanceTo(ship.Position) + source.Position.DistanceTo(destination.Position); + var score = (order.Valuation * 50f) + + shortageBias + + buildBias + + revisitBias + + regionalNeedBias + + (effectiveTradeSkill * 12f) + - systemRangePenalty + - riskPenalty + - distanceScore; + var summary = destinationSite is null + ? $"{order.ItemId}: {source.Label} -> {destination.Label}" + : $"{order.ItemId}: {source.Label} -> build support {destination.Label}"; + return new TradeRoutePlan(source, destination, order.ItemId, score, summary); + }) + .Where(route => route is not null) + .Cast() + .OrderByDescending(route => route.Score) + .ThenBy(route => route.ItemId, StringComparer.Ordinal) + .ThenBy(route => route.SourceStation.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (route is null && deniedReason is not null) + { + ship.LastAccessFailureReason = deniedReason; + } + + return route; + } + + private static FleetSupplyPlan? SelectFleetSupplyPlan(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + var assignment = ResolveAssignment(world, ship); + var targetCandidates = world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.FactionId == ship.FactionId && + candidate.Definition.CargoCapacity > 0.01f && + (assignment?.TargetEntityId is null || string.Equals(candidate.Id, assignment.TargetEntityId, StringComparison.Ordinal))) + .OrderByDescending(candidate => candidate.Definition.Kind == "military" ? 1 : 0) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + if (targetCandidates.Count == 0) + { + return null; + } + + var sourceStations = world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .ToList(); + foreach (var target in targetCandidates) + { + var itemId = assignment?.ItemId + ?? sourceStations + .SelectMany(station => station.Inventory) + .Where(entry => entry.Value > 2f) + .OrderByDescending(entry => entry.Value) + .ThenBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => entry.Key) + .FirstOrDefault(); + if (itemId is null) + { + continue; + } + + var source = sourceStations.FirstOrDefault(station => GetInventoryAmount(station.Inventory, itemId) > 2f); + if (source is null) + { + continue; + } + + var amount = MathF.Min(MathF.Max(10f, ship.Definition.CargoCapacity * 0.5f), GetInventoryAmount(source.Inventory, itemId)); + return new FleetSupplyPlan(source, target, itemId, amount, MathF.Max(16f, ship.DefaultBehavior.Radius), $"Supply {target.Definition.Label} with {itemId}"); + } + + return null; + } + + private static StationRuntime? SelectKnownStationVisit(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + var candidateIds = ship.KnownStationIds.Count == 0 && homeStation is not null + ? [homeStation.Id] + : ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal).ToArray(); + return candidateIds + .Select(id => ResolveStation(world, id)) + .Where(station => station is not null && station.FactionId == ship.FactionId) + .Cast() + .OrderByDescending(station => homeStation is not null && station.Id == homeStation.Id ? 1 : 0) + .ThenBy(station => station.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(station => station.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private static StationRuntime SelectBestDeliveryStation(SimulationWorld world, ShipRuntime ship, string itemId, StationRuntime homeStation, string behaviorKind) + { + if (!string.Equals(behaviorKind, "expert-auto-mine", StringComparison.Ordinal)) + { + return homeStation; + } + + return world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => GetFactionDemandScore(world, ship.FactionId, itemId) + GetRegionalCommodityPressure(world, ship.FactionId, station.SystemId, itemId) + (station.Id == homeStation.Id ? 5f : 0f)) + .ThenBy(station => station.SystemId == homeStation.SystemId ? 0 : 1) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault() + ?? homeStation; + } + + private static float GetFactionDemandScore(SimulationWorld world, string factionId, string itemId) + { + var signal = CommanderPlanningService.FindFactionEconomicAssessment(world, factionId)? + .CommoditySignals + .FirstOrDefault(candidate => candidate.ItemId == itemId); + var regionalBottleneckScore = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.ItemId, itemId, StringComparison.Ordinal)) + .Join( + world.Geopolitics.EconomyRegions.Regions.Where(region => string.Equals(region.FactionId, factionId, StringComparison.Ordinal)), + bottleneck => bottleneck.RegionId, + region => region.Id, + (bottleneck, _) => bottleneck.Severity) + .DefaultIfEmpty() + .Max() ?? 0f; + if (signal is null) + { + return regionalBottleneckScore * 8f; + } + + return MathF.Max(0f, signal.BuyBacklog + signal.ReservedForConstruction + MathF.Max(0f, -signal.ProjectedNetRatePerSecond * 50f) + (regionalBottleneckScore * 8f)); + } + + private static float GetRegionalCommodityPressure(SimulationWorld world, string factionId, string systemId, string itemId) + { + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, factionId, systemId); + if (region is null) + { + return 0f; + } + + var bottleneck = world.Geopolitics?.EconomyRegions.Bottlenecks + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal) + && string.Equals(candidate.ItemId, itemId, StringComparison.Ordinal)); + var assessment = world.Geopolitics?.EconomyRegions.EconomicAssessments + .FirstOrDefault(candidate => string.Equals(candidate.RegionId, region.Id, StringComparison.Ordinal)); + return (bottleneck?.Severity ?? 0f) + ((assessment?.ConstructionPressure ?? 0f) * 2f); + } + + private static ThreatTargetCandidate? SelectThreatTarget( + SimulationWorld world, + ShipRuntime ship, + string targetSystemId, + Vector3 anchorPosition, + float radius, + string? excludeEntityId = null) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.75f) + .Select(candidate => new ThreatTargetCandidate( + candidate.Id, + candidate.SystemId, + candidate.Position, + 100f + + (candidate.Definition.Kind == "military" ? 30f : 0f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase) ? 12f : 0f))) + .Concat(world.Stations + .Where(candidate => + candidate.Id != excludeEntityId && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, targetSystemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 2f) + .Select(candidate => new ThreatTargetCandidate(candidate.Id, candidate.SystemId, candidate.Position, 45f - candidate.Position.DistanceTo(anchorPosition) * 0.2f))) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static PoliceContactCandidate? SelectPoliceContact(SimulationWorld world, ShipRuntime ship, string systemId, Vector3 anchorPosition, float radius) + { + var policy = ResolvePolicy(world, ship.PolicySetId); + return world.Ships + .Where(candidate => + candidate.Id != ship.Id && + candidate.Health > 0f && + candidate.FactionId != ship.FactionId && + string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal) && + candidate.Position.DistanceTo(anchorPosition) <= radius * 1.5f) + .Select(candidate => + { + var engage = candidate.Definition.Kind == "military" + || string.Equals(policy?.CombatEngagementPolicy, "aggressive", StringComparison.OrdinalIgnoreCase); + var score = (engage ? 80f : 40f) + - candidate.Position.DistanceTo(anchorPosition) + - candidate.Position.DistanceTo(ship.Position) + + (candidate.Definition.Kind == "transport" ? 8f : 0f); + return new PoliceContactCandidate(candidate.Id, candidate.SystemId, candidate.Position, engage, score); + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.EntityId, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static SalvageOpportunity? SelectSalvageOpportunity(SimulationWorld world, ShipRuntime ship, StationRuntime? homeStation) + { + if (homeStation is null) + { + return null; + } + + var rangeBudget = ResolveBehaviorSystemRange(world, ship, "auto-salvage", ship.DefaultBehavior.MaxSystemRange > 0 ? ship.DefaultBehavior.MaxSystemRange : 1); + return world.Wrecks + .Where(wreck => + wreck.RemainingAmount > 0.01f && + IsWithinSystemRange(world, homeStation.SystemId, wreck.SystemId, rangeBudget)) + .Select(wreck => new SalvageOpportunity( + wreck, + (wreck.RemainingAmount * 3f) - wreck.Position.DistanceTo(ship.Position) - (GetSystemDistanceTier(world, homeStation.SystemId, wreck.SystemId) * 25f), + $"Salvage {wreck.ItemId} from {wreck.SourceEntityId}")) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Wreck.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static (string SystemId, Vector3 Position)? ResolveObjectTarget(SimulationWorld world, string? entityId) + { + if (entityId is null) + { + return null; + } + + if (world.Ships.FirstOrDefault(candidate => candidate.Id == entityId) is { } ship) + { + return (ship.SystemId, ship.Position); + } + + if (ResolveStation(world, entityId) is { } station) + { + return (station.SystemId, station.Position); + } + + if (world.Celestials.FirstOrDefault(candidate => candidate.Id == entityId) is { } celestial) + { + return (celestial.SystemId, celestial.Position); + } + + if (world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == entityId) is { } site) + { + var position = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? Vector3.Zero; + return (site.SystemId, position); + } + + if (world.Wrecks.FirstOrDefault(candidate => candidate.Id == entityId) is { } wreck) + { + return (wreck.SystemId, wreck.Position); + } + + return null; + } + + private static Vector3 GetFormationPosition(Vector3 anchorPosition, string seed, float radius) + { + var hash = Math.Abs(seed.Aggregate(17, (acc, c) => (acc * 31) + c)); + var angle = (hash % 360) * (MathF.PI / 180f); + return new Vector3( + anchorPosition.X + (MathF.Cos(angle) * radius), + anchorPosition.Y, + anchorPosition.Z + (MathF.Sin(angle) * radius)); + } + + private static TradeRoutePlan? ResolveTradeRoute(SimulationWorld world, string itemId, string sourceStationId, string destinationStationId) + { + var source = ResolveStation(world, sourceStationId); + var destination = ResolveStation(world, destinationStationId); + return source is null || destination is null ? null : new TradeRoutePlan(source, destination, itemId, 0f, $"{itemId}: {source.Label} -> {destination.Label}"); + } + + private static StationRuntime? ResolveStation(SimulationWorld world, string? stationId) => + stationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == stationId); + + private static ResourceNodeRuntime? ResolveNode(SimulationWorld world, string? nodeId) => + nodeId is null ? null : world.Nodes.FirstOrDefault(candidate => candidate.Id == nodeId); + + private static PolicySetRuntime? ResolvePolicy(SimulationWorld world, string? policySetId) => + policySetId is null ? null : world.Policies.FirstOrDefault(policy => policy.Id == policySetId); + + private static bool IsSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind) => + TryCheckSystemAllowed(world, policy, factionId, systemId, accessKind, out _); + + private static bool TryCheckSystemAllowed( + SimulationWorld world, + PolicySetRuntime? policy, + string factionId, + string systemId, + string accessKind, + out string? denialReason) + { + denialReason = null; + if (policy?.BlacklistedSystemIds.Contains(systemId) == true) + { + denialReason = $"blacklisted:{systemId}"; + return false; + } + + var controlState = GeopoliticalSimulationService.GetSystemControlState(world, systemId); + var authorityFactionId = controlState?.ControllerFactionId ?? controlState?.PrimaryClaimantFactionId; + if (authorityFactionId is null || string.Equals(authorityFactionId, factionId, StringComparison.Ordinal)) + { + return true; + } + + var hasAccess = string.Equals(accessKind, "trade", StringComparison.Ordinal) + ? GeopoliticalSimulationService.HasTradeAccess(world, factionId, authorityFactionId) + : GeopoliticalSimulationService.HasMilitaryAccess(world, factionId, authorityFactionId); + if (!hasAccess) + { + denialReason = $"{accessKind}-access-denied:{authorityFactionId}"; + return false; + } + + if (policy?.AvoidHostileSystems != true) + { + return true; + } + + if (GeopoliticalSimulationService.HasHostileRelation(world, factionId, authorityFactionId)) + { + denialReason = $"hostile-authority:{authorityFactionId}"; + return false; + } + + var hostileInfluencer = controlState?.InfluencingFactionIds.FirstOrDefault(candidate => + !string.Equals(candidate, factionId, StringComparison.Ordinal) + && GeopoliticalSimulationService.HasHostileRelation(world, factionId, candidate)); + if (hostileInfluencer is not null) + { + denialReason = $"hostile-influence:{hostileInfluencer}"; + return false; + } + + return true; + } + + private static CommanderAssignmentRuntime? ResolveAssignment(SimulationWorld world, ShipRuntime ship) => + ship.CommanderId is null + ? null + : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment; + + private static ShipOrderRuntime? GetTopOrder(ShipRuntime ship) => + ship.OrderQueue + .Where(order => order.Status is OrderStatus.Queued or OrderStatus.Active) + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .FirstOrDefault(); + + private static ShipPlanStepRuntime? GetCurrentStep(ShipPlanRuntime? plan) => + plan is null || plan.CurrentStepIndex >= plan.Steps.Count ? null : plan.Steps[plan.CurrentStepIndex]; + + private static StationRuntime? ResolveSupportStation(SimulationWorld world, ShipRuntime ship, ConstructionSiteRuntime site) + { + return ResolveStation(world, ResolveAssignment(world, ship)?.HomeStationId ?? ship.DefaultBehavior.HomeStationId) + ?? world.Stations + .Where(station => station.FactionId == ship.FactionId) + .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) + .ThenBy(station => station.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static Vector3 ResolveSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) + { + if (ship.DockedStationId is not null) + { + return GetShipDockedPosition(ship, station); + } + + if (site?.StationId is null && site is not null) + { + var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; + return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + } + + return GetConstructionHoldPosition(station, ship.Id); + } + + private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => + ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); + + private static void TrackHistory(ShipRuntime ship) + { + var plan = ship.ActivePlan; + var step = GetCurrentStep(plan); + var subTask = step is null || step.CurrentSubTaskIndex >= step.SubTasks.Count ? null : step.SubTasks[step.CurrentSubTaskIndex]; + var signature = $"{ship.State.ToContractValue()}|{plan?.Kind ?? "none"}|{step?.Kind ?? "none"}|{subTask?.Kind ?? "none"}|{GetShipCargoAmount(ship):0.0}"; + if (ship.LastSignature == signature) + { + return; + } + + ship.LastSignature = signature; + ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} plan={plan?.Kind ?? "none"} step={step?.Kind ?? "none"} subTask={subTask?.Kind ?? "none"} cargo={GetShipCargoAmount(ship):0.#}"); + if (ship.History.Count > 24) + { + ship.History.RemoveAt(0); + } + } + + private static void EmitStateEvents(ShipRuntime ship, ShipState previousState, string? previousPlanId, string? previousStepId, ICollection events) + { + var currentPlanId = ship.ActivePlan?.Id; + var currentStepId = GetCurrentStep(ship.ActivePlan)?.Id; + var occurredAtUtc = DateTimeOffset.UtcNow; + if (previousState != ship.State) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} state {previousState.ToContractValue()} -> {ship.State.ToContractValue()}.", occurredAtUtc)); + } + + if (!string.Equals(previousPlanId, currentPlanId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "plan-changed", $"{ship.Definition.Label} switched active plan.", occurredAtUtc)); + } + + if (!string.Equals(previousStepId, currentStepId, StringComparison.Ordinal)) + { + events.Add(new SimulationEventRecord("ship", ship.Id, "step-changed", $"{ship.Definition.Label} advanced plan step.", occurredAtUtc)); + } + } + + private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) + { + var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + if (anchor is null || site.BlueprintId is null) + { + site.State = ConstructionSiteStateKinds.Destroyed; + return; + } + + var station = new StationRuntime + { + Id = $"station-{world.Stations.Count + 1}", + SystemId = site.SystemId, + Label = BuildFoundedStationLabel(site.TargetDefinitionId), + Category = "station", + Objective = DetermineFoundationObjective(site.TargetDefinitionId), + Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, + Position = anchor.Position, + FactionId = site.FactionId, + CelestialId = site.CelestialId, + Health = 600f, + MaxHealth = 600f, + }; + + foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) + { + AddStationModule(world, station, moduleId); + } + + world.Stations.Add(station); + StationLifecycleService.EnsureStationCommander(world, station); + anchor.OccupyingStructureId = station.Id; + site.StationId = station.Id; + PrepareNextConstructionSiteStep(world, station, site); + } + + private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) + { + var modules = new List { "module_arg_dock_m_01_lowtech" }; + foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) + { + if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + var storageModule = GetStorageRequirement(itemDefinition.CargoKind); + if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) + { + modules.Add(storageModule); + } + else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + } + } + + if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + + if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + modules.Add("module_gen_prod_energycells_01"); + } + + modules.Add(primaryModuleId); + return modules.Distinct(StringComparer.Ordinal).ToList(); + } + + private static string DetermineFoundationObjective(string commodityId) => + commodityId switch + { + "energycells" => "power", + "water" => "water", + "refinedmetals" => "refinery", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + "shipyard" => "shipyard", + _ => "general", + }; + + private static string BuildFoundedStationLabel(string commodityId) => + $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; + + private enum SubTaskOutcome + { + Active, + Completed, + Failed, + } + + private sealed record TradeRoutePlan( + StationRuntime SourceStation, + StationRuntime DestinationStation, + string ItemId, + float Score, + string Summary); + + private sealed record MiningOpportunity( + ResourceNodeRuntime Node, + StationRuntime DropOffStation, + float Score, + string Summary); + + private sealed record FleetSupplyPlan( + StationRuntime SourceStation, + ShipRuntime TargetShip, + string ItemId, + float Amount, + float Radius, + string Summary); + + private sealed record ThreatTargetCandidate( + string EntityId, + string SystemId, + Vector3 Position, + float Score); + + private sealed record PoliceContactCandidate( + string EntityId, + string SystemId, + Vector3 Position, + bool Engage, + float Score); + + private sealed record SalvageOpportunity( + WreckRuntime Wreck, + float Score, + string Summary); +} diff --git a/apps/backend/Ships/Simulation/ShipControlService.cs b/apps/backend/Ships/Simulation/ShipControlService.cs deleted file mode 100644 index 0484a55..0000000 --- a/apps/backend/Ships/Simulation/ShipControlService.cs +++ /dev/null @@ -1,880 +0,0 @@ -using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; -using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; - -namespace SpaceGame.Api.Ships.Simulation; - -internal sealed class ShipControlService -{ - private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault(); - - private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) => - ship.CommanderId is null - ? null - : world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship); - - private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander) - { - if (commander.ActiveBehavior is not null) - { - ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind; - ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId; - ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId; - ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId; - ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId; - ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId; - ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase; - ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex; - ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId; - } - - if (commander.ActiveOrder is null) - { - ship.Order = null; - } - else - { - ship.Order = new ShipOrderRuntime - { - Kind = commander.ActiveOrder.Kind, - Status = commander.ActiveOrder.Status, - DestinationSystemId = commander.ActiveOrder.DestinationSystemId, - DestinationPosition = commander.ActiveOrder.DestinationPosition, - }; - } - - if (commander.ActiveTask is not null) - { - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ParseControllerTaskKind(commander.ActiveTask.Kind), - Status = commander.ActiveTask.Status, - CommanderId = commander.Id, - TargetEntityId = commander.ActiveTask.TargetEntityId, - TargetNodeId = commander.ActiveTask.TargetNodeId, - TargetPosition = commander.ActiveTask.TargetPosition, - TargetSystemId = commander.ActiveTask.TargetSystemId, - Threshold = commander.ActiveTask.Threshold, - }; - } - } - - private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander) - { - commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind }; - commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind; - commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId; - commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId; - commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId; - commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId; - commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId; - commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase; - commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex; - commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId; - - if (ship.Order is null) - { - commander.ActiveOrder = null; - } - else - { - commander.ActiveOrder ??= new CommanderOrderRuntime - { - Kind = ship.Order.Kind, - DestinationSystemId = ship.Order.DestinationSystemId, - DestinationPosition = ship.Order.DestinationPosition, - }; - commander.ActiveOrder.Status = ship.Order.Status; - commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId; - commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId; - } - - commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() }; - commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue(); - commander.ActiveTask.Status = ship.ControllerTask.Status; - commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId; - commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId; - commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition; - commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId; - commander.ActiveTask.Threshold = ship.ControllerTask.Threshold; - } - - internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world) - { - var commander = GetShipCommander(world, ship); - if (commander is not null) - { - SyncCommanderToShip(ship, commander); - } - - if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued) - { - ship.Order.Status = OrderStatus.Accepted; - if (commander?.ActiveOrder is not null) - { - commander.ActiveOrder.Status = ship.Order.Status; - } - } - - if (commander is not null) - { - SyncShipToCommander(ship, commander); - } - } - - internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) - { - var commander = GetShipCommander(world, ship); - if (ship.Order is not null) - { - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - Status = WorkStatus.Active, - CommanderId = commander?.Id, - TargetSystemId = ship.Order.DestinationSystemId, - TargetNodeId = ship.SpatialState.DestinationNodeId, - TargetPosition = ship.Order.DestinationPosition, - Threshold = world.Balance.ArrivalThreshold, - }; - SyncCommanderTask(commander, ship.ControllerTask); - return; - } - - _shipBehaviorStateMachine.Plan(engine, ship, world); - SyncCommanderTask(commander, ship.ControllerTask); - } - - internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) - { - var behavior = ship.DefaultBehavior; - var target = ResolveAttackTarget(ship, world); - if (target is null) - { - behavior.Kind = "idle"; - behavior.TargetEntityId = null; - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - behavior.TargetEntityId = target.EntityId; - behavior.AreaSystemId = target.SystemId; - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.AttackTarget, - TargetEntityId = target.EntityId, - TargetSystemId = target.SystemId, - TargetPosition = target.Position, - Threshold = target.AttackRange, - }; - } - - internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) - { - var behavior = ship.DefaultBehavior; - var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); - var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId); - if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId)) - { - behavior.Kind = "idle"; - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - var carryingCargo = GetShipCargoAmount(ship) > 0.01f; - if (carryingCargo) - { - if (ship.DockedStationId == destinationStation.Id) - { - behavior.Phase = "unload"; - } - else if (ship.DockedStationId is not null) - { - behavior.Phase = "undock-from-source"; - } - else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload") - { - behavior.Phase = "travel-to-destination"; - } - } - else - { - if (ship.DockedStationId == sourceStation.Id) - { - var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId); - behavior.Phase = available > 0.01f ? "load" : "wait-source"; - } - else if (ship.DockedStationId == destinationStation.Id) - { - behavior.Phase = "undock-from-destination"; - } - else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load") - { - behavior.Phase = "travel-to-source"; - } - } - - ship.ControllerTask = behavior.Phase switch - { - "travel-to-source" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = sourceStation.Id, - TargetSystemId = sourceStation.SystemId, - TargetPosition = sourceStation.Position, - Threshold = sourceStation.Radius + 8f, - ItemId = behavior.ItemId, - }, - "dock-source" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Dock, - TargetEntityId = sourceStation.Id, - TargetSystemId = sourceStation.SystemId, - TargetPosition = sourceStation.Position, - Threshold = sourceStation.Radius + 4f, - ItemId = behavior.ItemId, - }, - "load" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Load, - TargetEntityId = sourceStation.Id, - TargetSystemId = sourceStation.SystemId, - TargetPosition = sourceStation.Position, - Threshold = 0f, - ItemId = behavior.ItemId, - }, - "undock-from-source" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Undock, - TargetEntityId = sourceStation.Id, - TargetSystemId = sourceStation.SystemId, - TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z), - Threshold = 8f, - ItemId = behavior.ItemId, - }, - "travel-to-destination" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = destinationStation.Id, - TargetSystemId = destinationStation.SystemId, - TargetPosition = destinationStation.Position, - Threshold = destinationStation.Radius + 8f, - ItemId = behavior.ItemId, - }, - "dock-destination" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Dock, - TargetEntityId = destinationStation.Id, - TargetSystemId = destinationStation.SystemId, - TargetPosition = destinationStation.Position, - Threshold = destinationStation.Radius + 4f, - ItemId = behavior.ItemId, - }, - "unload" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Unload, - TargetEntityId = destinationStation.Id, - TargetSystemId = destinationStation.SystemId, - TargetPosition = destinationStation.Position, - Threshold = 0f, - ItemId = behavior.ItemId, - }, - "undock-from-destination" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Undock, - TargetEntityId = destinationStation.Id, - TargetSystemId = destinationStation.SystemId, - TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z), - Threshold = 8f, - ItemId = behavior.ItemId, - }, - _ => CreateIdleTask(world.Balance.ArrivalThreshold), - }; - } - - internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule) - { - var behavior = ship.DefaultBehavior; - var cargoItemId = ship.Inventory.Keys.FirstOrDefault(); - var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId); - if (string.IsNullOrWhiteSpace(targetResourceItemId)) - { - behavior.Phase = null; - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal)) - { - behavior.ItemId = targetResourceItemId; - behavior.NodeId = null; - } - - var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId); - behavior.StationId = refinery?.Id; - var node = behavior.NodeId is null - ? world.Nodes - .Where(candidate => - candidate.ItemId == targetResourceItemId && - candidate.OreRemaining > 0.01f && - CanShipMineItem(world, ship, candidate.ItemId)) - .OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0) - .ThenByDescending(candidate => candidate.OreRemaining) - .FirstOrDefault() - : world.Nodes.FirstOrDefault(candidate => - candidate.Id == behavior.NodeId && - string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) && - candidate.OreRemaining > 0.01f); - - if (node is not null) - { - behavior.AreaSystemId = node.SystemId; - } - - if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule)) - { - if (refinery is null && GetShipCargoAmount(ship) > 0.01f) - { - ship.Inventory.Clear(); - } - - behavior.Phase = null; - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - behavior.NodeId ??= node.Id; - - if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f - && behavior.Phase is "travel-to-node" or "extract") - { - behavior.Phase = "travel-to-station"; - } - - if (ship.DockedStationId == refinery.Id) - { - if (GetShipCargoAmount(ship) > 0.01f) - { - behavior.Phase = "unload"; - } - else if (behavior.Phase is "dock" or "unload") - { - behavior.Phase = "undock"; - } - } - else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract") - { - behavior.Phase = "travel-to-station"; - } - - switch (behavior.Phase) - { - case "extract": - var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Extract, - TargetEntityId = node.Id, - TargetSystemId = node.SystemId, - TargetPosition = extractionPosition, - Threshold = 5f, - }; - break; - case "travel-to-station": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = refinery.Radius + 8f, - }; - break; - case "dock": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Dock, - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = refinery.Radius + 4f, - }; - break; - case "unload": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Unload, - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = refinery.Position, - Threshold = 0f, - }; - break; - case "undock": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Undock, - TargetEntityId = refinery.Id, - TargetSystemId = refinery.SystemId, - TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), - Threshold = 8f, - }; - break; - default: - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = node.Id, - TargetSystemId = node.SystemId, - TargetPosition = node.Position, - Threshold = 18f, - }; - behavior.Phase = "travel-to-node"; - break; - } - } - - private static string? SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string? fallbackItemId) - { - var candidateItemId = world.MarketOrders - .Where(order => - string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal) - && order.Kind == MarketOrderKinds.Buy - && order.ConstructionSiteId is null - && order.State != MarketOrderStateKinds.Cancelled - && order.RemainingAmount > 0.01f) - .Select(order => new - { - ItemId = order.ItemId, - Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation), - }) - .Where(entry => CanShipMineItem(world, ship, entry.ItemId)) - .Where(entry => world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) - .GroupBy(entry => entry.ItemId, StringComparer.Ordinal) - .Select(group => new - { - ItemId = group.Key, - Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f), - }) - .OrderByDescending(entry => entry.Score) - .Select(entry => entry.ItemId) - .FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(candidateItemId)) - { - return candidateItemId; - } - - if (!string.IsNullOrWhiteSpace(fallbackItemId) - && CanShipMineItem(world, ship, fallbackItemId) - && world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) - { - return fallbackItemId; - } - - return world.Nodes - .Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId)) - .OrderByDescending(node => node.OreRemaining) - .Select(node => node.ItemId) - .FirstOrDefault() ?? fallbackItemId; - } - - private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) => - world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition) - && string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal) - && HasShipCapabilities(ship.Definition, "mining"); - - internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId) - { - var preferred = preferredStationId is null - ? null - : world.Stations.FirstOrDefault(station => station.Id == preferredStationId); - - var bestOrder = world.MarketOrders - .Where(order => - order.Kind == MarketOrderKinds.Buy && - order.ConstructionSiteId is null && - order.State != MarketOrderStateKinds.Cancelled && - order.ItemId == itemId && - order.RemainingAmount > 0.01f) - .Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId))) - .Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal)) - .Where(entry => CanStationReceiveItem(world, entry.Station!, itemId)) - .OrderByDescending(entry => - { - var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; - return entry.Order.Valuation - distancePenalty; - }) - .FirstOrDefault(); - - return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null); - } - - private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId) - { - if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - return false; - } - - var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); - return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal); - } - - private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) => - phase switch - { - "dock" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Dock, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 8f, - }, - "load" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Load, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 8f, - }, - "unload" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Unload, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = station.Position, - Threshold = 8f, - }, - "undock" => new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Undock, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, - TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z), - Threshold = 8f, - }, - _ => CreateIdleTask(world.Balance.ArrivalThreshold), - }; - - internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) - { - var behavior = ship.DefaultBehavior; - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); - var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId) - ? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId) - : station is null ? null : GetConstructionSiteForStation(world, station.Id); - if (station is null) - { - behavior.Kind = "idle"; - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId)) - { - behavior.TargetEntityId = null; - behavior.ModuleId = null; - site = GetConstructionSiteForStation(world, station.Id); - } - - var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); - behavior.ModuleId = moduleId; - if (moduleId is null) - { - ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); - return; - } - - if (ship.DockedStationId is not null) - { - var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); - if (dockedStation is not null) - { - dockedStation.DockedShipIds.Remove(ship.Id); - ReleaseDockingPad(dockedStation, ship.Id); - } - - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - ship.Position = ResolveConstructionHoldPosition(ship, station, site, world); - ship.TargetPosition = ship.Position; - } - - var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world); - var targetSystemId = site?.SystemId ?? station.SystemId; - var targetCelestialId = site?.CelestialId ?? station.CelestialId; - var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId) - && string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal); - var isAtConstructionHold = ship.SystemId == targetSystemId - && (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial); - - if (isAtConstructionHold) - { - if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site)) - { - behavior.Phase = "deliver-to-site"; - } - else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site)) - { - behavior.Phase = "build-site"; - } - else if (site is not null) - { - behavior.Phase = "wait-for-materials"; - } - else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId])) - { - behavior.Phase = "construct-module"; - } - else - { - behavior.Phase = "wait-for-materials"; - } - } - else if (behavior.Phase != "travel-to-station") - { - behavior.Phase = "travel-to-station"; - } - - switch (behavior.Phase) - { - case "construct-module": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.ConstructModule, - TargetEntityId = station.Id, - TargetSystemId = targetSystemId, - TargetPosition = constructionHoldPosition, - Threshold = 10f, - }; - break; - case "deliver-to-site": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.DeliverConstruction, - TargetEntityId = site?.Id, - TargetSystemId = targetSystemId, - TargetPosition = constructionHoldPosition, - Threshold = 10f, - }; - break; - case "build-site": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.BuildConstructionSite, - TargetEntityId = site?.Id, - TargetSystemId = targetSystemId, - TargetPosition = constructionHoldPosition, - Threshold = 10f, - }; - break; - case "wait-for-materials": - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Idle, - TargetEntityId = site?.Id ?? station.Id, - TargetSystemId = targetSystemId, - TargetPosition = constructionHoldPosition, - Threshold = 0f, - }; - break; - default: - ship.ControllerTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = site?.Id ?? station.Id, - TargetSystemId = targetSystemId, - TargetPosition = constructionHoldPosition, - Threshold = 10f, - }; - behavior.Phase = "travel-to-station"; - break; - } - } - - internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) - { - var commander = GetShipCommander(world, ship); - if (ship.Order is not null && controllerEvent == "arrived") - { - ship.Order = null; - ship.ControllerTask.Kind = ControllerTaskKind.Idle; - if (commander is not null) - { - commander.ActiveOrder = null; - commander.ActiveTask = new CommanderTaskRuntime - { - Kind = ShipTaskKinds.Idle, - Status = WorkStatus.Completed, - TargetSystemId = ship.SystemId, - Threshold = 0f, - }; - } - - return; - } - - _shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent); - if (commander is not null) - { - SyncShipToCommander(ship, commander); - if (commander.ActiveTask is not null) - { - commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed; - } - } - } - - internal void TrackHistory(ShipRuntime ship, string controllerEvent) - { - var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}"; - if (signature == ship.LastSignature) - { - return; - } - - ship.LastSignature = signature; - var target = ship.ControllerTask.TargetEntityId - ?? ship.ControllerTask.TargetSystemId - ?? "none"; - var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}"; - ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}"); - if (ship.History.Count > 18) - { - ship.History.RemoveAt(0); - } - } - - internal void EmitShipStateEvents( - ShipRuntime ship, - ShipState previousState, - string previousBehavior, - ControllerTaskKind previousTask, - string controllerEvent, - ICollection events) - { - var occurredAtUtc = DateTimeOffset.UtcNow; - - if (previousState != ship.State) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc)); - } - - if (previousBehavior != ship.DefaultBehavior.Kind) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc)); - } - - if (previousTask != ship.ControllerTask.Kind) - { - events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc)); - } - - if (controllerEvent != "none") - { - events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc)); - } - } - - internal static ControllerTaskRuntime CreateIdleTask(float threshold) => - new() - { - Kind = ControllerTaskKind.Idle, - Threshold = threshold, - }; - - private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch - { - "travel" => ControllerTaskKind.Travel, - "extract" => ControllerTaskKind.Extract, - "dock" => ControllerTaskKind.Dock, - "load" => ControllerTaskKind.Load, - "unload" => ControllerTaskKind.Unload, - "deliver-construction" => ControllerTaskKind.DeliverConstruction, - "build-construction-site" => ControllerTaskKind.BuildConstructionSite, - "attack-target" => ControllerTaskKind.AttackTarget, - - "construct-module" => ControllerTaskKind.ConstructModule, - "undock" => ControllerTaskKind.Undock, - _ => ControllerTaskKind.Idle, - }; - - private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task) - { - if (commander is null) - { - return; - } - - commander.ActiveTask = new CommanderTaskRuntime - { - Kind = task.Kind.ToContractValue(), - Status = task.Status, - TargetEntityId = task.TargetEntityId, - TargetNodeId = task.TargetNodeId, - TargetPosition = task.TargetPosition, - TargetSystemId = task.TargetSystemId, - Threshold = task.Threshold, - }; - } - - private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) - { - if (site is null || site.StationId is not null) - { - return GetConstructionHoldPosition(station, ship.Id); - } - - var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - var anchorPosition = anchor?.Position ?? station.Position; - return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); - } - - private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world) - { - if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId)) - { - var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!); - if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal)) - { - return direct; - } - } - - var hostileShips = world.Ships - .Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) - .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f)) - .ToList(); - - var hostileStations = world.Stations - .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) - .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f)) - .ToList(); - - var preferredSystemId = ship.DefaultBehavior.AreaSystemId; - return hostileShips - .Concat(hostileStations) - .OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1) - .ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) - .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId) - { - var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f); - if (ship is not null) - { - return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f); - } - - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId); - return station is null - ? null - : new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f); - } - - private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange); -} diff --git a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs b/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs deleted file mode 100644 index 48233cc..0000000 --- a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs +++ /dev/null @@ -1,592 +0,0 @@ -using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; -using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; -using static SpaceGame.Api.Stations.Simulation.StationSimulationService; - -namespace SpaceGame.Api.Ships.Simulation; - -internal sealed partial class ShipTaskExecutionService -{ - private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds) - { - ship.ActionTimer += deltaSeconds; - if (ship.ActionTimer < requiredSeconds) - { - return false; - } - - ship.ActionTimer = 0f; - return true; - } - - private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total) - { - if (ship.TrackedActionKey == actionKey) - { - return; - } - - ship.TrackedActionKey = actionKey; - ship.TrackedActionTotal = MathF.Max(total, 0.01f); - } - - internal static float GetShipCargoAmount(ShipRuntime ship) => - SimulationRuntimeSupport.GetShipCargoAmount(ship); - - private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node, world)) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var cargoAmount = GetShipCargoAmount(ship); - if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f) - { - ship.ActionTimer = 0f; - ship.State = ShipState.CargoFull; - ship.TargetPosition = ship.Position; - return "cargo-full"; - } - - ship.TargetPosition = task.TargetPosition.Value; - var distance = ship.Position.DistanceTo(task.TargetPosition.Value); - if (distance > task.Threshold) - { - ship.ActionTimer = 0f; - - ship.State = ShipState.MiningApproach; - ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - ship.State = ShipState.Mining; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) - { - return "none"; - } - - var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount); - var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity); - mined = MathF.Min(mined, node.OreRemaining); - if (mined <= 0.01f) - { - ship.ActionTimer = 0f; - ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull; - ship.TargetPosition = ship.Position; - return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full"; - } - - AddInventory(ship.Inventory, node.ItemId, mined); - - node.OreRemaining -= mined; - node.OreRemaining = MathF.Max(0f, node.OreRemaining); - - return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; - } - - private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (station is null || task.TargetPosition is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id); - if (padIndex is null) - { - ship.ActionTimer = 0f; - ship.State = ShipState.AwaitingDock; - ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); - var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); - if (waitDistance > 4f) - { - ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - } - - return "none"; - } - - ship.AssignedDockingPadIndex = padIndex; - var padPosition = GetDockingPadPosition(station, padIndex.Value); - ship.TargetPosition = padPosition; - var distance = ship.Position.DistanceTo(padPosition); - if (distance > 4f) - { - ship.ActionTimer = 0f; - - ship.State = ShipState.DockingApproach; - ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - ship.State = ShipState.Docking; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) - { - return "none"; - } - - ship.State = ShipState.Docked; - ship.DockedStationId = station.Id; - station.DockedShipIds.Add(ship.Id); - ship.Position = padPosition; - ship.TargetPosition = padPosition; - return "docked"; - } - - private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = ShipState.Transferring; - BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship)); - - var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId); - var transferredAny = false; - foreach (var (itemId, amount) in ship.Inventory.ToList()) - { - var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds); - var accepted = TryAddStationInventory(world, station, itemId, moved); - transferredAny |= accepted > 0.01f; - RemoveInventory(ship.Inventory, itemId, accepted); - if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal)) - { - faction.OreMined += accepted; - faction.Credits += accepted * 0.4f; - } - } - - if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining")) - { - ship.Inventory.Clear(); - return "unloaded"; - } - - return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none"; - } - - private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - if (ship.DockedStationId is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); - if (station is null) - { - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - ship.TargetPosition = GetShipDockedPosition(ship, station); - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = ShipState.Loading; - var itemId = ship.ControllerTask.ItemId; - BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship))); - var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); - var moved = itemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, itemId)); - if (itemId is not null && moved > 0.01f) - { - RemoveInventory(station.Inventory, itemId, moved); - AddInventory(ship.Inventory, itemId, moved); - } - - return itemId is null - || GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f - || GetInventoryAmount(station.Inventory, itemId) <= 0.01f - ? "loaded" - : "none"; - } - - private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var station = ResolveShipSupportStation(ship, world); - if (station is null || ship.DefaultBehavior.ModuleId is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe)) - { - ship.AssignedDockingPadIndex = null; - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var supportPosition = ResolveShipSupportPosition(ship, station, null, world); - if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) - { - ship.ActionTimer = 0f; - ship.State = ShipState.WaitingMaterials; - ship.TargetPosition = supportPosition; - return "none"; - } - - if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) - { - ship.State = ShipState.ConstructionBlocked; - ship.TargetPosition = supportPosition; - return "none"; - } - - ship.TargetPosition = supportPosition; - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = ShipState.Constructing; - station.ActiveConstruction.ProgressSeconds += deltaSeconds; - if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) - { - return "none"; - } - - AddStationModule(world, station, station.ActiveConstruction.ModuleId); - station.ActiveConstruction = null; - return "module-constructed"; - } - - private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var station = ResolveShipSupportStation(ship, world); - if (station is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId); - if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var supportPosition = ResolveShipSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - ship.TargetPosition = supportPosition; - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = ShipState.DeliveringConstruction; - BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site)); - - if (site.StationId is not null) - { - foreach (var required in site.RequiredItems) - { - var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); - var remaining = MathF.Max(0f, required.Value - delivered); - if (remaining <= 0.01f) - { - continue; - } - - var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); - var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); - moved = MathF.Min(moved, available); - if (moved <= 0.01f) - { - continue; - } - - RemoveInventory(station.Inventory, required.Key, moved); - AddInventory(site.Inventory, required.Key, moved); - AddInventory(site.DeliveredItems, required.Key, moved); - return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; - } - - return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; - } - - foreach (var required in site.RequiredItems) - { - var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); - var remaining = MathF.Max(0f, required.Value - delivered); - if (remaining <= 0.01f) - { - continue; - } - - var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); - var available = MathF.Max(0f, GetInventoryAmount(station.Inventory, required.Key) - GetStationReserveFloor(world, station, required.Key)); - moved = MathF.Min(moved, available); - if (moved <= 0.01f) - { - continue; - } - - RemoveInventory(station.Inventory, required.Key, moved); - AddInventory(site.Inventory, required.Key, moved); - AddInventory(site.DeliveredItems, required.Key, moved); - return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; - } - - return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; - } - - private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var station = ResolveShipSupportStation(ship, world); - if (station is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId); - if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var supportPosition = ResolveShipSupportPosition(ship, station, site, world); - if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) - { - ship.State = ShipState.LocalFlight; - ship.TargetPosition = supportPosition; - ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) - { - ship.State = ShipState.WaitingMaterials; - ship.TargetPosition = supportPosition; - return "none"; - } - - ship.TargetPosition = supportPosition; - ship.Position = ship.TargetPosition; - ship.ActionTimer = 0f; - ship.State = ShipState.Constructing; - site.AssignedConstructorShipIds.Add(ship.Id); - site.Progress += deltaSeconds; - if (site.Progress < recipe.Duration) - { - return "none"; - } - - if (site.StationId is null) - { - CompleteStationFoundation(world, station, site); - } - else - { - AddStationModule(world, station, site.BlueprintId); - PrepareNextConstructionSiteStep(world, station, site); - } - - return "site-constructed"; - } - - private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) => - ship.DockedStationId is not null - ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId) - : ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null - ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) - : null; - - private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) - { - if (ship.DockedStationId is not null) - { - return GetShipDockedPosition(ship, station); - } - - if (site?.StationId is null && site is not null) - { - var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; - return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); - } - - return GetConstructionHoldPosition(station, ship.Id); - } - - private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => - ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); - - - private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - if (ship.DockedStationId is null || task.TargetPosition is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); - var undockTarget = station is null - ? task.TargetPosition.Value - : GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); - ship.TargetPosition = undockTarget; - - ship.State = ShipState.Undocking; - if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) - { - if (station is not null) - { - ship.Position = GetShipDockedPosition(ship, station); - } - - return "none"; - } - - ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance); - if (ship.Position.DistanceTo(undockTarget) > task.Threshold) - { - return "none"; - } - - if (station is not null) - { - station.DockedShipIds.Remove(ship.Id); - ReleaseDockingPad(station, ship.Id); - } - - ship.DockedStationId = null; - ship.AssignedDockingPadIndex = null; - return "undocked"; - } - - internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) => - site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key))); - - private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) - { - var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); - if (anchor is null || site.BlueprintId is null) - { - site.State = ConstructionSiteStateKinds.Destroyed; - return; - } - - var station = new StationRuntime - { - Id = $"station-{world.Stations.Count + 1}", - SystemId = site.SystemId, - Label = BuildFoundedStationLabel(site.TargetDefinitionId), - Category = "station", - Objective = DetermineFoundationObjective(site.TargetDefinitionId), - Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, - Position = anchor.Position, - FactionId = site.FactionId, - CelestialId = site.CelestialId, - Health = 600f, - MaxHealth = 600f, - }; - - foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) - { - AddStationModule(world, station, moduleId); - } - - world.Stations.Add(station); - StationLifecycleService.EnsureStationCommander(world, station); - anchor.OccupyingStructureId = station.Id; - site.StationId = station.Id; - PrepareNextConstructionSiteStep(world, station, site); - } - - private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) - { - var modules = new List { "module_arg_dock_m_01_lowtech" }; - foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) - { - if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) - { - var storageModule = GetStorageRequirement(itemDefinition.CargoKind); - if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) - { - modules.Add(storageModule); - } - else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) - { - modules.Add("module_arg_stor_container_m_01"); - } - } - } - - if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) - { - modules.Add("module_arg_stor_container_m_01"); - } - - if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) - { - modules.Add("module_gen_prod_energycells_01"); - } - - modules.Add(primaryModuleId); - return modules.Distinct(StringComparer.Ordinal).ToList(); - } - - private static string DetermineFoundationObjective(string commodityId) => - commodityId switch - { - "energycells" => "power", - "water" => "water", - "refinedmetals" => "refinery", - "hullparts" => "hullparts", - "claytronics" => "claytronics", - "shipyard" => "shipyard", - _ => "general", - }; - - private static string BuildFoundedStationLabel(string commodityId) => - $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; -} diff --git a/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs b/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs deleted file mode 100644 index 606c822..0000000 --- a/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs +++ /dev/null @@ -1,392 +0,0 @@ -using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; -using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; - -namespace SpaceGame.Api.Ships.Simulation; - -internal sealed partial class ShipTaskExecutionService -{ - private const float WarpEngageDistanceKilometers = 250_000f; - private const float FrigateDps = 7f; - private const float DestroyerDps = 12f; - private const float CruiserDps = 18f; - private const float CapitalDps = 26f; - - private static float GetLocalTravelSpeed(ShipRuntime ship) => - SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed); - - private static float GetWarpTravelSpeed(ShipRuntime ship) => - SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed); - - private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) => - world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position - ?? Vector3.Zero; - - internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - return task.Kind switch - { - ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds), - ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds), - ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds), - ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds), - ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds), - ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds), - ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds), - ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds), - ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds), - - ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds), - ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds), - _ => UpdateIdle(ship, world, deltaSeconds), - }; - } - - private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - return UpdateTravel(ship, world, deltaSeconds, task); - } - - private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ControllerTaskRuntime task) - { - if (task.TargetPosition is null || task.TargetSystemId is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "none"; - } - - // Resolve live position each frame — entities like stations orbit celestials and move every tick - var targetPosition = ResolveCurrentTargetPosition(world, task); - var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition); - var distance = ship.Position.DistanceTo(targetPosition); - ship.TargetPosition = targetPosition; - - if (ship.SystemId != task.TargetSystemId) - { - if (!HasShipCapabilities(ship.Definition, "ftl")) - { - ship.State = ShipState.Idle; - return "none"; - } - - var destinationEntryCelestial = ResolveSystemEntryCelestial(world, task.TargetSystemId); - var destinationEntryPosition = destinationEntryCelestial?.Position ?? Vector3.Zero; - return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryCelestial); - } - - var currentCelestial = ResolveCurrentCelestial(world, ship); - if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal)) - { - if (!HasShipCapabilities(ship.Definition, "warp")) - { - return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold); - } - - return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial); - } - - if (targetCelestial is not null - && distance > WarpEngageDistanceKilometers - && HasShipCapabilities(ship.Definition, "warp")) - { - return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial); - } - - return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold); - } - - private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds) - { - var task = ship.ControllerTask; - if (string.IsNullOrWhiteSpace(task.TargetEntityId)) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "target-lost"; - } - - var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f); - var hostileStation = hostileShip is null - ? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId) - : null; - - if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal)) - || (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal))) - { - return "target-lost"; - } - - if (hostileShip is null && hostileStation is null) - { - ship.State = ShipState.Idle; - ship.TargetPosition = ship.Position; - return "target-lost"; - } - - var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; - var targetPosition = hostileShip?.Position ?? hostileStation!.Position; - var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; - var attackTask = new ControllerTaskRuntime - { - Kind = ControllerTaskKind.Travel, - TargetEntityId = task.TargetEntityId, - TargetSystemId = targetSystemId, - TargetPosition = targetPosition, - Threshold = attackRange, - }; - - if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) - { - return UpdateTravel(ship, world, deltaSeconds, attackTask); - } - - ship.State = ShipState.EngagingTarget; - ship.TargetPosition = targetPosition; - ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); - var damage = GetShipDamagePerSecond(ship) * deltaSeconds; - - if (hostileShip is not null) - { - hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); - return hostileShip.Health <= 0f ? "target-destroyed" : "none"; - } - - hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f); - return hostileStation.Health <= 0f ? "target-destroyed" : "none"; - } - - private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task) - { - if (!string.IsNullOrWhiteSpace(task.TargetEntityId)) - { - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (station is not null) - { - return station.Position; - } - - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (celestial is not null) - { - return celestial.Position; - } - } - - return task.TargetPosition!.Value; - } - - private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition) - { - if (!string.IsNullOrWhiteSpace(task.TargetEntityId)) - { - var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (station?.CelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId); - } - - var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); - if (celestial is not null) - { - return celestial; - } - } - - return world.Celestials - .Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(targetPosition)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship) - { - if (ship.SpatialState.CurrentCelestialId is not null) - { - return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId); - } - - return world.Celestials - .Where(candidate => candidate.SystemId == ship.SystemId) - .OrderBy(candidate => candidate.Position.DistanceTo(ship.Position)) - .FirstOrDefault(); - } - - private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) => - world.Celestials.FirstOrDefault(candidate => - candidate.SystemId == systemId && - candidate.Kind == SpatialNodeKind.Star); - - private string UpdateLocalTravel( - ShipRuntime ship, - SimulationWorld world, - float deltaSeconds, - string targetSystemId, - Vector3 targetPosition, - CelestialRuntime? targetCelestial, - float threshold) - { - var distance = ship.Position.DistanceTo(targetPosition); - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.Transit = null; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - - if (distance <= threshold) - { - ship.ActionTimer = 0f; - ship.Position = targetPosition; - ship.TargetPosition = ship.Position; - ship.SystemId = targetSystemId; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return "arrived"; - } - - ship.ActionTimer = 0f; - ship.State = ShipState.LocalFlight; - ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds); - return "none"; - } - - private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial) - { - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id) - { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKinds.Warp, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = targetCelestial.Id, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - } - - ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = targetCelestial.Id; - - var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); - if (ship.State != ShipState.Warping) - { - if (ship.State != ShipState.SpoolingWarp) - { - ship.ActionTimer = 0f; - } - - ship.State = ShipState.SpoolingWarp; - if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) - { - return "none"; - } - - ship.State = ShipState.Warping; - } - - var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null - ? ship.Position.DistanceTo(targetPosition) - : (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition))); - ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds); - transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); - return ship.Position.DistanceTo(targetPosition) <= 18f - ? CompleteTransitArrival(ship, targetCelestial.SystemId, targetPosition, targetCelestial) - : "none"; - } - - private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial) - { - var destinationNodeId = targetCelestial?.Id; - var transit = ship.SpatialState.Transit; - if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId) - { - transit = new ShipTransitRuntime - { - Regime = MovementRegimeKinds.FtlTransit, - OriginNodeId = ship.SpatialState.CurrentCelestialId, - DestinationNodeId = destinationNodeId, - StartedAtUtc = world.GeneratedAtUtc, - }; - ship.SpatialState.Transit = transit; - } - - ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit; - ship.SpatialState.CurrentCelestialId = null; - ship.SpatialState.DestinationNodeId = destinationNodeId; - - if (ship.State != ShipState.Ftl) - { - if (ship.State != ShipState.SpoolingFtl) - { - ship.ActionTimer = 0f; - } - - ship.State = ShipState.SpoolingFtl; - if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime)) - { - return "none"; - } - - ship.State = ShipState.Ftl; - } - - var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId); - var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId); - var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition)); - transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance)); - return transit.Progress >= 0.999f - ? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetCelestial) - : "none"; - } - - private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial) - { - ship.ActionTimer = 0f; - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return "arrived"; - } - - private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial) - { - ship.ActionTimer = 0f; - ship.Position = targetPosition; - ship.TargetPosition = targetPosition; - ship.SystemId = targetSystemId; - ship.SpatialState.Transit = null; - ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace; - ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight; - ship.SpatialState.CurrentCelestialId = targetCelestial?.Id; - ship.SpatialState.DestinationNodeId = targetCelestial?.Id; - ship.State = ShipState.Arriving; - return "none"; - } - - private static float GetShipDamagePerSecond(ShipRuntime ship) => - ship.Definition.Class switch - { - "frigate" => FrigateDps, - "destroyer" => DestroyerDps, - "cruiser" => CruiserDps, - "capital" => CapitalDps, - _ => 4f, - }; -} diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index 110584e..88f142a 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -6,11 +6,12 @@ public sealed class SimulationEngine private readonly OrbitalSimulationOptions _orbitalSimulation; private readonly OrbitalStateUpdater _orbitalStateUpdater; private readonly InfrastructureSimulationService _infrastructureSimulation; + private readonly GeopoliticalSimulationService _geopolitics; private readonly CommanderPlanningService _commanderPlanning; + private readonly PlayerFactionService _playerFaction; private readonly StationSimulationService _stationSimulation; private readonly StationLifecycleService _stationLifecycle; - private readonly ShipControlService _shipControl; - private readonly ShipTaskExecutionService _shipTaskExecution; + private readonly ShipAiService _shipAi; private readonly SimulationProjectionService _projection; public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null) @@ -18,11 +19,12 @@ public sealed class SimulationEngine _orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions(); _orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation); _infrastructureSimulation = new InfrastructureSimulationService(); + _geopolitics = new GeopoliticalSimulationService(); _commanderPlanning = new CommanderPlanningService(); + _playerFaction = new PlayerFactionService(); _stationSimulation = new StationSimulationService(); _stationLifecycle = new StationLifecycleService(_stationSimulation); - _shipControl = new ShipControlService(); - _shipTaskExecution = new ShipTaskExecutionService(); + _shipAi = new ShipAiService(); _projection = new SimulationProjectionService(_orbitalSimulation); } @@ -31,13 +33,16 @@ public sealed class SimulationEngine var nowUtc = DateTimeOffset.UtcNow; var events = new List(); var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f); + world.GeneratedAtUtc = nowUtc; world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond; _orbitalStateUpdater.Update(world); _infrastructureSimulation.UpdateClaims(world, events); _infrastructureSimulation.UpdateConstructionSites(world, events); - _commanderPlanning.UpdateCommanders(this, world, simulationDeltaSeconds, events); + _geopolitics.Update(world, simulationDeltaSeconds, events); + _commanderPlanning.UpdateCommanders(world, simulationDeltaSeconds, events); + _playerFaction.Update(world, simulationDeltaSeconds, events); _stationLifecycle.UpdateStations(world, simulationDeltaSeconds, events); foreach (var ship in world.Ships.ToList()) @@ -48,25 +53,12 @@ public sealed class SimulationEngine } var previousPosition = ship.Position; - var previousState = ship.State; - var previousBehavior = ship.DefaultBehavior.Kind; - var previousTask = ship.ControllerTask.Kind; - - _shipControl.RefreshControlLayers(ship, world); - _shipControl.PlanControllerTask(this, ship, world); - - var controllerEvent = _shipTaskExecution.UpdateControllerTask(ship, world, simulationDeltaSeconds); - - _shipControl.AdvanceControlState(this, ship, world, controllerEvent); + _shipAi.UpdateShip(world, ship, simulationDeltaSeconds, events); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(simulationDeltaSeconds); - _shipControl.TrackHistory(ship, controllerEvent); - _shipControl.EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } _orbitalStateUpdater.SyncSpatialState(world); CleanupDestroyedEntities(world, events); - world.GeneratedAtUtc = nowUtc; - return _projection.BuildDelta(world, sequence, events); } @@ -76,18 +68,6 @@ public sealed class SimulationEngine public void PrimeDeltaBaseline(SimulationWorld world) => _projection.PrimeDeltaBaseline(world); - internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string? resourceItemId, string requiredModule) => - _shipControl.PlanResourceHarvest(ship, world, resourceItemId, requiredModule); - - internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) => - _shipControl.PlanStationConstruction(ship, world); - - internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) => - _shipControl.PlanAttackTarget(ship, world); - - internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) => - _shipControl.PlanTransportHaul(ship, world); - internal static float GetShipCargoAmount(ShipRuntime ship) => SimulationRuntimeSupport.GetShipCargoAmount(ship); @@ -95,6 +75,7 @@ public sealed class SimulationEngine { foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList()) { + CreateWreck(world, "ship", ship.Id, ship.SystemId, ship.Position, ship.Definition.CargoCapacity + (ship.Definition.MaxHealth * 0.08f)); world.Ships.Remove(ship); if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation) { @@ -117,6 +98,7 @@ public sealed class SimulationEngine foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList()) { + CreateWreck(world, "station", station.Id, station.SystemId, station.Position, station.MaxHealth * 0.12f); world.Stations.Remove(station); if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial) @@ -138,4 +120,29 @@ public sealed class SimulationEngine events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow)); } } + + private static void CreateWreck(SimulationWorld world, string sourceKind, string sourceEntityId, string systemId, Vector3 position, float amount) + { + var itemId = world.ItemDefinitions.ContainsKey("scrapmetal") + ? "scrapmetal" + : world.ItemDefinitions.ContainsKey("rawscrap") + ? "rawscrap" + : world.ItemDefinitions.Keys.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); + if (itemId is null || amount <= 0.01f) + { + return; + } + + world.Wrecks.Add(new WreckRuntime + { + Id = $"wreck-{sourceKind}-{sourceEntityId}", + SourceKind = sourceKind, + SourceEntityId = sourceEntityId, + SystemId = systemId, + Position = position, + ItemId = itemId, + RemainingAmount = amount, + MaxAmount = amount, + }); + } } diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index e92314e..eb73c77 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; @@ -30,7 +31,9 @@ internal sealed class SimulationProjectionService BuildMarketOrderDeltas(world), BuildPolicyDeltas(world), BuildShipDeltas(world), - BuildFactionDeltas(world)); + BuildFactionDeltas(world), + BuildPlayerFactionDelta(world), + BuildGeopoliticsDelta(world)); public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence) { @@ -167,7 +170,11 @@ internal sealed class SimulationProjectionService policy.TradeAccessPolicy, policy.DockingAccessPolicy, policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy)).ToList(), + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot( ship.Id, ship.Label, @@ -178,11 +185,18 @@ internal sealed class SimulationProjectionService ship.LocalVelocity, ship.TargetLocalPosition, ship.State, - ship.OrderKind, - ship.DefaultBehaviorKind, - ship.BehaviorPhase, - ship.ControllerTaskKind, - ship.CommanderObjective, + ship.OrderQueue, + ship.DefaultBehavior, + ship.Assignment, + ship.Skills, + ship.ActivePlan, + ship.CurrentStepId, + ship.ActiveSubTasks, + ship.ControlSourceKind, + ship.ControlSourceId, + ship.ControlReason, + ship.LastReplanReason, + ship.LastAccessFailureReason, ship.CelestialId, ship.DockedStationId, ship.CommanderId, @@ -194,9 +208,8 @@ internal sealed class SimulationProjectionService ship.FactionId, ship.Health, ship.History, - ship.CurrentAction, ship.SpatialState)).ToList(), - world.Factions.Select(faction => ToFactionDelta(faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot( + world.Factions.Select(faction => ToFactionDelta(world, faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot( faction.Id, faction.Label, faction.Color, @@ -207,11 +220,13 @@ internal sealed class SimulationProjectionService faction.ShipsBuilt, faction.ShipsLost, faction.DefaultPolicySetId, - faction.StrategicAssessment, - faction.StrategicPriorities, - faction.Blackboard, - faction.Objectives, - faction.IssuedTasks)).ToList()); + faction.Doctrine, + faction.Memory, + faction.StrategicState, + faction.DecisionLog, + faction.Commanders)).ToList(), + ToPlayerFactionSnapshot(world.PlayerFaction), + ToGeopoliticalStateSnapshot(world.Geopolitics)); } public void PrimeDeltaBaseline(SimulationWorld world) @@ -260,6 +275,16 @@ internal sealed class SimulationProjectionService { faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id)); } + + if (world.PlayerFaction is not null) + { + world.PlayerFaction.LastDeltaSignature = BuildPlayerFactionSignature(world.PlayerFaction); + } + + if (world.Geopolitics is not null) + { + world.Geopolitics.LastDeltaSignature = BuildGeopoliticalSignature(world.Geopolitics); + } } private static IReadOnlyList BuildNodeDeltas(SimulationWorld world) @@ -419,12 +444,46 @@ internal sealed class SimulationProjectionService } faction.LastDeltaSignature = signature; - deltas.Add(ToFactionDelta(faction, commander)); + deltas.Add(ToFactionDelta(world, faction, commander)); } return deltas; } + private static PlayerFactionSnapshot? BuildPlayerFactionDelta(SimulationWorld world) + { + if (world.PlayerFaction is null) + { + return null; + } + + var signature = BuildPlayerFactionSignature(world.PlayerFaction); + if (signature == world.PlayerFaction.LastDeltaSignature) + { + return null; + } + + world.PlayerFaction.LastDeltaSignature = signature; + return ToPlayerFactionSnapshot(world.PlayerFaction); + } + + private static GeopoliticalStateSnapshot? BuildGeopoliticsDelta(SimulationWorld world) + { + if (world.Geopolitics is null) + { + return null; + } + + var signature = BuildGeopoliticalSignature(world.Geopolitics); + if (signature == world.Geopolitics.LastDeltaSignature) + { + return null; + } + + world.Geopolitics.LastDeltaSignature = signature; + return ToGeopoliticalStateSnapshot(world.Geopolitics); + } + private static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => world.Commanders.FirstOrDefault(c => c.FactionId == factionId && @@ -469,7 +528,7 @@ internal sealed class SimulationProjectionService $"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}"; private static string BuildPolicySignature(PolicySetRuntime policy) => - $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}"; + $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}|{policy.CombatEngagementPolicy}|{policy.AvoidHostileSystems}|{policy.FleeHullRatio:0.###}|{string.Join(",", policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal))}"; private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) => string.Join("|", @@ -484,10 +543,37 @@ internal sealed class SimulationProjectionService ship.TargetPosition.Y.ToString("0.###"), ship.TargetPosition.Z.ToString("0.###"), ship.State.ToContractValue(), - ship.Order?.Kind ?? "none", + string.Join(",", ship.OrderQueue + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => $"{order.Id}:{order.Kind}:{order.Status.ToContractValue()}:{order.Priority}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), ship.DefaultBehavior.Kind, - ship.DefaultBehavior.Phase ?? "none", - ship.ControllerTask.Kind.ToContractValue(), + ship.DefaultBehavior.TargetEntityId ?? "none", + ship.DefaultBehavior.TargetPosition?.X.ToString("0.###") ?? "none", + ship.DefaultBehavior.TargetPosition?.Y.ToString("0.###") ?? "none", + ship.DefaultBehavior.TargetPosition?.Z.ToString("0.###") ?? "none", + ship.DefaultBehavior.WaitSeconds.ToString("0.###"), + ship.DefaultBehavior.Radius.ToString("0.###"), + ship.DefaultBehavior.MaxSystemRange.ToString(CultureInfo.InvariantCulture), + ship.DefaultBehavior.KnownStationsOnly.ToString(), + string.Join(",", ship.DefaultBehavior.RepeatOrders.Select(order => + $"{order.Kind}:{order.TargetEntityId}:{order.TargetSystemId}:{order.ItemId}:{order.WaitSeconds:0.###}:{order.Radius:0.###}:{order.MaxSystemRange?.ToString(CultureInfo.InvariantCulture) ?? "none"}:{order.KnownStationsOnly}")), + ship.DefaultBehavior.RepeatIndex.ToString(CultureInfo.InvariantCulture), + string.Join(",", ship.KnownStationIds.OrderBy(id => id, StringComparer.Ordinal)), + ship.ControlSourceKind, + ship.ControlSourceId ?? "none", + ship.ControlReason ?? "none", + ship.LastReplanReason ?? "none", + ship.LastAccessFailureReason ?? "none", + ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId)?.Assignment is { } assignment + ? $"{assignment.ObjectiveId}:{assignment.Kind}:{assignment.BehaviorKind}:{assignment.Status}:{assignment.CampaignId}:{assignment.TheaterId}:{assignment.TargetSystemId}:{assignment.TargetEntityId}:{assignment.ItemId}:{assignment.Priority:0.###}:{assignment.UpdatedAtUtc.UtcTicks}" + : "no-assignment", + ship.ActivePlan?.Kind ?? "none", + ship.ActivePlan?.Status.ToContractValue() ?? "none", + ship.ActivePlan?.CurrentStepIndex.ToString(CultureInfo.InvariantCulture) ?? "-1", + string.Join(",", + ToActiveSubTaskSnapshots(ship).Select(subTask => + $"{subTask.Id}:{subTask.Kind}:{subTask.Status}:{subTask.Progress:0.###}:{subTask.ElapsedSeconds:0.###}:{subTask.BlockingReason ?? "none"}")), ship.SpatialState.CurrentCelestialId ?? "none", ship.DockedStationId ?? "none", ship.CommanderId ?? "none", @@ -501,13 +587,13 @@ internal sealed class SimulationProjectionService ship.SpatialState.Transit?.DestinationNodeId ?? "none", ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", GetShipCargoAmount(ship).ToString("0.###"), - ship.TrackedActionKey ?? "none", - ship.TrackedActionTotal.ToString("0.###"), - ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site - ? ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site).ToString("0.###") - : "0", + ship.Skills.Navigation.ToString(CultureInfo.InvariantCulture), + ship.Skills.Trade.ToString(CultureInfo.InvariantCulture), + ship.Skills.Mining.ToString(CultureInfo.InvariantCulture), + ship.Skills.Combat.ToString(CultureInfo.InvariantCulture), + ship.Skills.Construction.ToString(CultureInfo.InvariantCulture), ship.Health.ToString("0.###"), - ship.ActionTimer.ToString("0.###")); + GetCurrentShipStep(ship)?.Id ?? "none"); private static string BuildInventorySignature(IReadOnlyDictionary inventory) => string.Join(",", @@ -518,18 +604,126 @@ internal sealed class SimulationProjectionService private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander) { - var prioritySig = commander?.LastStrategicPriorities is { } prios - ? string.Join(",", prios.Select(p => $"{p.Name}:{p.Priority:0.##}")) - : string.Empty; - 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}"; + var assignmentSig = commander?.Assignment is null + ? string.Empty + : $"{commander.Assignment.ObjectiveId}:{commander.Assignment.Kind}:{commander.Assignment.BehaviorKind}:{commander.Assignment.Status}:{commander.Assignment.TargetSystemId}:{commander.Assignment.TargetEntityId}:{commander.Assignment.ItemId}"; + var state = faction.StrategicState; + var strategicSig = string.Join(";", + state.Status, + state.PlanCycle.ToString(CultureInfo.InvariantCulture), + state.EconomicAssessment.PrimaryExpansionSiteId ?? "none", + state.EconomicAssessment.PrimaryExpansionSystemId ?? "none", + state.ThreatAssessment.PrimaryThreatFactionId ?? "none", + state.ThreatAssessment.PrimaryThreatSystemId ?? "none", + state.Theaters.Count.ToString(CultureInfo.InvariantCulture), + state.Campaigns.Count.ToString(CultureInfo.InvariantCulture), + state.Objectives.Count.ToString(CultureInfo.InvariantCulture), + state.Reservations.Count.ToString(CultureInfo.InvariantCulture), + state.ProductionPrograms.Count.ToString(CultureInfo.InvariantCulture), + state.EconomicAssessment.CommoditySignals.Count.ToString(CultureInfo.InvariantCulture), + state.ThreatAssessment.ThreatSignals.Count.ToString(CultureInfo.InvariantCulture)); + var doctrineSig = $"{faction.Doctrine.StrategicPosture}:{faction.Doctrine.ExpansionPosture}:{faction.Doctrine.MilitaryPosture}:{faction.Doctrine.EconomicPosture}"; + var decisionSig = string.Join(",", faction.DecisionLog.Select(entry => entry.Id)); + var theaterSig = string.Join(";", + state.Theaters.OrderBy(theater => theater.Id, StringComparer.Ordinal) + .Select(theater => $"{theater.Id}:{theater.Kind}:{theater.SystemId}:{theater.Status}:{theater.Priority:0.###}:{theater.SupplyRisk:0.###}:{theater.TargetFactionId}:{theater.AnchorEntityId}:{theater.UpdatedAtUtc.UtcTicks}:{string.Join(",", theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal))}")); + var campaignSig = string.Join(";", + state.Campaigns.OrderBy(campaign => campaign.Id, StringComparer.Ordinal) + .Select(campaign => $"{campaign.Id}:{campaign.Kind}:{campaign.Status}:{campaign.Priority:0.###}:{campaign.TheaterId}:{campaign.TargetFactionId}:{campaign.TargetSystemId}:{campaign.TargetEntityId}:{campaign.CurrentStepIndex}:{campaign.PauseReason}:{campaign.ContinuationScore:0.###}:{campaign.SupplyAdequacy:0.###}:{campaign.ReplacementPressure:0.###}:{campaign.RequiresReinforcement}:{campaign.UpdatedAtUtc.UtcTicks}")); + var objectiveSig = string.Join(";", + state.Objectives.OrderBy(objective => objective.Id, StringComparer.Ordinal) + .Select(objective => $"{objective.Id}:{objective.CampaignId}:{objective.TheaterId}:{objective.Kind}:{objective.DelegationKind}:{objective.BehaviorKind}:{objective.Status}:{objective.Priority:0.###}:{objective.CommanderId}:{objective.TargetSystemId}:{objective.TargetEntityId}:{objective.ItemId}:{objective.CurrentStepIndex}:{objective.UseOrders}:{objective.StagingOrderKind}:{objective.ReinforcementLevel}:{objective.UpdatedAtUtc.UtcTicks}:{string.Join(",", objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal))}")); + var reservationSig = string.Join(";", + state.Reservations.OrderBy(reservation => reservation.Id, StringComparer.Ordinal) + .Select(reservation => $"{reservation.Id}:{reservation.ObjectiveId}:{reservation.CampaignId}:{reservation.AssetKind}:{reservation.AssetId}:{reservation.Priority:0.###}:{reservation.UpdatedAtUtc.UtcTicks}")); + var productionSig = string.Join(";", + state.ProductionPrograms.OrderBy(program => program.Id, StringComparer.Ordinal) + .Select(program => $"{program.Id}:{program.Kind}:{program.Status}:{program.Priority:0.###}:{program.CampaignId}:{program.CommodityId}:{program.ModuleId}:{program.ShipKind}:{program.TargetSystemId}:{program.TargetCount}:{program.CurrentCount}:{program.Notes}")); + return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{assignmentSig}|{strategicSig}|{doctrineSig}|{decisionSig}|{theaterSig}|{campaignSig}|{objectiveSig}|{reservationSig}|{productionSig}"; + } + + private static string BuildPlayerFactionSignature(PlayerFactionRuntime player) + { + var intentSig = $"{player.StrategicIntent.StrategicPosture}:{player.StrategicIntent.EconomicPosture}:{player.StrategicIntent.MilitaryPosture}:{player.StrategicIntent.LogisticsPosture}:{player.StrategicIntent.DesiredReserveRatio:0.###}"; + var registrySig = string.Join("|", + player.AssetRegistry.ShipIds.Count, + player.AssetRegistry.StationIds.Count, + player.AssetRegistry.CommanderIds.Count, + player.AssetRegistry.FleetIds.Count, + player.AssetRegistry.TaskForceIds.Count, + player.AssetRegistry.StationGroupIds.Count, + player.AssetRegistry.EconomicRegionIds.Count, + player.AssetRegistry.FrontIds.Count, + player.AssetRegistry.ReserveIds.Count); + var orgSig = string.Join("|", + player.Fleets.Count, + player.TaskForces.Count, + player.StationGroups.Count, + player.EconomicRegions.Count, + player.Fronts.Count, + player.Reserves.Count, + player.Policies.Count, + player.AutomationPolicies.Count, + player.ReinforcementPolicies.Count, + player.ProductionPrograms.Count, + player.Directives.Count, + player.Assignments.Count, + player.Alerts.Count); + var policySig = string.Join(";", + player.Policies.OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.PolicySetId}:{policy.TradeAccessPolicy}:{policy.DockingAccessPolicy}:{policy.ConstructionAccessPolicy}:{policy.OperationalRangePolicy}:{policy.CombatEngagementPolicy}:{policy.AvoidHostileSystems}:{policy.FleeHullRatio:0.###}:{policy.UpdatedAtUtc.UtcTicks}")); + var automationSig = string.Join(";", + player.AutomationPolicies.OrderBy(policy => policy.Id, StringComparer.Ordinal) + .Select(policy => $"{policy.Id}:{policy.ScopeKind}:{policy.ScopeId}:{policy.Enabled}:{policy.BehaviorKind}:{policy.UseOrders}:{policy.StagingOrderKind}:{policy.MaxSystemRange}:{policy.KnownStationsOnly}:{policy.Radius:0.###}:{policy.WaitSeconds:0.###}:{policy.PreferredItemId}:{policy.UpdatedAtUtc.UtcTicks}")); + var directiveSig = string.Join(";", + player.Directives.OrderBy(directive => directive.Id, StringComparer.Ordinal) + .Select(directive => $"{directive.Id}:{directive.ScopeKind}:{directive.ScopeId}:{directive.Kind}:{directive.BehaviorKind}:{directive.UseOrders}:{directive.StagingOrderKind}:{directive.TargetEntityId}:{directive.TargetSystemId}:{directive.ItemId}:{directive.Priority}:{directive.UpdatedAtUtc.UtcTicks}")); + var assignmentSig = string.Join(";", + player.Assignments.OrderBy(assignment => assignment.Id, StringComparer.Ordinal) + .Select(assignment => $"{assignment.Id}:{assignment.AssetKind}:{assignment.AssetId}:{assignment.FleetId}:{assignment.TaskForceId}:{assignment.StationGroupId}:{assignment.EconomicRegionId}:{assignment.FrontId}:{assignment.ReserveId}:{assignment.DirectiveId}:{assignment.PolicyId}:{assignment.AutomationPolicyId}:{assignment.Role}:{assignment.Status}:{assignment.UpdatedAtUtc.UtcTicks}")); + var decisionSig = string.Join(",", player.DecisionLog.Select(entry => entry.Id)); + var orgDetailSig = string.Join(";", + player.Fleets.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"fleet:{entry.Id}:{entry.FrontId}:{entry.HomeSystemId}:{entry.HomeStationId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}") + .Concat(player.TaskForces.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"task-force:{entry.Id}:{entry.FleetId}:{entry.FrontId}:{entry.CommanderId}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.StationGroups.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"station-group:{entry.Id}:{entry.EconomicRegionId}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.EconomicRegions.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"economic-region:{entry.Id}:{entry.SharedEconomicRegionId}:{entry.Role}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.Fronts.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"front:{entry.Id}:{entry.SharedFrontLineId}:{entry.TargetFactionId}:{entry.Priority:0.###}:{entry.UpdatedAtUtc.UtcTicks}")) + .Concat(player.Reserves.OrderBy(entry => entry.Id, StringComparer.Ordinal).Select(entry => $"reserve:{entry.Id}:{entry.HomeSystemId}:{entry.UpdatedAtUtc.UtcTicks}"))); + var alertSig = string.Join(";", + player.Alerts.OrderBy(alert => alert.Id, StringComparer.Ordinal) + .Select(alert => $"{alert.Id}:{alert.Kind}:{alert.Severity}:{alert.AssetKind}:{alert.AssetId}:{alert.RelatedDirectiveId}:{alert.Status}:{alert.CreatedAtUtc.UtcTicks}")); + return $"{player.SovereignFactionId}|{player.Status}|{intentSig}|{registrySig}|{orgSig}|{policySig}|{automationSig}|{directiveSig}|{assignmentSig}|{decisionSig}|{orgDetailSig}|{alertSig}"; + } + + private static string BuildGeopoliticalSignature(GeopoliticalStateRuntime state) + { + var diplomacySig = string.Join(";", + state.Diplomacy.Relations.OrderBy(relation => relation.Id, StringComparer.Ordinal) + .Select(relation => $"{relation.Id}:{relation.Posture}:{relation.TensionScore:0.###}:{relation.GrievanceScore:0.###}:{relation.TradeAccessPolicy}:{relation.MilitaryAccessPolicy}:{relation.WarStateId}:{relation.UpdatedAtUtc.UtcTicks}")); + var territorySig = string.Join(";", + state.Territory.ControlStates.OrderBy(control => control.SystemId, StringComparer.Ordinal) + .Select(control => $"{control.SystemId}:{control.ControllerFactionId}:{control.PrimaryClaimantFactionId}:{control.ControlKind}:{control.IsContested}:{control.ControlScore:0.###}:{control.StrategicValue:0.###}:{control.UpdatedAtUtc.UtcTicks}")); + var economySig = string.Join(";", + state.EconomyRegions.Regions.OrderBy(region => region.Id, StringComparer.Ordinal) + .Select(region => $"{region.Id}:{region.FactionId}:{region.Kind}:{region.Status}:{region.CoreSystemId}:{string.Join(",", region.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{region.UpdatedAtUtc.UtcTicks}")); + var tensionSig = string.Join(";", + state.Diplomacy.BorderTensions.OrderBy(tension => tension.Id, StringComparer.Ordinal) + .Select(tension => $"{tension.Id}:{tension.RelationId}:{tension.BorderEdgeId}:{tension.Status}:{tension.TensionScore:0.###}:{tension.IncidentScore:0.###}:{tension.MilitaryPressure:0.###}:{tension.AccessFriction:0.###}:{string.Join(",", tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{tension.UpdatedAtUtc.UtcTicks}")); + var frontSig = string.Join(";", + state.Territory.FrontLines.OrderBy(front => front.Id, StringComparer.Ordinal) + .Select(front => $"{front.Id}:{front.Kind}:{front.Status}:{front.AnchorSystemId}:{front.PressureScore:0.###}:{front.SupplyRisk:0.###}:{string.Join(",", front.FactionIds.OrderBy(id => id, StringComparer.Ordinal))}:{string.Join(",", front.SystemIds.OrderBy(id => id, StringComparer.Ordinal))}:{front.UpdatedAtUtc.UtcTicks}")); + var corridorSig = string.Join(";", + state.EconomyRegions.Corridors.OrderBy(corridor => corridor.Id, StringComparer.Ordinal) + .Select(corridor => $"{corridor.Id}:{corridor.FactionId}:{corridor.Kind}:{corridor.Status}:{corridor.RiskScore:0.###}:{corridor.ThroughputScore:0.###}:{corridor.AccessState}:{string.Join(",", corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal))}:{corridor.UpdatedAtUtc.UtcTicks}")); + var bottleneckSig = string.Join(";", + state.EconomyRegions.Bottlenecks.OrderBy(bottleneck => bottleneck.Id, StringComparer.Ordinal) + .Select(bottleneck => $"{bottleneck.Id}:{bottleneck.RegionId}:{bottleneck.ItemId}:{bottleneck.Cause}:{bottleneck.Status}:{bottleneck.Severity:0.###}:{bottleneck.UpdatedAtUtc.UtcTicks}")); + var assessmentSig = string.Join(";", + state.EconomyRegions.SecurityAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) + .Select(assessment => $"security:{assessment.RegionId}:{assessment.SupplyRisk:0.###}:{assessment.BorderPressure:0.###}:{assessment.ActiveWarCount}:{assessment.HostileRelationCount}:{assessment.AccessFriction:0.###}:{assessment.UpdatedAtUtc.UtcTicks}") + .Concat(state.EconomyRegions.EconomicAssessments.OrderBy(assessment => assessment.RegionId, StringComparer.Ordinal) + .Select(assessment => $"economic:{assessment.RegionId}:{assessment.SustainmentScore:0.###}:{assessment.ProductionDepth:0.###}:{assessment.ConstructionPressure:0.###}:{assessment.CorridorDependency:0.###}:{assessment.UpdatedAtUtc.UtcTicks}"))); + return $"{state.Cycle}|{state.UpdatedAtUtc.UtcTicks}|{state.Routes.Count}|{state.Diplomacy.Relations.Count}|{state.Diplomacy.Incidents.Count}|{state.Diplomacy.Wars.Count}|{state.Territory.ControlStates.Count}|{state.Territory.BorderEdges.Count}|{state.Territory.FrontLines.Count}|{state.EconomyRegions.Regions.Count}|{state.EconomyRegions.Corridors.Count}|{diplomacySig}|{territorySig}|{economySig}|{tensionSig}|{frontSig}|{corridorSig}|{bottleneckSig}|{assessmentSig}"; } private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( @@ -675,7 +869,11 @@ internal sealed class SimulationProjectionService policy.TradeAccessPolicy, policy.DockingAccessPolicy, policy.ConstructionAccessPolicy, - policy.OperationalRangePolicy); + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) { @@ -692,11 +890,18 @@ internal sealed class SimulationProjectionService ToDto(ship.Velocity), ToDto(ship.TargetPosition), ship.State.ToContractValue(), - ship.Order?.Kind, - ship.DefaultBehavior.Kind, - ship.DefaultBehavior.Phase, - ship.ControllerTask.Kind.ToContractValue(), - commander?.ActiveActionName, + ToShipOrderSnapshots(ship), + ToDefaultBehaviorSnapshot(ship.DefaultBehavior), + ToShipAssignmentSnapshot(commander), + new ShipSkillProfileSnapshot(ship.Skills.Navigation, ship.Skills.Trade, ship.Skills.Mining, ship.Skills.Combat, ship.Skills.Construction), + ToShipPlanSnapshot(ship.ActivePlan), + GetCurrentShipStep(ship)?.Id, + ToActiveSubTaskSnapshots(ship), + ship.ControlSourceKind, + ship.ControlSourceId, + ship.ControlReason, + ship.LastReplanReason, + ship.LastAccessFailureReason, ship.SpatialState.CurrentCelestialId, ship.DockedStationId, ship.CommanderId, @@ -709,34 +914,9 @@ internal sealed class SimulationProjectionService ship.FactionId, ship.Health, ship.History.ToList(), - ToShipActionProgressSnapshot(world, ship), ToShipSpatialStateSnapshot(ship.SpatialState)); } - private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship) - { - var progress = ship.State switch - { - ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)), - ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)), - ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)), - ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)), - ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)), - ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)), - ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)), - ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)), - ShipState.Loading or ShipState.Unloading => null, - ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null - ? null - : world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site - ? null - : CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site)), - _ => null, - }; - - return progress; - } - private static (float Speed, string Unit) ToShipTravelSpeed(ShipRuntime ship) { return ship.SpatialState.MovementRegime switch @@ -747,20 +927,6 @@ internal sealed class SimulationProjectionService }; } - private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) => - new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f)); - - private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount) - { - if (totalAmount <= 0.01f) - { - return null; - } - - var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f); - return new ShipActionProgressSnapshot(label, progress); - } - private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary inventory) => inventory .Where(entry => entry.Value > 0.001f) @@ -768,173 +934,204 @@ internal sealed class SimulationProjectionService .Select(entry => new InventoryEntry(entry.Key, entry.Value)) .ToList(); - private static FactionDelta ToFactionDelta(FactionRuntime faction, CommanderRuntime? commander) + private static IReadOnlyList ToShipOrderSnapshots(ShipRuntime ship) => + ship.OrderQueue + .OrderByDescending(order => order.Priority) + .ThenBy(order => order.CreatedAtUtc) + .Select(order => new ShipOrderSnapshot( + order.Id, + order.Kind, + order.Status.ToContractValue(), + order.Priority, + order.InterruptCurrentPlan, + order.CreatedAtUtc, + order.Label, + order.TargetEntityId, + order.TargetSystemId, + order.TargetPosition is null ? null : ToDto(order.TargetPosition.Value), + order.SourceStationId, + order.DestinationStationId, + order.ItemId, + order.NodeId, + order.ConstructionSiteId, + order.ModuleId, + order.WaitSeconds, + order.Radius, + order.MaxSystemRange, + order.KnownStationsOnly, + order.FailureReason)) + .ToList(); + + private static DefaultBehaviorSnapshot ToDefaultBehaviorSnapshot(DefaultBehaviorRuntime behavior) => + new( + behavior.Kind, + behavior.HomeSystemId, + behavior.HomeStationId, + behavior.AreaSystemId, + behavior.TargetEntityId, + behavior.PreferredItemId, + behavior.PreferredNodeId, + behavior.PreferredConstructionSiteId, + behavior.PreferredModuleId, + behavior.TargetPosition is null ? null : ToDto(behavior.TargetPosition.Value), + behavior.WaitSeconds, + behavior.Radius, + behavior.MaxSystemRange, + behavior.KnownStationsOnly, + behavior.PatrolPoints.Select(ToDto).ToList(), + behavior.PatrolIndex, + behavior.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + behavior.RepeatIndex); + + private static ShipOrderTemplateSnapshot ToShipOrderTemplateSnapshot(ShipOrderTemplateRuntime template) => + new( + template.Kind, + template.Label, + template.TargetEntityId, + template.TargetSystemId, + template.TargetPosition is null ? null : ToDto(template.TargetPosition.Value), + template.SourceStationId, + template.DestinationStationId, + template.ItemId, + template.NodeId, + template.ConstructionSiteId, + template.ModuleId, + template.WaitSeconds, + template.Radius, + template.MaxSystemRange, + template.KnownStationsOnly); + + private static ShipAssignmentSnapshot? ToShipAssignmentSnapshot(CommanderRuntime? commander) { - FactionPlanningStateSnapshot? strategicAssessment = null; - IReadOnlyList? strategicPriorities = null; - FactionBlackboardSnapshot? blackboard = null; - IReadOnlyList? objectives = null; - IReadOnlyList? issuedTasks = null; - - if (commander?.LastStrategicAssessment is { } ps) + if (commander?.Assignment is not { } assignment) { - strategicAssessment = new FactionPlanningStateSnapshot( - ps.MilitaryShipCount, - ps.MinerShipCount, - ps.TransportShipCount, - ps.ConstructorShipCount, - ps.ControlledSystemCount, - ps.TargetSystemCount, - ps.HasShipFactory, - NormalizeFiniteFloat(ps.OreStockpile), - 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); + return null; } - if (commander?.LastStrategicPriorities is { } prios) + return new ShipAssignmentSnapshot( + commander.Id, + commander.ParentCommanderId, + assignment.Kind, + assignment.BehaviorKind, + assignment.Status, + assignment.ObjectiveId, + assignment.CampaignId, + assignment.TheaterId, + assignment.Priority, + assignment.HomeSystemId, + assignment.HomeStationId, + assignment.TargetSystemId, + assignment.TargetEntityId, + assignment.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), + assignment.ItemId, + assignment.Notes, + assignment.UpdatedAtUtc); + } + + private static ShipPlanSnapshot? ToShipPlanSnapshot(ShipPlanRuntime? plan) + { + if (plan is null) { - strategicPriorities = prios.Select(p => new FactionStrategicPrioritySnapshot(p.Name, p.Priority)).ToList(); + return null; } - if (commander?.FactionBlackboard is { } bb) + return new ShipPlanSnapshot( + plan.Id, + plan.SourceKind.ToContractValue(), + plan.SourceId, + plan.Kind, + plan.Status.ToContractValue(), + plan.Summary, + plan.CurrentStepIndex, + plan.CreatedAtUtc, + plan.UpdatedAtUtc, + plan.InterruptReason, + plan.FailureReason, + plan.Steps.Select(ToShipPlanStepSnapshot).ToList()); + } + + private static ShipPlanStepSnapshot ToShipPlanStepSnapshot(ShipPlanStepRuntime step) => + new( + step.Id, + step.Kind, + step.Status.ToContractValue(), + step.Summary, + step.BlockingReason, + step.CurrentSubTaskIndex, + step.SubTasks.Select(ToShipSubTaskSnapshot).ToList()); + + private static ShipSubTaskSnapshot ToShipSubTaskSnapshot(ShipSubTaskRuntime subTask) => + new( + subTask.Id, + subTask.Kind, + subTask.Status.ToContractValue(), + subTask.Summary, + subTask.TargetEntityId, + subTask.TargetSystemId, + subTask.TargetNodeId, + subTask.TargetPosition is null ? null : ToDto(subTask.TargetPosition.Value), + subTask.ItemId, + subTask.ModuleId, + subTask.Threshold, + subTask.Amount, + subTask.Progress, + subTask.ElapsedSeconds, + subTask.TotalSeconds, + subTask.BlockingReason); + + private static IReadOnlyList ToActiveSubTaskSnapshots(ShipRuntime ship) + { + var step = GetCurrentShipStep(ship); + if (step is null) { - 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()); + return []; } - 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.StatusReason, - step.ExecutionBindingKind, - step.ExecutionBindingTargetId, - step.ExecutionBindingSummary, - 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(); - } + return step.SubTasks + .Where(subTask => subTask.Status is WorkStatus.Pending or WorkStatus.Active or WorkStatus.Blocked) + .Select(ToShipSubTaskSnapshot) + .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(); - } + private static ShipPlanStepRuntime? GetCurrentShipStep(ShipRuntime ship) => + ship.ActivePlan is null || ship.ActivePlan.CurrentStepIndex >= ship.ActivePlan.Steps.Count + ? null + : ship.ActivePlan.Steps[ship.ActivePlan.CurrentStepIndex]; + + private static CommanderAssignmentSnapshot ToCommanderAssignmentSnapshot(CommanderRuntime commander) + { + var assignment = commander.Assignment; + return new CommanderAssignmentSnapshot( + commander.Id, + assignment?.Kind ?? "unassigned", + assignment?.BehaviorKind ?? "none", + assignment?.Status ?? "idle", + assignment?.ObjectiveId, + assignment?.CampaignId, + assignment?.TheaterId, + commander.ParentCommanderId, + commander.ControlledEntityId, + assignment?.Priority ?? 0f, + assignment?.HomeSystemId, + assignment?.HomeStationId, + assignment?.TargetSystemId, + assignment?.TargetEntityId, + assignment?.TargetPosition is null ? null : ToDto(assignment.TargetPosition.Value), + assignment?.ItemId, + assignment?.Notes, + assignment?.UpdatedAtUtc, + commander.ActiveObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + commander.SubordinateCommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); + } + + private static FactionDelta ToFactionDelta(SimulationWorld world, FactionRuntime faction, CommanderRuntime? commander) + { + var commanders = world.Commanders + .Where(candidate => candidate.FactionId == faction.Id) + .OrderBy(candidate => candidate.Kind, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .Select(ToCommanderAssignmentSnapshot) + .ToList(); return new FactionDelta( faction.Id, @@ -947,11 +1144,731 @@ internal sealed class SimulationProjectionService faction.ShipsBuilt, faction.ShipsLost, faction.DefaultPolicySetId, - strategicAssessment, - strategicPriorities, - blackboard, - objectives, - issuedTasks); + ToFactionDoctrineSnapshot(faction.Doctrine), + ToFactionMemorySnapshot(faction.Memory), + ToFactionStrategicStateSnapshot(faction.StrategicState), + ToFactionDecisionLogSnapshots(faction.DecisionLog), + commanders); + } + + private static FactionDoctrineSnapshot ToFactionDoctrineSnapshot(FactionDoctrineRuntime doctrine) => new( + doctrine.StrategicPosture, + doctrine.ExpansionPosture, + doctrine.MilitaryPosture, + doctrine.EconomicPosture, + doctrine.DesiredControlledSystems, + doctrine.DesiredMilitaryPerFront, + doctrine.DesiredMinersPerSystem, + doctrine.DesiredTransportsPerSystem, + doctrine.DesiredConstructors, + doctrine.ReserveCreditsRatio, + doctrine.ExpansionBudgetRatio, + doctrine.WarBudgetRatio, + doctrine.ReserveMilitaryRatio, + doctrine.OffensiveReadinessThreshold, + doctrine.SupplySecurityBias, + doctrine.FailureAversion, + doctrine.ReinforcementLeadPerFront); + + private static FactionMemorySnapshot ToFactionMemorySnapshot(FactionMemoryRuntime memory) => new( + memory.LastPlanCycle, + memory.UpdatedAtUtc, + memory.LastObservedShipsBuilt, + memory.LastObservedShipsLost, + memory.LastObservedCredits, + memory.KnownSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + memory.KnownEnemyFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + memory.SystemMemories + .OrderBy(entry => entry.SystemId, StringComparer.Ordinal) + .Select(entry => new FactionSystemMemorySnapshot( + entry.SystemId, + entry.LastSeenAtUtc, + entry.LastEnemyShipCount, + entry.LastEnemyStationCount, + entry.ControlledByFaction, + entry.LastRole, + NormalizeFiniteFloat(entry.FrontierPressure), + NormalizeFiniteFloat(entry.RouteRisk), + NormalizeFiniteFloat(entry.HistoricalShortagePressure), + entry.OffensiveFailures, + entry.DefensiveFailures, + entry.OffensiveSuccesses, + entry.DefensiveSuccesses, + entry.LastContestedAtUtc, + entry.LastShortageAtUtc)) + .ToList(), + memory.CommodityMemories + .OrderBy(entry => entry.ItemId, StringComparer.Ordinal) + .Select(entry => new FactionCommodityMemorySnapshot( + entry.ItemId, + NormalizeFiniteFloat(entry.HistoricalShortageScore), + NormalizeFiniteFloat(entry.HistoricalSurplusScore), + NormalizeFiniteFloat(entry.LastObservedBacklog), + entry.UpdatedAtUtc, + entry.LastCriticalAtUtc)) + .ToList(), + memory.RecentOutcomes + .OrderBy(entry => entry.OccurredAtUtc) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .Select(entry => new FactionOutcomeRecordSnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedCampaignId, + entry.RelatedObjectiveId, + entry.OccurredAtUtc)) + .ToList()); + + private static FactionStrategicStateSnapshot ToFactionStrategicStateSnapshot(FactionStrategicStateRuntime state) => new( + state.PlanCycle, + state.UpdatedAtUtc, + state.Status, + new FactionBudgetSnapshot( + state.Budget.ReservedCredits, + state.Budget.ExpansionCredits, + state.Budget.WarCredits, + state.Budget.ReservedMilitaryAssets, + state.Budget.ReservedLogisticsAssets, + state.Budget.ReservedConstructionAssets), + new FactionEconomicAssessmentSnapshot( + state.EconomicAssessment.PlanCycle, + state.EconomicAssessment.UpdatedAtUtc, + state.EconomicAssessment.MilitaryShipCount, + state.EconomicAssessment.MinerShipCount, + state.EconomicAssessment.TransportShipCount, + state.EconomicAssessment.ConstructorShipCount, + state.EconomicAssessment.ControlledSystemCount, + state.EconomicAssessment.TargetMilitaryShipCount, + state.EconomicAssessment.TargetMinerShipCount, + state.EconomicAssessment.TargetTransportShipCount, + state.EconomicAssessment.TargetConstructorShipCount, + state.EconomicAssessment.HasShipyard, + state.EconomicAssessment.HasWarIndustrySupplyChain, + state.EconomicAssessment.PrimaryExpansionSiteId, + state.EconomicAssessment.PrimaryExpansionSystemId, + NormalizeFiniteFloat(state.EconomicAssessment.ReplacementPressure), + NormalizeFiniteFloat(state.EconomicAssessment.SustainmentScore), + NormalizeFiniteFloat(state.EconomicAssessment.LogisticsSecurityScore), + state.EconomicAssessment.CriticalShortageCount, + state.EconomicAssessment.IndustrialBottleneckItemId, + state.EconomicAssessment.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()), + new FactionThreatAssessmentSnapshot( + state.ThreatAssessment.PlanCycle, + state.ThreatAssessment.UpdatedAtUtc, + state.ThreatAssessment.EnemyFactionCount, + state.ThreatAssessment.EnemyShipCount, + state.ThreatAssessment.EnemyStationCount, + state.ThreatAssessment.PrimaryThreatFactionId, + state.ThreatAssessment.PrimaryThreatSystemId, + state.ThreatAssessment.ThreatSignals.Select(signal => new FactionThreatSignalSnapshot( + signal.ScopeId, + signal.ScopeKind, + signal.EnemyShipCount, + signal.EnemyStationCount, + signal.EnemyFactionId)).ToList()), + state.Theaters.Select(theater => new FactionTheaterSnapshot( + theater.Id, + theater.Kind, + theater.SystemId, + theater.Status, + theater.Priority, + NormalizeFiniteFloat(theater.SupplyRisk), + NormalizeFiniteFloat(theater.FriendlyAssetValue), + theater.TargetFactionId, + theater.AnchorEntityId, + theater.AnchorPosition is null ? null : ToDto(theater.AnchorPosition.Value), + theater.UpdatedAtUtc, + theater.CampaignIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Campaigns.Select(campaign => new FactionCampaignSnapshot( + campaign.Id, + campaign.Kind, + campaign.Status, + campaign.Priority, + campaign.TheaterId, + campaign.TargetFactionId, + campaign.TargetSystemId, + campaign.TargetEntityId, + campaign.CommodityId, + campaign.SupportStationId, + campaign.CurrentStepIndex, + campaign.CreatedAtUtc, + campaign.UpdatedAtUtc, + campaign.Summary, + campaign.PauseReason, + NormalizeFiniteFloat(campaign.ContinuationScore), + NormalizeFiniteFloat(campaign.SupplyAdequacy), + NormalizeFiniteFloat(campaign.ReplacementPressure), + campaign.FailureCount, + campaign.SuccessCount, + campaign.FleetCommanderId, + campaign.RequiresReinforcement, + campaign.Steps.Select(ToFactionPlanStepSnapshot).ToList(), + campaign.ObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Objectives.Select(objective => new FactionObjectiveSnapshot( + objective.Id, + objective.CampaignId, + objective.TheaterId, + objective.Kind, + objective.DelegationKind, + objective.BehaviorKind, + objective.Status, + objective.Priority, + objective.CommanderId, + objective.HomeSystemId, + objective.HomeStationId, + objective.TargetSystemId, + objective.TargetEntityId, + objective.TargetPosition is null ? null : ToDto(objective.TargetPosition.Value), + objective.ItemId, + objective.Notes, + objective.CurrentStepIndex, + objective.CreatedAtUtc, + objective.UpdatedAtUtc, + objective.UseOrders, + objective.StagingOrderKind, + objective.ReinforcementLevel, + objective.Steps.Select(ToFactionPlanStepSnapshot).ToList(), + objective.ReservedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Reservations.Select(reservation => new FactionReservationSnapshot( + reservation.Id, + reservation.ObjectiveId, + reservation.CampaignId, + reservation.AssetKind, + reservation.AssetId, + reservation.Priority, + reservation.CreatedAtUtc, + reservation.UpdatedAtUtc)).ToList(), + state.ProductionPrograms.Select(program => new FactionProductionProgramSnapshot( + program.Id, + program.Kind, + program.Status, + program.Priority, + program.CampaignId, + program.CommodityId, + program.ModuleId, + program.ShipKind, + program.TargetSystemId, + program.TargetCount, + program.CurrentCount, + program.Notes)).ToList()); + + private static FactionPlanStepSnapshot ToFactionPlanStepSnapshot(FactionPlanStepRuntime step) => new( + step.Id, + step.Kind, + step.Status, + step.Summary, + step.BlockingReason); + + private static IReadOnlyList ToFactionDecisionLogSnapshots(IReadOnlyCollection entries) => + entries + .OrderBy(entry => entry.OccurredAtUtc) + .ThenBy(entry => entry.Id, StringComparer.Ordinal) + .Select(entry => new FactionDecisionLogEntrySnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedEntityId, + entry.PlanCycle, + entry.OccurredAtUtc)) + .ToList(); + + private static PlayerFactionSnapshot? ToPlayerFactionSnapshot(PlayerFactionRuntime? player) + { + if (player is null) + { + return null; + } + + return new PlayerFactionSnapshot( + player.Id, + player.Label, + player.SovereignFactionId, + player.Status, + player.CreatedAtUtc, + player.UpdatedAtUtc, + new PlayerAssetRegistrySnapshot( + player.AssetRegistry.ShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.CommanderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.PolicySetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.EconomicRegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + player.AssetRegistry.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList()), + new PlayerStrategicIntentSnapshot( + player.StrategicIntent.StrategicPosture, + player.StrategicIntent.EconomicPosture, + player.StrategicIntent.MilitaryPosture, + player.StrategicIntent.LogisticsPosture, + player.StrategicIntent.DesiredReserveRatio, + player.StrategicIntent.AllowDelegatedCombatAutomation, + player.StrategicIntent.AllowDelegatedEconomicAutomation, + player.StrategicIntent.Notes), + player.Fleets.Select(fleet => new PlayerFleetSnapshot( + fleet.Id, + fleet.Label, + fleet.Status, + fleet.Role, + fleet.CommanderId, + fleet.FrontId, + fleet.HomeSystemId, + fleet.HomeStationId, + fleet.PolicyId, + fleet.AutomationPolicyId, + fleet.ReinforcementPolicyId, + fleet.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.TaskForceIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + fleet.UpdatedAtUtc)).ToList(), + player.TaskForces.Select(taskForce => new PlayerTaskForceSnapshot( + taskForce.Id, + taskForce.Label, + taskForce.Status, + taskForce.Role, + taskForce.FleetId, + taskForce.CommanderId, + taskForce.FrontId, + taskForce.PolicyId, + taskForce.AutomationPolicyId, + taskForce.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + taskForce.UpdatedAtUtc)).ToList(), + player.StationGroups.Select(group => new PlayerStationGroupSnapshot( + group.Id, + group.Label, + group.Status, + group.Role, + group.EconomicRegionId, + group.PolicyId, + group.AutomationPolicyId, + group.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.FocusItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + group.UpdatedAtUtc)).ToList(), + player.EconomicRegions.Select(region => new PlayerEconomicRegionSnapshot( + region.Id, + region.Label, + region.Status, + region.Role, + region.SharedEconomicRegionId, + region.PolicyId, + region.AutomationPolicyId, + region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.StationGroupIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.UpdatedAtUtc)).ToList(), + player.Fronts.Select(front => new PlayerFrontSnapshot( + front.Id, + front.Label, + front.Status, + front.Priority, + front.Posture, + front.SharedFrontLineId, + front.TargetFactionId, + front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.FleetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.ReserveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.DirectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.UpdatedAtUtc)).ToList(), + player.Reserves.Select(reserve => new PlayerReserveGroupSnapshot( + reserve.Id, + reserve.Label, + reserve.Status, + reserve.ReserveKind, + reserve.HomeSystemId, + reserve.PolicyId, + reserve.AssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.FrontIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + reserve.UpdatedAtUtc)).ToList(), + player.Policies.Select(policy => new PlayerFactionPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.PolicySetId, + policy.AllowDelegatedCombat, + policy.AllowDelegatedTrade, + policy.ReserveCreditsRatio, + policy.ReserveMilitaryRatio, + policy.TradeAccessPolicy, + policy.DockingAccessPolicy, + policy.ConstructionAccessPolicy, + policy.OperationalRangePolicy, + policy.CombatEngagementPolicy, + policy.AvoidHostileSystems, + policy.FleeHullRatio, + policy.BlacklistedSystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.AutomationPolicies.Select(policy => new PlayerAutomationPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.Enabled, + policy.BehaviorKind, + policy.UseOrders, + policy.StagingOrderKind, + policy.MaxSystemRange, + policy.KnownStationsOnly, + policy.Radius, + policy.WaitSeconds, + policy.PreferredItemId, + policy.Notes, + policy.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + policy.UpdatedAtUtc)).ToList(), + player.ReinforcementPolicies.Select(policy => new PlayerReinforcementPolicySnapshot( + policy.Id, + policy.Label, + policy.ScopeKind, + policy.ScopeId, + policy.ShipKind, + policy.DesiredAssetCount, + policy.MinimumReserveCount, + policy.AutoTransferReserves, + policy.AutoQueueProduction, + policy.SourceReserveId, + policy.TargetFrontId, + policy.Notes, + policy.UpdatedAtUtc)).ToList(), + player.ProductionPrograms.Select(program => new PlayerProductionProgramSnapshot( + program.Id, + program.Label, + program.Status, + program.Kind, + program.TargetShipKind, + program.TargetModuleId, + program.TargetItemId, + program.TargetCount, + program.CurrentCount, + program.StationGroupId, + program.ReinforcementPolicyId, + program.Notes, + program.UpdatedAtUtc)).ToList(), + player.Directives.Select(directive => new PlayerDirectiveSnapshot( + directive.Id, + directive.Label, + directive.Status, + directive.Kind, + directive.ScopeKind, + directive.ScopeId, + directive.TargetEntityId, + directive.TargetSystemId, + directive.TargetPosition is null ? null : ToDto(directive.TargetPosition.Value), + directive.HomeSystemId, + directive.HomeStationId, + directive.SourceStationId, + directive.DestinationStationId, + directive.BehaviorKind, + directive.UseOrders, + directive.StagingOrderKind, + directive.ItemId, + directive.PreferredNodeId, + directive.PreferredConstructionSiteId, + directive.PreferredModuleId, + directive.Priority, + directive.Radius, + directive.WaitSeconds, + directive.MaxSystemRange, + directive.KnownStationsOnly, + directive.PatrolPoints.Select(ToDto).ToList(), + directive.RepeatOrders.Select(ToShipOrderTemplateSnapshot).ToList(), + directive.PolicyId, + directive.AutomationPolicyId, + directive.Notes, + directive.CreatedAtUtc, + directive.UpdatedAtUtc)).ToList(), + player.Assignments.Select(assignment => new PlayerAssignmentSnapshot( + assignment.Id, + assignment.AssetKind, + assignment.AssetId, + assignment.FleetId, + assignment.TaskForceId, + assignment.StationGroupId, + assignment.EconomicRegionId, + assignment.FrontId, + assignment.ReserveId, + assignment.DirectiveId, + assignment.PolicyId, + assignment.AutomationPolicyId, + assignment.Role, + assignment.Status, + assignment.UpdatedAtUtc)).ToList(), + player.DecisionLog.Select(entry => new PlayerDecisionLogEntrySnapshot( + entry.Id, + entry.Kind, + entry.Summary, + entry.RelatedEntityKind, + entry.RelatedEntityId, + entry.OccurredAtUtc)).ToList(), + player.Alerts.Select(alert => new PlayerAlertSnapshot( + alert.Id, + alert.Kind, + alert.Severity, + alert.Summary, + alert.AssetKind, + alert.AssetId, + alert.RelatedDirectiveId, + alert.Status, + alert.CreatedAtUtc)).ToList()); + } + + private static GeopoliticalStateSnapshot? ToGeopoliticalStateSnapshot(GeopoliticalStateRuntime? state) + { + if (state is null) + { + return null; + } + + return new GeopoliticalStateSnapshot( + state.Cycle, + state.UpdatedAtUtc, + state.Routes.Select(route => new SystemRouteLinkSnapshot( + route.Id, + route.SourceSystemId, + route.DestinationSystemId, + route.Distance, + route.IsPrimaryLane)).ToList(), + new DiplomaticStateSnapshot( + state.Diplomacy.Relations.Select(relation => new DiplomaticRelationSnapshot( + relation.Id, + relation.FactionAId, + relation.FactionBId, + relation.Status, + relation.Posture, + relation.TrustScore, + relation.TensionScore, + relation.GrievanceScore, + relation.TradeAccessPolicy, + relation.MilitaryAccessPolicy, + relation.WarStateId, + relation.CeasefireUntilUtc, + relation.UpdatedAtUtc, + relation.ActiveTreatyIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + relation.ActiveIncidentIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Treaties.Select(treaty => new TreatySnapshot( + treaty.Id, + treaty.Kind, + treaty.Status, + treaty.TradeAccessPolicy, + treaty.MilitaryAccessPolicy, + treaty.Summary, + treaty.CreatedAtUtc, + treaty.UpdatedAtUtc, + treaty.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Incidents.Select(incident => new DiplomaticIncidentSnapshot( + incident.Id, + incident.Kind, + incident.Status, + incident.SourceFactionId, + incident.TargetFactionId, + incident.SystemId, + incident.BorderEdgeId, + incident.Summary, + incident.Severity, + incident.EscalationScore, + incident.CreatedAtUtc, + incident.LastObservedAtUtc)).ToList(), + state.Diplomacy.BorderTensions.Select(tension => new BorderTensionSnapshot( + tension.Id, + tension.RelationId, + tension.BorderEdgeId, + tension.FactionAId, + tension.FactionBId, + tension.Status, + tension.TensionScore, + tension.IncidentScore, + tension.MilitaryPressure, + tension.AccessFriction, + tension.UpdatedAtUtc, + tension.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Diplomacy.Wars.Select(war => new WarStateSnapshot( + war.Id, + war.RelationId, + war.FactionAId, + war.FactionBId, + war.Status, + war.WarGoal, + war.EscalationScore, + war.StartedAtUtc, + war.CeasefireUntilUtc, + war.UpdatedAtUtc, + war.ActiveFrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList()), + new TerritoryStateSnapshot( + state.Territory.Claims.Select(claim => new TerritoryClaimSnapshot( + claim.Id, + claim.SourceClaimId, + claim.FactionId, + claim.SystemId, + claim.CelestialId, + claim.Status, + claim.ClaimKind, + claim.ClaimStrength, + claim.UpdatedAtUtc)).ToList(), + state.Territory.Influences.Select(influence => new TerritoryInfluenceSnapshot( + influence.Id, + influence.SystemId, + influence.FactionId, + influence.ClaimStrength, + influence.AssetStrength, + influence.LogisticsStrength, + influence.TotalInfluence, + influence.IsContesting, + influence.UpdatedAtUtc)).ToList(), + state.Territory.ControlStates.Select(control => new TerritoryControlStateSnapshot( + control.SystemId, + control.ControllerFactionId, + control.PrimaryClaimantFactionId, + control.ControlKind, + control.IsContested, + control.ControlScore, + control.StrategicValue, + control.ClaimantFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + control.InfluencingFactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + control.UpdatedAtUtc)).ToList(), + state.Territory.StrategicProfiles.Select(profile => new SectorStrategicProfileSnapshot( + profile.SystemId, + profile.ControllerFactionId, + profile.ZoneKind, + profile.IsContested, + profile.StrategicValue, + profile.SecurityRating, + profile.TerritorialPressure, + profile.LogisticsValue, + profile.EconomicRegionId, + profile.FrontLineId, + profile.UpdatedAtUtc)).ToList(), + state.Territory.BorderEdges.Select(edge => new BorderEdgeSnapshot( + edge.Id, + edge.SourceSystemId, + edge.DestinationSystemId, + edge.SourceFactionId, + edge.DestinationFactionId, + edge.IsContested, + edge.RelationId, + edge.TensionScore, + edge.CorridorImportance, + edge.UpdatedAtUtc)).ToList(), + state.Territory.FrontLines.Select(front => new FrontLineSnapshot( + front.Id, + front.Kind, + front.Status, + front.AnchorSystemId, + front.PressureScore, + front.SupplyRisk, + front.UpdatedAtUtc, + front.FactionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + front.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.Territory.Zones.Select(zone => new TerritoryZoneSnapshot( + zone.Id, + zone.SystemId, + zone.FactionId, + zone.Kind, + zone.Status, + zone.Reason, + zone.UpdatedAtUtc)).ToList(), + state.Territory.Pressures.Select(pressure => new TerritoryPressureSnapshot( + pressure.Id, + pressure.SystemId, + pressure.FactionId, + pressure.Kind, + pressure.PressureScore, + pressure.SecurityScore, + pressure.HostileInfluence, + pressure.CorridorRisk, + pressure.UpdatedAtUtc)).ToList()), + new EconomyRegionStateSnapshot( + state.EconomyRegions.Regions.Select(region => new EconomicRegionSnapshot( + region.Id, + region.FactionId, + region.Label, + region.Kind, + region.Status, + region.CoreSystemId, + region.UpdatedAtUtc, + region.SystemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.FrontLineIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + region.CorridorIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.SupplyNetworks.Select(network => new SupplyNetworkSnapshot( + network.Id, + network.RegionId, + network.ThroughputScore, + network.RiskScore, + network.UpdatedAtUtc, + network.StationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ProducerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ConsumerItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + network.ConstructionItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.Corridors.Select(corridor => new LogisticsCorridorSnapshot( + corridor.Id, + corridor.FactionId, + corridor.Kind, + corridor.Status, + corridor.RiskScore, + corridor.ThroughputScore, + corridor.AccessState, + corridor.UpdatedAtUtc, + corridor.SystemPathIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + corridor.RegionIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + corridor.BorderEdgeIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.ProductionProfiles.Select(profile => new RegionalProductionProfileSnapshot( + profile.RegionId, + profile.PrimaryIndustry, + profile.ShipyardCount, + profile.StationCount, + profile.UpdatedAtUtc, + profile.ProducedItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), + profile.ScarceItemIds.OrderBy(id => id, StringComparer.Ordinal).ToList())).ToList(), + state.EconomyRegions.TradeBalances.Select(balance => new RegionalTradeBalanceSnapshot( + balance.RegionId, + balance.ImportsRequiredCount, + balance.ExportsSurplusCount, + balance.CriticalShortageCount, + balance.NetTradeScore, + balance.UpdatedAtUtc)).ToList(), + state.EconomyRegions.Bottlenecks.Select(bottleneck => new RegionalBottleneckSnapshot( + bottleneck.Id, + bottleneck.RegionId, + bottleneck.ItemId, + bottleneck.Cause, + bottleneck.Status, + bottleneck.Severity, + bottleneck.UpdatedAtUtc)).ToList(), + state.EconomyRegions.SecurityAssessments.Select(assessment => new RegionalSecurityAssessmentSnapshot( + assessment.RegionId, + assessment.SupplyRisk, + assessment.BorderPressure, + assessment.ActiveWarCount, + assessment.HostileRelationCount, + assessment.AccessFriction, + assessment.UpdatedAtUtc)).ToList(), + state.EconomyRegions.EconomicAssessments.Select(assessment => new RegionalEconomicAssessmentSnapshot( + assessment.RegionId, + assessment.SustainmentScore, + assessment.ProductionDepth, + assessment.ConstructionPressure, + assessment.CorridorDependency, + assessment.UpdatedAtUtc)).ToList())); } private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( diff --git a/apps/backend/SpaceGame.Api.csproj b/apps/backend/SpaceGame.Api.csproj index fb4b086..134b0f4 100644 --- a/apps/backend/SpaceGame.Api.csproj +++ b/apps/backend/SpaceGame.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index c4ac369..c1351fe 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -1,4 +1,3 @@ -using static SpaceGame.Api.Ships.Simulation.ShipControlService; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Stations.Simulation; @@ -80,7 +79,7 @@ internal sealed class StationLifecycleService TargetPosition = spawnPosition, SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), DefaultBehavior = CreateSpawnedShipBehavior(definition, station), - ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold), + Skills = WorldSeedingService.CreateSkills(definition), Health = definition.MaxHealth, }; @@ -109,13 +108,22 @@ internal sealed class StationLifecycleService { if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) { - return new DefaultBehaviorRuntime { Kind = "idle" }; + return new DefaultBehaviorRuntime + { + Kind = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? "advanced-auto-trade" : "idle", + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + MaxSystemRange = string.Equals(definition.Kind, "transport", StringComparison.Ordinal) ? 2 : 0, + }; } var patrolRadius = station.Radius + 90f; return new DefaultBehaviorRuntime { Kind = "patrol", + HomeSystemId = station.SystemId, + HomeStationId = station.Id, + AreaSystemId = station.SystemId, PatrolPoints = [ new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), @@ -150,7 +158,13 @@ internal sealed class StationLifecycleService ParentCommanderId = factionCommander.Id, ControlledEntityId = station.Id, PolicySetId = factionCommander.PolicySetId, - Doctrine = "station-default", + Doctrine = "station-control", + Skills = new CommanderSkillProfileRuntime + { + Leadership = 3, + Coordination = Math.Clamp(3 + (station.Modules.Count / 8), 3, 5), + Strategy = 3, + }, }; station.CommanderId = commander.Id; @@ -179,25 +193,12 @@ internal sealed class StationLifecycleService ParentCommanderId = factionCommander.Id, ControlledEntityId = ship.Id, PolicySetId = factionCommander.PolicySetId, - Doctrine = "ship-default", - ActiveBehavior = new CommanderBehaviorRuntime + Doctrine = "ship-control", + Skills = new CommanderSkillProfileRuntime { - Kind = ship.DefaultBehavior.Kind, - AreaSystemId = ship.DefaultBehavior.AreaSystemId, - TargetEntityId = ship.DefaultBehavior.TargetEntityId, - ItemId = ship.DefaultBehavior.ItemId, - StationId = ship.DefaultBehavior.StationId, - ModuleId = ship.DefaultBehavior.ModuleId, - NodeId = ship.DefaultBehavior.NodeId, - Phase = ship.DefaultBehavior.Phase, - PatrolIndex = ship.DefaultBehavior.PatrolIndex, - }, - ActiveTask = new CommanderTaskRuntime - { - Kind = ShipTaskKinds.Idle, - Status = WorkStatus.Pending, - TargetSystemId = ship.SystemId, - Threshold = 0f, + Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), + Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), + Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), }, }; diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index 557027f..4dd5cc8 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -62,7 +62,7 @@ internal sealed class StationSimulationService var superfluidCoolantReserve = role == "superfluidcoolant" ? 120f : 0f; var quantumTubesReserve = role == "quantumtubes" ? 120f : 0f; var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") - && FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") + && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f ? 90f : 0f; @@ -104,6 +104,7 @@ internal sealed class StationSimulationService AddSupplyOrder(desiredOrders, station, "superfluidcoolant", ScaleSupplyTriggerByEconomy(economy, "superfluidcoolant", MathF.Max(superfluidCoolantReserve * 1.35f, superfluidCoolantReserve + 30f)), reserveFloor: superfluidCoolantReserve, valuationBase: ScaleSupplyValuation(economy, "superfluidcoolant", 0.9f)); AddSupplyOrder(desiredOrders, station, "quantumtubes", ScaleSupplyTriggerByEconomy(economy, "quantumtubes", MathF.Max(quantumTubesReserve * 1.35f, quantumTubesReserve + 30f)), reserveFloor: quantumTubesReserve, valuationBase: ScaleSupplyValuation(economy, "quantumtubes", 0.9f)); + desiredOrders = ApplyRegionalMarketModifiers(world, station, desiredOrders); ReconcileStationMarketOrders(world, station, desiredOrders); } @@ -116,7 +117,7 @@ internal sealed class StationSimulationService var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") - && FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") + && GetShipProductionPressure(world, station.FactionId, "military") > 0.2f ? 90f : 0f; @@ -257,8 +258,9 @@ internal sealed class StationSimulationService var priority = (float)recipe.Priority; var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); - var fleetPressure = FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") ? 1f : 0f; + var fleetPressure = GetShipProductionPressure(world, station.FactionId, "military"); priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure); + priority += GetStrategicRecipeBias(world, station, recipe); return priority; } @@ -321,6 +323,52 @@ internal sealed class StationSimulationService }; } + private static float GetStrategicRecipeBias(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) + { + var commander = station.CommanderId is null + ? null + : world.Commanders.FirstOrDefault(candidate => candidate.Id == station.CommanderId); + var assignment = commander?.Assignment; + if (assignment is null) + { + return 0f; + } + + var outputItemIds = recipe.Outputs + .Select(output => output.ItemId) + .ToHashSet(StringComparer.Ordinal); + + if (string.Equals(assignment.Kind, "ship-production-focus", StringComparison.Ordinal) + && recipe.ShipOutputId is not null + && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition) + && string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)) + { + return 260f; + } + + if (string.Equals(assignment.Kind, "commodity-focus", StringComparison.Ordinal) + && assignment.ItemId is not null + && outputItemIds.Contains(assignment.ItemId)) + { + return 220f; + } + + if (string.Equals(assignment.Kind, "expansion-support", StringComparison.Ordinal) + && outputItemIds.Overlaps(["energycells", "refinedmetals", "hullparts", "claytronics"])) + { + return 180f; + } + + if (string.Equals(assignment.Kind, "station-oversight", StringComparison.Ordinal) + && assignment.ItemId is not null + && outputItemIds.Contains(assignment.ItemId)) + { + return 90f; + } + + return 0f; + } + internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) { var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) @@ -338,7 +386,7 @@ internal sealed class StationSimulationService return false; } - if (!FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, shipDefinition.Kind)) + if (GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind) <= 0.05f) { return false; } @@ -559,12 +607,20 @@ internal sealed class StationSimulationService var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); var controlledSystems = GetFactionControlledSystemsCount(world, factionId); var deficit = Math.Max(0, targetSystems - controlledSystems); - return Math.Clamp(deficit / (float)targetSystems, 0f, 1f); + var contestedSystems = world.Geopolitics?.Territory.ControlStates.Count(state => + state.IsContested + && (string.Equals(state.ControllerFactionId, factionId, StringComparison.Ordinal) + || string.Equals(state.PrimaryClaimantFactionId, factionId, StringComparison.Ordinal) + || state.ClaimantFactionIds.Contains(factionId, StringComparer.Ordinal))) ?? 0; + var frontierSystems = world.Geopolitics?.Territory.Zones.Count(zone => + string.Equals(zone.FactionId, factionId, StringComparison.Ordinal) + && zone.Kind is "frontier" or "corridor" or "contested") ?? 0; + return Math.Clamp((deficit / (float)targetSystems) + (contestedSystems * 0.12f) + (frontierSystems * 0.04f), 0f, 1f); } internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) { - return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id)); + return GeopoliticalSimulationService.GetControlledSystems(world, factionId).Count; } private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve) @@ -612,34 +668,66 @@ internal sealed class StationSimulationService : baseValuation; } + private static List ApplyRegionalMarketModifiers(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) + { + var region = GeopoliticalSimulationService.GetPrimaryEconomicRegion(world, station.FactionId, station.SystemId); + if (region is null) + { + return desiredOrders.ToList(); + } + + var security = world.Geopolitics?.EconomyRegions.SecurityAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); + var economic = world.Geopolitics?.EconomyRegions.EconomicAssessments.FirstOrDefault(assessment => string.Equals(assessment.RegionId, region.Id, StringComparison.Ordinal)); + var bottlenecks = world.Geopolitics?.EconomyRegions.Bottlenecks + .Where(bottleneck => string.Equals(bottleneck.RegionId, region.Id, StringComparison.Ordinal)) + .ToDictionary(bottleneck => bottleneck.ItemId, StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); + var riskMultiplier = 1f + ((security?.SupplyRisk ?? 0f) * 0.3f) + ((security?.AccessFriction ?? 0f) * 0.2f); + var sustainmentFloor = 1f + MathF.Max(0f, 0.55f - (economic?.SustainmentScore ?? 1f)); + + return desiredOrders + .Select(order => + { + bottlenecks.TryGetValue(order.ItemId, out var bottleneck); + var severity = bottleneck?.Severity ?? 0f; + var buyBias = order.Kind == MarketOrderKinds.Buy ? 1f + (severity * 0.08f) : 1f; + var sellBias = order.Kind == MarketOrderKinds.Sell && severity > 0f ? MathF.Max(0.35f, 1f - (severity * 0.07f)) : 1f; + var amount = order.Amount * (order.Kind == MarketOrderKinds.Buy ? riskMultiplier * buyBias * sustainmentFloor : sellBias); + var valuation = order.Valuation * (order.Kind == MarketOrderKinds.Buy + ? 1f + (severity * 0.06f) + ((security?.SupplyRisk ?? 0f) * 0.18f) + : 1f + (severity * 0.04f)); + float? reserveThreshold = order.ReserveThreshold.HasValue + ? order.ReserveThreshold.Value * (1f + ((security?.SupplyRisk ?? 0f) * 0.15f)) + : null; + return new DesiredMarketOrder(order.Kind, order.ItemId, amount, valuation, reserveThreshold); + }) + .ToList(); + } + 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) + var economic = FindFactionEconomicAssessment(world, factionId); + var threat = FindFactionThreatAssessment(world, factionId); + if (economic is null || threat is null) { return 0f; } - return task.State == FactionIssuedTaskState.Blocked ? 0.4f : 1f; + return shipKind switch + { + "military" => threat.EnemyFactionCount > 0 + ? economic.MilitaryShipCount < Math.Max(4, economic.ControlledSystemCount * 2) ? 1f : 0.25f + : 0.1f, + "construction" => economic.PrimaryExpansionSiteId is not null + ? economic.ConstructorShipCount < 1 ? 1f : 0.35f + : economic.ConstructorShipCount < 1 ? 0.5f : 0f, + "transport" => economic.TransportShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.8f : 0.2f, + _ when shipKind == "mining" || shipKind == "miner" => economic.MinerShipCount < Math.Max(2, economic.ControlledSystemCount) ? 0.85f : 0.2f, + _ => 0.15f, + }; } private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) - { - var totalLagrangePoints = world.Celestials.Count(node => - node.SystemId == systemId && - node.Kind == SpatialNodeKind.LagrangePoint); - if (totalLagrangePoints == 0) - { - return false; - } - - var ownedLocations = world.Claims.Count(claim => - claim.SystemId == systemId && - claim.FactionId == factionId && - claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active); - return ownedLocations > (totalLagrangePoints / 2f); - } + => GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId); private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); } diff --git a/apps/backend/Universe/Contracts/World.cs b/apps/backend/Universe/Contracts/World.cs index 8fcfcd4..8e7baf1 100644 --- a/apps/backend/Universe/Contracts/World.cs +++ b/apps/backend/Universe/Contracts/World.cs @@ -17,7 +17,9 @@ public sealed record WorldSnapshot( IReadOnlyList MarketOrders, IReadOnlyList Policies, IReadOnlyList Ships, - IReadOnlyList Factions); + IReadOnlyList Factions, + PlayerFactionSnapshot? PlayerFaction, + GeopoliticalStateSnapshot? Geopolitics); public sealed record WorldDelta( long Sequence, @@ -36,6 +38,8 @@ public sealed record WorldDelta( IReadOnlyList Policies, IReadOnlyList Ships, IReadOnlyList Factions, + PlayerFactionSnapshot? PlayerFaction, + GeopoliticalStateSnapshot? Geopolitics, ObserverScope? Scope = null); public sealed record SimulationEventRecord( diff --git a/apps/backend/Universe/Runtime/SimulationWorld.cs b/apps/backend/Universe/Runtime/SimulationWorld.cs index 572e2c9..e5be667 100644 --- a/apps/backend/Universe/Runtime/SimulationWorld.cs +++ b/apps/backend/Universe/Runtime/SimulationWorld.cs @@ -9,9 +9,12 @@ public sealed class SimulationWorld public required List Systems { get; init; } public required List Nodes { get; init; } public required List Celestials { get; init; } + public required List Wrecks { get; init; } public required List Stations { get; init; } public required List Ships { get; init; } public required List Factions { get; init; } + public PlayerFactionRuntime? PlayerFaction { get; set; } + public GeopoliticalStateRuntime? Geopolitics { get; set; } public required List Commanders { get; init; } public required List Claims { get; init; } public required List ConstructionSites { get; init; } diff --git a/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs b/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs index ee08686..cdda136 100644 --- a/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs +++ b/apps/backend/Universe/Runtime/SpatialRuntimeModels.cs @@ -36,6 +36,18 @@ public sealed class CelestialRuntime public string LastDeltaSignature { get; set; } = string.Empty; } +public sealed class WreckRuntime +{ + public required string Id { get; init; } + public required string SourceKind { get; init; } + public required string SourceEntityId { get; init; } + public required string SystemId { get; set; } + public required Vector3 Position { get; set; } + public required string ItemId { get; set; } + public float RemainingAmount { get; set; } + public float MaxAmount { get; init; } +} + public sealed class ShipSpatialStateRuntime { public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace; diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index a9c4a8a..26f9d95 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -15,10 +15,16 @@ internal sealed class WorldBuilder( var systems = generationService.ExpandSystems( generationService.InjectSpecialSystems(catalog.AuthoredSystems), worldGeneration.TargetSystemCount); + + Console.WriteLine("TEST"); + Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); + var scenario = dataLoader.NormalizeScenarioToAvailableSystems( catalog.Scenario, systems.Select(system => system.Id).ToList()); + Console.WriteLine(string.Join(',', systems.Select(s => s.Id))); + var systemRuntimes = systems .Select(definition => new SystemRuntime { @@ -42,11 +48,29 @@ internal sealed class WorldBuilder( var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery); + if (worldGeneration.AiControllerFactionCount < int.MaxValue) + { + var aiFactionIds = stations + .Select(s => s.FactionId) + .Concat(ships.Select(s => s.FactionId)) + .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(worldGeneration.AiControllerFactionCount) + .ToHashSet(StringComparer.Ordinal); + aiFactionIds.Add(DefaultFactionId); + stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); + ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); + } + var factions = seedingService.CreateFactions(stations, ships); seedingService.BootstrapFactionEconomy(factions, stations); var policies = seedingService.CreatePolicies(factions); var commanders = seedingService.CreateCommanders(factions, stations, ships); var nowUtc = DateTimeOffset.UtcNow; + var playerFaction = worldGeneration.GeneratePlayerFaction + ? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc) + : null; var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); var bootstrapWorld = new SimulationWorld { @@ -56,9 +80,11 @@ internal sealed class WorldBuilder( Systems = systemRuntimes, Celestials = spatialLayout.Celestials, Nodes = spatialLayout.Nodes, + Wrecks = [], Stations = stations, Ships = ships, Factions = factions, + PlayerFaction = playerFaction, Commanders = commanders, Claims = claims, ConstructionSites = [], @@ -75,7 +101,7 @@ internal sealed class WorldBuilder( }; var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld); - return new SimulationWorld + var world = new SimulationWorld { Label = "Split Viewer / Simulation World", Seed = WorldSeed, @@ -83,9 +109,12 @@ internal sealed class WorldBuilder( Systems = systemRuntimes, Celestials = spatialLayout.Celestials, Nodes = spatialLayout.Nodes, + Wrecks = [], Stations = stations, Ships = ships, Factions = factions, + PlayerFaction = playerFaction, + Geopolitics = null, Commanders = commanders, Claims = claims, ConstructionSites = constructionSites, @@ -100,6 +129,10 @@ internal sealed class WorldBuilder( OrbitalTimeSeconds = WorldSeed * 97d, GeneratedAtUtc = DateTimeOffset.UtcNow, }; + + var geopolitics = new GeopoliticalSimulationService(); + geopolitics.Update(world, 0f, []); + return world; } private static List CreateStations( @@ -291,7 +324,7 @@ internal sealed class WorldBuilder( patrolRoutes, stations, refinery), - ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, + Skills = WorldSeedingService.CreateSkills(definition), Health = definition.MaxHealth, }); diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 2316956..e3f0da1 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -286,16 +286,9 @@ internal sealed class WorldSeedingService FactionId = faction.Id, ControlledEntityId = faction.Id, PolicySetId = faction.DefaultPolicySetId, - Doctrine = "strategic-expansionist", + Doctrine = "strategic-control", }; - commander.Goals.Add("control-all-systems"); - commander.Goals.Add("control-five-systems-fast"); - commander.Goals.Add("expand-industrial-base"); - commander.Goals.Add("grow-war-fleet"); - commander.Goals.Add("deter-pirate-harassment"); - commander.Goals.Add("contest-rival-expansion"); - commanders.Add(commander); factionCommanders[faction.Id] = commander; faction.CommanderIds.Add(commander.Id); @@ -316,7 +309,7 @@ internal sealed class WorldSeedingService ParentCommanderId = parentCommander.Id, ControlledEntityId = station.Id, PolicySetId = parentCommander.PolicySetId, - Doctrine = "station-default", + Doctrine = "station-control", }; station.CommanderId = commander.Id; @@ -341,16 +334,9 @@ internal sealed class WorldSeedingService ParentCommanderId = parentCommander.Id, ControlledEntityId = ship.Id, PolicySetId = parentCommander.PolicySetId, - Doctrine = "ship-default", - ActiveBehavior = CopyBehavior(ship.DefaultBehavior), - ActiveTask = CopyTask(ship.ControllerTask, null), + Doctrine = "ship-control", }; - if (ship.Order is not null) - { - commander.ActiveOrder = CopyOrder(ship.Order); - } - ship.CommanderId = commander.Id; ship.PolicySetId = parentCommander.PolicySetId; parentCommander.SubordinateCommanderIds.Add(commander.Id); @@ -361,6 +347,93 @@ internal sealed class WorldSeedingService return commanders; } + internal PlayerFactionRuntime CreatePlayerFaction( + IReadOnlyCollection factions, + IReadOnlyCollection stations, + IReadOnlyCollection ships, + IReadOnlyCollection commanders, + IReadOnlyCollection policies, + DateTimeOffset nowUtc) + { + var sovereignFaction = factions.FirstOrDefault(faction => string.Equals(faction.Id, DefaultFactionId, StringComparison.Ordinal)) + ?? factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First(); + + var player = new PlayerFactionRuntime + { + Id = "player-faction", + Label = $"{sovereignFaction.Label} Command", + SovereignFactionId = sovereignFaction.Id, + CreatedAtUtc = nowUtc, + UpdatedAtUtc = nowUtc, + }; + + foreach (var shipId in ships.Where(ship => ship.FactionId == sovereignFaction.Id).Select(ship => ship.Id)) + { + player.AssetRegistry.ShipIds.Add(shipId); + } + + foreach (var stationId in stations.Where(station => station.FactionId == sovereignFaction.Id).Select(station => station.Id)) + { + player.AssetRegistry.StationIds.Add(stationId); + } + + foreach (var commanderId in commanders.Where(commander => commander.FactionId == sovereignFaction.Id).Select(commander => commander.Id)) + { + player.AssetRegistry.CommanderIds.Add(commanderId); + } + + foreach (var policy in policies.Where(policy => string.Equals(policy.OwnerId, sovereignFaction.Id, StringComparison.Ordinal))) + { + player.AssetRegistry.PolicySetIds.Add(policy.Id); + } + + player.Policies.Add(new PlayerFactionPolicyRuntime + { + Id = "player-core-policy", + Label = "Core Empire Policy", + ScopeKind = "player-faction", + ScopeId = player.Id, + PolicySetId = sovereignFaction.DefaultPolicySetId, + TradeAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.TradeAccessPolicy ?? "owner-and-allies", + DockingAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.DockingAccessPolicy ?? "owner-and-allies", + ConstructionAccessPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.ConstructionAccessPolicy ?? "owner-only", + OperationalRangePolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.OperationalRangePolicy ?? "unrestricted", + CombatEngagementPolicy = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.CombatEngagementPolicy ?? "defensive", + AvoidHostileSystems = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.AvoidHostileSystems ?? true, + FleeHullRatio = policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId)?.FleeHullRatio ?? 0.35f, + UpdatedAtUtc = nowUtc, + }); + + if (policies.FirstOrDefault(policy => policy.Id == sovereignFaction.DefaultPolicySetId) is { } defaultPolicy) + { + foreach (var systemId in defaultPolicy.BlacklistedSystemIds) + { + player.Policies[0].BlacklistedSystemIds.Add(systemId); + } + } + + player.AutomationPolicies.Add(new PlayerAutomationPolicyRuntime + { + Id = "player-core-automation", + Label = "Core Automation", + ScopeKind = "player-faction", + ScopeId = player.Id, + BehaviorKind = "idle", + UpdatedAtUtc = nowUtc, + }); + + player.Reserves.Add(new PlayerReserveGroupRuntime + { + Id = "player-core-reserve", + Label = "Strategic Reserve", + ReserveKind = "military", + UpdatedAtUtc = nowUtc, + }); + player.AssetRegistry.ReserveIds.Add("player-core-reserve"); + + return player; + } + internal static DefaultBehaviorRuntime CreateBehavior( ShipDefinition definition, string systemId, @@ -381,22 +454,32 @@ internal sealed class WorldSeedingService return new DefaultBehaviorRuntime { Kind = "construct-station", - StationId = homeStation.Id, - Phase = "travel-to-station", + HomeSystemId = homeStation.SystemId, + HomeStationId = homeStation.Id, + PreferredConstructionSiteId = null, }; } if (HasCapabilities(definition, "mining") && homeStation is not null) { - return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, homeStation.Id); + return new DefaultBehaviorRuntime + { + Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine", + HomeSystemId = homeStation.SystemId, + HomeStationId = homeStation.Id, + AreaSystemId = scenario.MiningDefaults.NodeSystemId, + MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1, + }; } if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal)) { return new DefaultBehaviorRuntime { - Kind = "trade-haul", - Phase = "travel-to-source", + Kind = "advanced-auto-trade", + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + MaxSystemRange = 2, }; } @@ -405,7 +488,9 @@ internal sealed class WorldSeedingService return new DefaultBehaviorRuntime { Kind = "patrol", - StationId = homeStation?.Id, + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + AreaSystemId = systemId, PatrolPoints = route, PatrolIndex = 0, }; @@ -414,6 +499,20 @@ internal sealed class WorldSeedingService return new DefaultBehaviorRuntime { Kind = "idle", + HomeSystemId = homeStation?.SystemId ?? systemId, + HomeStationId = homeStation?.Id, + }; + } + + internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition) + { + return definition.Kind switch + { + "transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 }, + "construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 }, + "military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 }, + _ when HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 }, + _ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 }, }; } @@ -471,43 +570,4 @@ internal sealed class WorldSeedingService .Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..])); } - private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new() - { - Kind = kind, - AreaSystemId = areaSystemId, - StationId = stationId, - Phase = "travel-to-node", - }; - - private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new() - { - Kind = behavior.Kind, - AreaSystemId = behavior.AreaSystemId, - TargetEntityId = behavior.TargetEntityId, - ItemId = behavior.ItemId, - ModuleId = behavior.ModuleId, - NodeId = behavior.NodeId, - Phase = behavior.Phase, - PatrolIndex = behavior.PatrolIndex, - StationId = behavior.StationId, - }; - - private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new() - { - Kind = order.Kind, - Status = order.Status, - DestinationSystemId = order.DestinationSystemId, - DestinationPosition = order.DestinationPosition, - }; - - private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new() - { - Kind = task.Kind.ToContractValue(), - Status = task.Status, - TargetEntityId = task.TargetEntityId, - TargetNodeId = targetNodeId ?? task.TargetNodeId, - TargetPosition = task.TargetPosition, - TargetSystemId = task.TargetSystemId, - Threshold = task.Threshold, - }; } diff --git a/apps/backend/Universe/Simulation/WorldGenerationOptions.cs b/apps/backend/Universe/Simulation/WorldGenerationOptions.cs index 5fd95c9..a15995a 100644 --- a/apps/backend/Universe/Simulation/WorldGenerationOptions.cs +++ b/apps/backend/Universe/Simulation/WorldGenerationOptions.cs @@ -2,6 +2,7 @@ namespace SpaceGame.Api.Universe.Simulation; public sealed class WorldGenerationOptions { - public int TargetSystemCount { get; init; } = 160; - + public int TargetSystemCount { get; init; } + public int AiControllerFactionCount { get; init; } + public bool GeneratePlayerFaction { get; init; } } diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index a09be0f..185f516 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -14,6 +14,7 @@ public sealed class WorldService( private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value); private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value); + private readonly PlayerFactionService _playerFaction = new(); private readonly Dictionary _subscribers = []; private readonly Queue _history = []; private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); @@ -74,6 +75,156 @@ public sealed class WorldService( } } + public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request) + { + lock (_sync) + { + var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public ShipSnapshot? RemoveShipOrder(string shipId, string orderId) + { + lock (_sync) + { + var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request) + { + lock (_sync) + { + var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request); + if (ship is null) + { + return null; + } + + return GetShipSnapshotUnsafe(ship.Id); + } + } + + public PlayerFactionSnapshot? GetPlayerFaction() + { + lock (_sync) + { + _playerFaction.EnsureDomain(_world); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request) + { + lock (_sync) + { + _playerFaction.CreateOrganization(_world, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId) + { + lock (_sync) + { + _playerFaction.DeleteOrganization(_world, organizationId); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpdateOrganizationMembership(_world, organizationId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertDirective(_world, directiveId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId) + { + lock (_sync) + { + _playerFaction.DeleteDirective(_world, directiveId); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertPolicy(_world, policyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertProductionProgram(_world, productionProgramId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpsertAssignment(_world, assetId, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + + public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request) + { + lock (_sync) + { + _playerFaction.UpdateStrategicIntent(_world, request); + return GetPlayerFactionSnapshotUnsafe(); + } + } + public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -158,7 +309,9 @@ public sealed class WorldService( [], [], [], - []); + [], + null, + null); _history.Enqueue(resetDelta); foreach (var subscriber in _subscribers.Values.ToList()) @@ -203,6 +356,12 @@ public sealed class WorldService( }; } + private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) => + _engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId); + + private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() => + _engine.BuildSnapshot(_world, _sequence).PlayerFaction; + private static bool HasMeaningfulDelta(WorldDelta delta) => delta.RequiresSnapshotRefresh || delta.Events.Count > 0 @@ -214,7 +373,9 @@ public sealed class WorldService( || delta.MarketOrders.Count > 0 || delta.Policies.Count > 0 || delta.Ships.Count > 0 - || delta.Factions.Count > 0; + || delta.Factions.Count > 0 + || delta.PlayerFaction is not null + || delta.Geopolitics is not null; private void Unsubscribe(Guid subscriberId) { @@ -261,6 +422,8 @@ public sealed class WorldService( Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [], Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(), Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [], + PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null, + Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null, Scope = scope, }; } diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index 572643a..c4824e6 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -6,8 +6,10 @@ } }, "WorldGeneration": { - "TargetSystemCount": 10, - "IncludeSolSystem": true + "TargetSystemCount": 2, + "IncludeSolSystem": true, + "AiControllerFactionCount": 0, + "GeneratePlayerFaction": false }, "OrbitalSimulation": { "SimulatedSecondsPerRealSecond": 0 diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index 92f7cac..2a3feda 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -1,6 +1,21 @@ import type { WorldDelta, WorldSnapshot } from "./contracts"; import type { TelemetrySnapshot } from "./contractsTelemetry"; import type { BalanceSettings } from "./contractsBalance"; +import type { PlayerFactionSnapshot } from "./contractsPlayerFaction"; +import type { ShipSnapshot } from "./contractsShips"; +import type { + PlayerAssetAssignmentCommandRequest, + PlayerAutomationPolicyCommandRequest, + PlayerDirectiveCommandRequest, + PlayerOrganizationCommandRequest, + PlayerOrganizationMembershipCommandRequest, + PlayerPolicyCommandRequest, + PlayerStrategicIntentCommandRequest, +} from "./playerFactionCommands"; +import type { + ShipDefaultBehaviorCommandRequest, + ShipOrderCommandRequest, +} from "./shipCommands"; export interface WorldStreamScope { scopeKind?: string; @@ -8,12 +23,16 @@ export interface WorldStreamScope { bubbleId?: string | null; } -export async function fetchWorldSnapshot(signal?: AbortSignal) { - const response = await fetch("/api/world", { signal }); +async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); if (!response.ok) { - throw new Error(`World request failed with ${response.status}`); + throw new Error(`${init?.method ?? "GET"} ${typeof input === "string" ? input : input.toString()} failed with ${response.status}`); } - return response.json() as Promise; + return response.json() as Promise; +} + +export async function fetchWorldSnapshot(signal?: AbortSignal) { + return fetchJson("/api/world", { signal }); } export function openWorldStream( @@ -52,39 +71,114 @@ export function openWorldStream( } export async function fetchTelemetry(signal?: AbortSignal) { - const response = await fetch("/api/telemetry", { signal }); - if (!response.ok) { - throw new Error(`Telemetry request failed with ${response.status}`); - } - return response.json() as Promise; + return fetchJson("/api/telemetry", { signal }); } export async function fetchBalance(signal?: AbortSignal) { - const response = await fetch("/api/balance", { signal }); - if (!response.ok) { - throw new Error(`Balance request failed with ${response.status}`); - } - return response.json() as Promise; + return fetchJson("/api/balance", { signal }); } export async function updateBalance(settings: BalanceSettings) { - const response = await fetch("/api/balance", { + return fetchJson("/api/balance", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings), }); - if (!response.ok) { - throw new Error(`Balance update failed with ${response.status}`); - } - return response.json() as Promise; } export async function resetWorld() { - const response = await fetch("/api/world/reset", { + return fetchJson("/api/world/reset", { method: "POST", }); - if (!response.ok) { - throw new Error(`Reset request failed with ${response.status}`); - } - return response.json() as Promise; +} + +export async function fetchPlayerFaction(signal?: AbortSignal) { + return fetchJson("/api/player-faction", { signal }); +} + +export async function createPlayerOrganization(request: PlayerOrganizationCommandRequest) { + return fetchJson("/api/player-faction/organizations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function deletePlayerOrganization(organizationId: string) { + return fetchJson(`/api/player-faction/organizations/${organizationId}`, { + method: "DELETE", + }); +} + +export async function updatePlayerOrganizationMembership(organizationId: string, request: PlayerOrganizationMembershipCommandRequest) { + return fetchJson(`/api/player-faction/organizations/${organizationId}/membership`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function upsertPlayerDirective(request: PlayerDirectiveCommandRequest, directiveId?: string | null) { + const path = directiveId ? `/api/player-faction/directives/${directiveId}` : "/api/player-faction/directives"; + return fetchJson(path, { + method: directiveId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function deletePlayerDirective(directiveId: string) { + return fetchJson(`/api/player-faction/directives/${directiveId}`, { + method: "DELETE", + }); +} + +export async function upsertPlayerAssignment(assetId: string, request: PlayerAssetAssignmentCommandRequest) { + return fetchJson(`/api/player-faction/assignments/${assetId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function upsertPlayerPolicy(request: PlayerPolicyCommandRequest, policyId?: string | null) { + const path = policyId ? `/api/player-faction/policies/${policyId}` : "/api/player-faction/policies"; + return fetchJson(path, { + method: policyId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function upsertPlayerAutomationPolicy(request: PlayerAutomationPolicyCommandRequest, automationPolicyId?: string | null) { + const path = automationPolicyId ? `/api/player-faction/automation-policies/${automationPolicyId}` : "/api/player-faction/automation-policies"; + return fetchJson(path, { + method: automationPolicyId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function updatePlayerStrategicIntent(request: PlayerStrategicIntentCommandRequest) { + return fetchJson("/api/player-faction/strategic-intent", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function enqueueShipOrder(shipId: string, request: ShipOrderCommandRequest) { + return fetchJson(`/api/ships/${shipId}/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export async function updateShipDefaultBehavior(shipId: string, request: ShipDefaultBehaviorCommandRequest) { + return fetchJson(`/api/ships/${shipId}/default-behavior`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); } diff --git a/apps/viewer/src/components/gm/GmGeopoliticsPanel.vue b/apps/viewer/src/components/gm/GmGeopoliticsPanel.vue new file mode 100644 index 0000000..007fa66 --- /dev/null +++ b/apps/viewer/src/components/gm/GmGeopoliticsPanel.vue @@ -0,0 +1,206 @@ + + + diff --git a/apps/viewer/src/components/gm/GmOpsWindow.vue b/apps/viewer/src/components/gm/GmOpsWindow.vue index b3dd00a..f296221 100644 --- a/apps/viewer/src/components/gm/GmOpsWindow.vue +++ b/apps/viewer/src/components/gm/GmOpsWindow.vue @@ -13,7 +13,10 @@ import { } from "@tanstack/vue-table"; import { storeToRefs } from "pinia"; import GmWindow from "./GmWindow.vue"; +import GmPlayerFactionPanel from "./GmPlayerFactionPanel.vue"; +import GmGeopoliticsPanel from "./GmGeopoliticsPanel.vue"; import { useGmStore } from "../../ui/stores/gmStore"; +import { usePlayerFactionStore } from "../../ui/stores/playerFactionStore"; import { useViewerSelectionStore } from "../../ui/stores/viewerSelection"; import type { ShipSnapshot } from "../../contractsShips"; import type { StationSnapshot } from "../../contractsInfrastructure"; @@ -74,10 +77,11 @@ const emit = defineEmits<{ focus: [id: string, kind: "ship" | "station"]; }>(); -type TabId = "ships" | "stations" | "factions"; +type TabId = "ships" | "stations" | "factions" | "player" | "geopolitics"; const activeTab = ref("ships"); const gmStore = useGmStore(); +const playerFactionStore = usePlayerFactionStore(); const selectionStore = useViewerSelectionStore(); const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore); @@ -128,62 +132,51 @@ function formatCargoAmount(value: number | null | undefined) { return value.toFixed(2).replace(/\.?0+$/, ""); } +function formatPercent(value: number | null | undefined) { + if (value == null || Number.isNaN(value)) return "—"; + return `${Math.round(value * 100)}%`; +} + +function getLeadCampaign(faction: FactionSnapshot) { + return [...faction.strategicState.campaigns] + .sort((left, right) => right.priority - left.priority) + .find((campaign) => campaign.status !== "completed" && campaign.status !== "cancelled") + ?? faction.strategicState.campaigns[0]; +} + function getLeadObjective(faction: FactionSnapshot) { - return [...(faction.objectives ?? [])] + return [...faction.strategicState.objectives] .sort((left, right) => right.priority - left.priority) - .find((objective) => objective.state !== "Complete" && objective.state !== "Cancelled") - ?? faction.objectives?.[0]; + .find((objective) => objective.status !== "completed" && objective.status !== "cancelled") + ?? faction.strategicState.objectives[0]; } -function getLeadStep(faction: FactionSnapshot) { - const objective = getLeadObjective(faction); - return [...(objective?.steps ?? [])] - .sort((left, right) => right.priority - left.priority) - .find((step) => step.status !== "Complete" && step.status !== "Cancelled") - ?? objective?.steps?.[0]; -} - -function getLeadTask(faction: FactionSnapshot) { - return [...(faction.issuedTasks ?? [])] - .sort((left, right) => right.priority - left.priority) - .find((task) => task.state !== "Complete" && task.state !== "Cancelled") - ?? faction.issuedTasks?.[0]; +function getLatestDecision(faction: FactionSnapshot) { + return [...faction.decisionLog] + .sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0]; } function describeCommodityState(faction: FactionSnapshot, itemId: string, shortLabel: string) { - const signal = faction.blackboard?.commoditySignals.find((entry) => entry.itemId === itemId); + const signal = faction.strategicState.economicAssessment.commoditySignals.find((entry) => entry.itemId === itemId); if (!signal) return `${shortLabel} —`; return `${shortLabel} ${titleCaseToken(signal.level)} ${compactRate(signal.projectedNetRatePerSecond)}`; } function describeFactionStrategicState(faction: FactionSnapshot) { + const campaign = getLeadCampaign(faction); const objective = getLeadObjective(faction); - if (!objective) return "No objectives"; - return `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.state)}`; -} - -function describeFactionLeadStep(faction: FactionSnapshot) { - const step = getLeadStep(faction); - if (!step) return "No steps"; - const target = step.commodityId ?? step.moduleId ?? step.targetFactionId ?? step.targetSiteId; - return target - ? `${titleCaseToken(step.kind)} · ${titleCaseToken(step.status)} · ${target}` - : `${titleCaseToken(step.kind)} · ${titleCaseToken(step.status)}`; + if (!campaign && !objective) return "No campaigns"; + if (!campaign) return `${titleCaseToken(objective?.kind)} · ${titleCaseToken(objective?.status)}`; + return `${titleCaseToken(campaign.kind)} · ${titleCaseToken(campaign.status)}`; } function describeFactionLeadTask(faction: FactionSnapshot) { - const task = getLeadTask(faction); - if (!task) return "No tasks"; - const target = task.shipRole ?? task.commodityId ?? task.moduleId ?? task.targetFactionId ?? task.targetSiteId; + const objective = getLeadObjective(faction); + if (!objective) return "No objectives"; + const target = objective.itemId ?? objective.targetEntityId ?? objective.targetSystemId ?? objective.homeStationId; return target - ? `${titleCaseToken(task.kind)} · ${titleCaseToken(task.state)} · ${target}` - : `${titleCaseToken(task.kind)} · ${titleCaseToken(task.state)}`; -} - -function describeFactionPriority(faction: FactionSnapshot) { - const priority = [...(faction.strategicPriorities ?? [])] - .sort((left, right) => right.priority - left.priority)[0]; - return priority ? `${titleCaseToken(priority.goalName)} · ${compactNumber(priority.priority, 0)}` : "—"; + ? `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.status)} · ${target}` + : `${titleCaseToken(objective.kind)} · ${titleCaseToken(objective.status)}`; } function describeFactionEconomy(faction: FactionSnapshot) { @@ -195,9 +188,57 @@ function describeFactionEconomy(faction: FactionSnapshot) { } function describeFactionThreat(faction: FactionSnapshot) { - const blackboard = faction.blackboard; - if (!blackboard) return "—"; - return `Enemy ships ${blackboard.enemyShipCount} · stations ${blackboard.enemyStationCount}`; + const threat = faction.strategicState.threatAssessment; + return `Enemy ships ${threat.enemyShipCount} · stations ${threat.enemyStationCount}`; +} + +function describeFactionCommitments(faction: FactionSnapshot) { + const economic = faction.strategicState.economicAssessment; + return `Mil ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} · Min ${economic.minerShipCount}/${economic.targetMinerShipCount} · Tr ${economic.transportShipCount}/${economic.targetTransportShipCount}`; +} + +function describeFactionReserves(faction: FactionSnapshot) { + const budget = faction.strategicState.budget; + return `Assets ${budget.reservedMilitaryAssets}/${budget.reservedLogisticsAssets}/${budget.reservedConstructionAssets} · Credits ${compactNumber(budget.reservedCredits, 0)}`; +} + +function describeFactionBottleneck(faction: FactionSnapshot) { + const economic = faction.strategicState.economicAssessment; + if (!economic.industrialBottleneckItemId) { + return `None · sustain ${formatPercent(economic.sustainmentScore)}`; + } + return `${economic.industrialBottleneckItemId} · sustain ${formatPercent(economic.sustainmentScore)} · replace ${formatPercent(economic.replacementPressure)}`; +} + +function describeFactionIntent(faction: FactionSnapshot) { + const latestDecision = getLatestDecision(faction); + const leadCampaign = getLeadCampaign(faction); + if (!leadCampaign) return latestDecision?.summary ?? "—"; + const pause = leadCampaign.pauseReason ? ` · ${leadCampaign.pauseReason}` : ""; + return `${titleCaseToken(leadCampaign.kind)} · ${titleCaseToken(leadCampaign.status)}${pause}`; +} + +function describeFactionMemory(faction: FactionSnapshot) { + const topSystem = [...faction.memory.systems] + .sort((left, right) => (right.frontierPressure + right.routeRisk + right.historicalShortagePressure) + - (left.frontierPressure + left.routeRisk + left.historicalShortagePressure))[0]; + const topCommodity = [...faction.memory.commodities] + .sort((left, right) => right.historicalShortageScore - left.historicalShortageScore)[0]; + if (!topSystem && !topCommodity) return "—"; + return `${topSystem ? `${topSystem.systemId} fp ${compactNumber(topSystem.frontierPressure, 1)}` : "no-front"}${topCommodity ? ` · ${topCommodity.itemId} hs ${compactNumber(topCommodity.historicalShortageScore, 1)}` : ""}`; +} + +function describeFactionDecision(faction: FactionSnapshot) { + const latestDecision = getLatestDecision(faction); + return latestDecision ? `${titleCaseToken(latestDecision.kind)} · ${latestDecision.summary}` : "—"; +} + +function describeFactionFronts(faction: FactionSnapshot) { + const activeTheaters = faction.strategicState.theaters.filter((theater) => theater.status === "active"); + const defense = activeTheaters.filter((theater) => theater.kind.includes("defense")).length; + const offense = activeTheaters.filter((theater) => theater.kind.includes("offense")).length; + const economy = activeTheaters.filter((theater) => theater.kind.includes("economic")).length; + return `${activeTheaters.length} active · D ${defense} · O ${offense} · E ${economy}`; } // ── Ships table ──────────────────────────────────────────────────────────── @@ -210,32 +251,40 @@ type ShipRow = { faction: string; system: string; state: string; - objective: string; + assignment: string; behavior: string; - phase: string; - action: string; - task: string; + orders: string; + plan: string; + step: string; + subtask: string; cargo: number; health: number; }; const shipRows = computed(() => - gmStore.ships.map((s) => ({ - id: s.id, - label: s.label, - class: s.class, - factionColor: factionColorMap.value.get(s.factionId) ?? "—", - faction: factionMap.value.get(s.factionId) ?? s.factionId, - system: s.systemId, - state: titleCaseToken(s.state), - objective: s.commanderObjective ? titleCaseToken(s.commanderObjective) : "—", - behavior: titleCaseToken(s.defaultBehaviorKind), - phase: s.behaviorPhase ? titleCaseToken(s.behaviorPhase) : "—", - action: s.currentAction ? `${s.currentAction.label} ${Math.round(s.currentAction.progress * 100)}%` : "—", - task: titleCaseToken(s.controllerTaskKind), - cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0), - health: Math.round(s.health), - })), + gmStore.ships.map((s) => { + const topOrder = [...s.orderQueue] + .sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0]; + const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex]; + const currentSubTask = s.activeSubTasks[0]; + return { + id: s.id, + label: s.label, + class: s.class, + factionColor: factionColorMap.value.get(s.factionId) ?? "—", + faction: factionMap.value.get(s.factionId) ?? s.factionId, + system: s.systemId, + state: titleCaseToken(s.state), + assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—", + behavior: titleCaseToken(s.defaultBehavior.kind), + orders: topOrder ? `${titleCaseToken(topOrder.kind)} · ${s.orderQueue.length}` : "—", + plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—", + step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—", + subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—", + cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0), + health: Math.round(s.health), + }; + }), ); const shipColumnHelper = createColumnHelper(); @@ -250,11 +299,12 @@ const shipColumns = [ shipColumnHelper.accessor("faction", { header: "Faction" }), shipColumnHelper.accessor("system", { header: "System" }), shipColumnHelper.accessor("state", { header: "Ship State" }), - shipColumnHelper.accessor("objective", { header: "Commander Objective" }), + shipColumnHelper.accessor("assignment", { header: "Assignment" }), shipColumnHelper.accessor("behavior", { header: "Behavior" }), - shipColumnHelper.accessor("phase", { header: "Phase" }), - shipColumnHelper.accessor("action", { header: "Current Action" }), - shipColumnHelper.accessor("task", { header: "Task" }), + shipColumnHelper.accessor("orders", { header: "Orders" }), + shipColumnHelper.accessor("plan", { header: "Plan" }), + shipColumnHelper.accessor("step", { header: "Current Step" }), + shipColumnHelper.accessor("subtask", { header: "SubTask" }), shipColumnHelper.accessor("cargo", { header: "Cargo", cell: (info) => formatCargoAmount(info.getValue()), @@ -264,7 +314,7 @@ const shipColumns = [ const shipFilter = ref(""); const shipSorting = ref([]); -const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "objective", "behavior", "phase", "action", "task", "cargo", "health"]); +const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask", "cargo", "health"]); const shipTable = useVueTable({ get data() { return shipRows.value; }, @@ -383,14 +433,18 @@ type FactionRow = { label: string; color: string; planCycle: number; - priority: string; - strategicState: string; - leadStep: string; - leadTask: string; - warReadiness: string; + posture: string; + fronts: string; + leadCampaign: string; + leadObjective: string; + commitments: string; + reserves: string; + bottleneck: string; + intent: string; + decision: string; + memory: string; economy: string; threat: string; - fleets: string; systems: string; credits: number; population: number; @@ -399,29 +453,29 @@ type FactionRow = { }; const factionRows = computed(() => - gmStore.factions.map((f) => { - const assessment = f.strategicAssessment; - const blackboard = f.blackboard; - return { - id: f.id, - label: f.label, - color: f.color, - planCycle: blackboard?.planCycle ?? 0, - priority: describeFactionPriority(f), - strategicState: describeFactionStrategicState(f), - leadStep: describeFactionLeadStep(f), - leadTask: describeFactionLeadTask(f), - warReadiness: `Industry ${blackboard?.hasWarIndustrySupplyChain ? "yes" : "no"} · Shipyard ${blackboard?.hasShipyard ? "yes" : "no"}${blackboard?.hasActiveExpansionProject ? ` · Expanding ${blackboard.activeExpansionCommodityId ?? blackboard.activeExpansionModuleId ?? "site"}` : ""}`, - economy: describeFactionEconomy(f), - threat: describeFactionThreat(f), - fleets: assessment ? `M ${assessment.militaryShipCount}/${blackboard?.targetWarshipCount ?? 0} · Mn ${assessment.minerShipCount} · Tr ${assessment.transportShipCount} · Cn ${assessment.constructorShipCount}` : "—", - systems: assessment ? `${assessment.controlledSystemCount} / ${assessment.targetSystemCount}` : "—", - credits: Math.round(f.credits), - population: Math.round(f.populationTotal), - shipsBuilt: f.shipsBuilt, - shipsLost: f.shipsLost, - }; - }), + gmStore.factions.map((f) => ({ + id: f.id, + label: f.label, + color: f.color, + planCycle: f.strategicState.planCycle, + posture: `${titleCaseToken(f.doctrine.strategicPosture)} · ${titleCaseToken(f.doctrine.militaryPosture)} · ${titleCaseToken(f.doctrine.economicPosture)}`, + fronts: describeFactionFronts(f), + leadCampaign: describeFactionStrategicState(f), + leadObjective: describeFactionLeadTask(f), + commitments: describeFactionCommitments(f), + reserves: describeFactionReserves(f), + bottleneck: describeFactionBottleneck(f), + intent: describeFactionIntent(f), + decision: describeFactionDecision(f), + memory: describeFactionMemory(f), + economy: describeFactionEconomy(f), + threat: describeFactionThreat(f), + systems: `${f.strategicState.economicAssessment.controlledSystemCount} / ${f.doctrine.desiredControlledSystems}`, + credits: Math.round(f.credits), + population: Math.round(f.populationTotal), + shipsBuilt: f.shipsBuilt, + shipsLost: f.shipsLost, + })), ); const factionColumnHelper = createColumnHelper(); @@ -432,14 +486,18 @@ const factionColumns = [ cell: (info) => renderColorCell(info.getValue()), }), factionColumnHelper.accessor("planCycle", { header: "Cycle" }), - factionColumnHelper.accessor("priority", { header: "Top Priority" }), - factionColumnHelper.accessor("strategicState", { header: "Objective" }), - factionColumnHelper.accessor("leadStep", { header: "Lead Step" }), - factionColumnHelper.accessor("leadTask", { header: "Issued Task" }), - factionColumnHelper.accessor("warReadiness", { header: "Campaign State" }), + factionColumnHelper.accessor("posture", { header: "Posture" }), + factionColumnHelper.accessor("fronts", { header: "Fronts" }), + factionColumnHelper.accessor("leadCampaign", { header: "Lead Campaign" }), + factionColumnHelper.accessor("leadObjective", { header: "Lead Objective" }), + factionColumnHelper.accessor("commitments", { header: "Commitments" }), + factionColumnHelper.accessor("reserves", { header: "Reserves" }), + factionColumnHelper.accessor("bottleneck", { header: "Bottleneck" }), + factionColumnHelper.accessor("intent", { header: "Strategic Intent" }), + factionColumnHelper.accessor("decision", { header: "Recent Decision" }), + factionColumnHelper.accessor("memory", { header: "Memory" }), factionColumnHelper.accessor("economy", { header: "Economy" }), factionColumnHelper.accessor("threat", { header: "Threat" }), - factionColumnHelper.accessor("fleets", { header: "Fleets" }), factionColumnHelper.accessor("systems", { header: "Systems" }), factionColumnHelper.accessor("credits", { header: "Credits" }), factionColumnHelper.accessor("population", { header: "Pop" }), @@ -449,7 +507,7 @@ const factionColumns = [ const factionFilter = ref(""); const factionSorting = ref([]); -const factionOrder = useColumnOrder(["label", "color", "planCycle", "priority", "strategicState", "leadStep", "leadTask", "warReadiness", "economy", "threat", "fleets", "systems", "credits", "population", "shipsBuilt", "shipsLost"]); +const factionOrder = useColumnOrder(["label", "color", "planCycle", "posture", "fronts", "leadCampaign", "leadObjective", "commitments", "reserves", "bottleneck", "intent", "decision", "memory", "economy", "threat", "systems", "credits", "population", "shipsBuilt", "shipsLost"]); const factionTable = useVueTable({ get data() { return factionRows.value; }, @@ -472,6 +530,8 @@ const factionTable = useVueTable({ // ── Row counts ───────────────────────────────────────────────────────────── const tabs: { id: TabId; label: string }[] = [ + { id: "player", label: "Player" }, + { id: "geopolitics", label: "Geopolitics" }, { id: "ships", label: "Ships" }, { id: "stations", label: "Stations" }, { id: "factions", label: "Factions" }, @@ -479,11 +539,15 @@ const tabs: { id: TabId; label: string }[] = [ const activeFilter = computed({ get: () => { + if (activeTab.value === "player") return ""; + if (activeTab.value === "geopolitics") return ""; if (activeTab.value === "ships") return shipFilter.value; if (activeTab.value === "stations") return stationFilter.value; return factionFilter.value; }, set: (v: string) => { + if (activeTab.value === "player") return; + if (activeTab.value === "geopolitics") return; if (activeTab.value === "ships") shipFilter.value = v; else if (activeTab.value === "stations") stationFilter.value = v; else factionFilter.value = v; @@ -491,12 +555,24 @@ const activeFilter = computed({ }); const activeRowCount = computed(() => { + if (activeTab.value === "player") { + return (playerFactionStore.playerFaction?.assetRegistry.shipIds.length ?? 0) + + (playerFactionStore.playerFaction?.assetRegistry.stationIds.length ?? 0); + } + if (activeTab.value === "geopolitics") { + const geopolitics = gmStore.geopolitics; + return (geopolitics?.diplomacy.relations.length ?? 0) + + (geopolitics?.territory.controlStates.length ?? 0) + + (geopolitics?.economyRegions.regions.length ?? 0); + } if (activeTab.value === "ships") return shipTable.getFilteredRowModel().rows.length; if (activeTab.value === "stations") return stationTable.getFilteredRowModel().rows.length; return factionTable.getFilteredRowModel().rows.length; }); const activeTotalCount = computed(() => { + if (activeTab.value === "player") return activeRowCount.value; + if (activeTab.value === "geopolitics") return activeRowCount.value; if (activeTab.value === "ships") return gmStore.ships.length; if (activeTab.value === "stations") return gmStore.stations.length; return gmStore.factions.length; @@ -558,7 +634,7 @@ function hideOrdersTooltip() {