Files
space-game/apps/backend/Factions/AI/CommanderPlanningService.cs

3440 lines
159 KiB
C#

using SpaceGame.Api.Industry.Planning;
using SpaceGame.Api.Stations.Simulation;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Factions.AI;
internal sealed class CommanderPlanningService
{
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 const string MilitaryShipCategory = "military";
private const string MiningShipCategory = "mining";
private const string TransportShipCategory = "transport";
private const string ConstructionShipCategory = "construction";
internal void UpdateCommanders(SimulationWorld world, IPlayerStateStore playerStateStore, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
EnsureHierarchy(world);
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive)
{
continue;
}
if (commander.ReplanTimer > 0f)
{
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
}
}
foreach (var commander in world.Commanders.Where(candidate => candidate.IsAlive && candidate.Kind == CommanderKind.Faction).ToList())
{
if (PlayerFactionService.IsPlayerFaction(playerStateStore, commander.FactionId))
{
continue;
}
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(playerStateStore, 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(playerStateStore, 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(playerStateStore, commander.FactionId))
{
continue;
}
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
continue;
}
UpdateShipCommander(world, commander, events);
}
}
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)
{
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)
{
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<string, CommanderRuntime>(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 static void EnsureFactionStateDefaults(SimulationWorld world, FactionRuntime faction)
{
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<string, CommanderRuntime> stationCommanders)
{
if (IsMilitaryShip(ship.Definition))
{
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<SimulationEventRecord> events)
{
var faction = FindFaction(world, commander.FactionId);
if (faction is null)
{
commander.IsAlive = false;
return;
}
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
commander.PlanningCycle += 1;
EnsureFactionStateDefaults(world, faction);
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);
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 static void UpdateStationCommander(SimulationWorld world, CommanderRuntime commander)
{
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<SimulationEventRecord> events)
{
commander.ReplanTimer = ShipCommanderReplanInterval;
commander.NeedsReplan = false;
commander.PlanningCycle += 1;
commander.ActiveObjectiveIds.Clear();
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.Name} returned to default behavior."
: $"{ship.Definition.Name} 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 && IsMilitaryShip(ship.Definition));
var minerShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsMiningShip(ship.Definition));
var transportShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsTransportShip(ship.Definition));
var constructorShipCount = world.Ships.Count(ship => ship.FactionId == faction.Id && ship.Health > 0f && IsConstructionShip(ship.Definition));
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<FactionTheaterRuntime> BuildTheaters(
SimulationWorld world,
FactionRuntime faction,
FactionThreatAssessmentRuntime threatAssessment,
FactionEconomicAssessmentRuntime economicAssessment,
IndustryExpansionProject? expansionProject,
DateTimeOffset nowUtc)
{
var theaters = new List<FactionTheaterRuntime>();
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<FrontLineRuntime>()))
{
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<FactionCampaignRuntime> BuildCampaigns(
SimulationWorld world,
FactionRuntime faction,
IReadOnlyList<FactionTheaterRuntime> theaters,
FactionThreatAssessmentRuntime threatAssessment,
FactionEconomicAssessmentRuntime economicAssessment,
IndustryExpansionProject? expansionProject,
IReadOnlyDictionary<string, FactionCampaignRuntime> previousCampaigns,
DateTimeOffset nowUtc)
{
var campaigns = new List<FactionCampaignRuntime>();
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<FactionPlanStepRuntime> 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<FactionOperationalObjectiveRuntime> BuildObjectives(
SimulationWorld world,
FactionRuntime faction,
IReadOnlyList<FactionTheaterRuntime> theaters,
IReadOnlyList<FactionCampaignRuntime> campaigns,
FactionEconomicAssessmentRuntime economicAssessment,
FactionThreatAssessmentRuntime threatAssessment,
IndustryExpansionProject? expansionProject,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> previousObjectives,
DateTimeOffset nowUtc)
{
var objectives = new List<FactionOperationalObjectiveRuntime>();
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<FactionOperationalObjectiveRuntime> objectives,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> 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 = ProtectStation,
DelegationKind = "ship",
BehaviorKind = ProtectStation,
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<FactionOperationalObjectiveRuntime> objectives,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> 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 = AttackTarget,
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 = ProtectPosition,
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 = SupplyFleet,
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<FactionOperationalObjectiveRuntime> objectives,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> previousObjectives,
DateTimeOffset nowUtc)
{
if (expansionProject is null)
{
return;
}
objectives.Add(CreateObjective(
previousObjectives,
new FactionOperationalObjectiveRuntime
{
Id = $"{campaign.Id}-construct-site",
CampaignId = campaign.Id,
TheaterId = theater?.Id,
Kind = "construct-site",
DelegationKind = "ship",
BehaviorKind = ConstructStation,
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 = FindBuildTasks,
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 = ProtectPosition,
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))
{
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 = ExpertAutoMine,
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));
}
}
private static void AddEconomicObjectives(
SimulationWorld world,
FactionRuntime faction,
FactionCampaignRuntime campaign,
FactionTheaterRuntime? theater,
FactionEconomicAssessmentRuntime economicAssessment,
ICollection<FactionOperationalObjectiveRuntime> objectives,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> previousObjectives,
DateTimeOffset nowUtc)
{
var itemId = campaign.CommodityId ?? ResolveCommodityFromTheaterId(theater?.Id);
if (string.IsNullOrWhiteSpace(itemId))
{
return;
}
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 = FillShortages,
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));
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 = ExpertAutoMine,
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 = RevisitKnownStations,
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 void AddForceBuildUpObjectives(
SimulationWorld world,
FactionRuntime faction,
FactionCampaignRuntime campaign,
FactionEconomicAssessmentRuntime economicAssessment,
ICollection<FactionOperationalObjectiveRuntime> objectives,
IReadOnlyDictionary<string, FactionOperationalObjectiveRuntime> previousObjectives,
DateTimeOffset nowUtc)
{
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)
{
return;
}
objectives.Add(CreateObjective(
previousObjectives,
new FactionOperationalObjectiveRuntime
{
Id = $"{campaign.Id}-feed-shipyard",
CampaignId = campaign.Id,
Kind = "feed-shipyard",
DelegationKind = "ship",
BehaviorKind = FillShortages,
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 = ExpertAutoMine,
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<string, FactionOperationalObjectiveRuntime> 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<FactionPlanStepRuntime> 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<FactionAssetReservationRuntime> BuildReservations(
SimulationWorld world,
FactionRuntime faction,
IReadOnlyList<FactionOperationalObjectiveRuntime> objectives,
DateTimeOffset nowUtc)
{
var reservations = new List<FactionAssetReservationRuntime>();
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<string>(StringComparer.Ordinal);
var availableMilitaryCommanders = commanders.Count(commander =>
commander.Kind == CommanderKind.Ship &&
world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } commanderShip
&& commanderShip.Health > 0f
&& IsMilitaryShip(commanderShip.Definition));
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;
}
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<CommanderRuntime> commanders,
IReadOnlySet<string> 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
{
ConstructStation => IsConstructionShip(ship.Definition),
FindBuildTasks => IsTransportShip(ship.Definition),
FillShortages or AdvancedAutoTrade or RevisitKnownStations or SupplyFleet => IsTransportShip(ship.Definition),
LocalAutoMine or AdvancedAutoMine or ExpertAutoMine => IsMiningShip(ship.Definition),
Patrol or Police or ProtectPosition or ProtectShip or ProtectStation or AttackTarget => IsMilitaryShip(ship.Definition),
_ => 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<FactionProductionProgramRuntime> BuildProductionPrograms(
FactionRuntime faction,
IReadOnlyList<FactionTheaterRuntime> theaters,
IReadOnlyList<FactionCampaignRuntime> campaigns,
FactionEconomicAssessmentRuntime economicAssessment,
FactionThreatAssessmentRuntime threatAssessment,
IndustryExpansionProject? expansionProject,
IReadOnlyDictionary<string, FactionProductionProgramRuntime> previousPrograms)
{
var programs = new List<FactionProductionProgramRuntime>();
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 = MilitaryShipCategory,
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 = MiningShipCategory,
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 = TransportShipCategory,
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 = ConstructionShipCategory,
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<string, FactionProductionProgramRuntime> 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<string, FactionCampaignRuntime> previousCampaigns,
IReadOnlyCollection<FactionCampaignRuntime> 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<string, FactionOperationalObjectiveRuntime> previousObjectives,
IReadOnlyCollection<FactionOperationalObjectiveRuntime> 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<string, FactionTheaterRuntime> previousTheaters,
IReadOnlyCollection<FactionTheaterRuntime> 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<string, FactionProductionProgramRuntime> previousPrograms,
IReadOnlyCollection<FactionProductionProgramRuntime> 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<SimulationEventRecord> 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;
}
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 (commander.Kind == CommanderKind.Ship
&& world.Ships.FirstOrDefault(ship => ship.Id == commander.ControlledEntityId) is { } ship)
{
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" => AttackTarget,
"defense" => ProtectPosition,
"expansion" => ProtectPosition,
_ => 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 = FillShortages,
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 = FillShortages,
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 = FindBuildTasks,
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 = FillShortages,
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<string, CommanderRuntime> 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, SupplyFleet, 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<string, CommanderRuntime>(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" => AttackTarget,
"defense" => ProtectPosition,
"expansion" => ProtectPosition,
_ => 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<string, CommanderRuntime> 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<string, CommanderRuntime> fleetCommanders)
{
if (objective?.CampaignId is not null
&& fleetCommanders.TryGetValue(objective.CampaignId, out var fleetCommander)
&& (IsCombatObjective(objective) || string.Equals(objective.BehaviorKind, SupplyFleet, 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 (IsMiningShip(ship.Definition))
{
if (homeStation is null)
{
return new DefaultBehaviorRuntime
{
Kind = LocalAutoMine,
HomeSystemId = ship.SystemId,
HomeStationId = null,
AreaSystemId = ship.SystemId,
ItemId = "ore",
Radius = 24f,
MaxSystemRange = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = ship.Definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
HomeStationId = homeStation?.Id,
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
ItemId = null,
Radius = 24f,
MaxSystemRange = ship.Definition.GetTotalCargoCapacity() >= 120f ? 3 : 1,
};
}
if (IsTransportShip(ship.Definition))
{
return new DefaultBehaviorRuntime
{
Kind = AdvancedAutoTrade,
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
HomeStationId = homeStation?.Id,
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
Radius = 24f,
MaxSystemRange = 2,
};
}
if (IsConstructionShip(ship.Definition))
{
return new DefaultBehaviorRuntime
{
Kind = ConstructStation,
HomeSystemId = homeStation?.SystemId ?? ship.SystemId,
HomeStationId = homeStation?.Id,
AreaSystemId = homeStation?.SystemId ?? ship.SystemId,
Radius = 28f,
MaxSystemRange = 2,
};
}
if (IsMilitaryShip(ship.Definition))
{
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
{
ProtectPosition or ProtectStation or Patrol or Police => MathF.Max(80f, fallback.Radius),
FollowShip or ProtectShip => MathF.Max(18f, fallback.Radius * 0.6f),
FillShortages or AdvancedAutoTrade or FindBuildTasks => MathF.Max(20f, fallback.Radius),
_ => fallback.Radius,
};
var maxRange = objective.BehaviorKind switch
{
AttackTarget or ProtectPosition or ProtectStation or ProtectShip or Patrol or Police => Math.Max(1, fallback.MaxSystemRange),
FillShortages or AdvancedAutoTrade or FindBuildTasks or SupplyFleet => 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,
ItemId = objective.ItemId ?? fallback.ItemId,
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 == SupplyFleet ? 4f : fallback.WaitSeconds,
Radius = radius,
MaxSystemRange = maxRange,
KnownStationsOnly = objective.BehaviorKind == RevisitKnownStations,
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.ItemId = source.ItemId;
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.ItemId, right.ItemId, 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,
SourceKind = ShipOrderSourceKind.Commander,
SourceId = objective.Id,
Priority = 90 + objective.ReinforcementLevel,
InterruptCurrentPlan = true,
Label = $"{objective.Kind} staging",
TargetEntityId = objective.TargetEntityId,
TargetSystemId = targetSystemId,
TargetPosition = targetPosition,
DestinationStationId = objective.BehaviorKind == DockAndWait ? 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 ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
{
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 changed;
}
var existing = ship.OrderQueue.FirstOrDefault(order => string.Equals(order.Id, desiredOrder.Id, StringComparison.Ordinal));
if (existing is not null)
{
if (ShipOrdersEqual(existing, desiredOrder))
{
return changed;
}
ship.OrderQueue.Remove(existing);
changed = true;
}
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
{
changed |= ship.OrderQueue.RemoveAll(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
}
if (ship.OrderQueue.Count < 8)
{
ship.OrderQueue.Add(desiredOrder);
changed = true;
}
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.SourceKind == right.SourceKind
&& string.Equals(left.SourceId, right.SourceId, 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 AttackTarget or ProtectPosition or ProtectShip or ProtectStation 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<OffensiveTargetCandidate> 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<Vector3> 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<FactionTheaterRuntime> theaters,
IReadOnlyCollection<FactionCampaignRuntime> 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);
}