3441 lines
159 KiB
C#
3441 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.AnchorId}",
|
|
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.AnchorId,
|
|
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?.AnchorId ?? 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,
|
|
PreferredAnchorId = fallback.PreferredAnchorId,
|
|
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.PreferredAnchorId = source.PreferredAnchorId;
|
|
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.PreferredAnchorId, right.PreferredAnchorId, 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.AnchorId, right.AnchorId, 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 == DockAtStation ? 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 is not null)
|
|
{
|
|
return world.Anchors.FirstOrDefault(anchor => anchor.Id == site.AnchorId)?.Position
|
|
?? Vector3.Zero;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool ReconcileAiOrders(ShipRuntime ship, ShipOrderRuntime? desiredOrder)
|
|
{
|
|
var changed = ship.OrderQueue.RemoveWhere(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.FindById(desiredOrder.Id);
|
|
if (existing is not null)
|
|
{
|
|
if (ShipOrdersEqual(existing, desiredOrder))
|
|
{
|
|
return changed;
|
|
}
|
|
|
|
ship.OrderQueue.AddOrReplaceManagedOrder(desiredOrder);
|
|
return true;
|
|
}
|
|
|
|
if (ship.OrderQueue.Count >= MaxAiOrdersPerShip)
|
|
{
|
|
changed |= ship.OrderQueue.RemoveWhere(order => order.Id.StartsWith("ai-order-", StringComparison.Ordinal)) > 0;
|
|
}
|
|
|
|
if (ship.OrderQueue.Count < ShipOrderQueue.MaxOrders)
|
|
{
|
|
ship.OrderQueue.AddOrReplaceManagedOrder(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.AnchorId, right.AnchorId, 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?.AnchorId ?? 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)
|
|
{
|
|
return world.Anchors.FirstOrDefault(candidate => candidate.Id == site.AnchorId)?.Position
|
|
?? Vector3.Zero;
|
|
}
|
|
|
|
return world.Anchors.FirstOrDefault(candidate => candidate.Id == project.AnchorId)?.Position
|
|
?? ResolveSystemAnchor(world, project.SystemId);
|
|
}
|
|
|
|
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
|
=> GeopoliticalSimulationService.FactionControlsSystem(world, factionId, systemId);
|
|
}
|