404 lines
16 KiB
C#
404 lines
16 KiB
C#
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
|
|
|
|
namespace SpaceGame.Api.Factions.AI;
|
|
|
|
internal sealed class CommanderPlanningService
|
|
{
|
|
private const float FactionCommanderReplanInterval = 10f;
|
|
private const float ShipCommanderReplanInterval = 5f;
|
|
|
|
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
|
|
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
|
|
|
|
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
|
|
[
|
|
new ExterminateRivalGoal(),
|
|
new EnsureWarIndustryGoal(),
|
|
new ExpandTerritoryGoal(),
|
|
new EnsureWarFleetGoal(),
|
|
new EnsureWaterSecurityGoal(),
|
|
new EnsureMiningCapacityGoal(),
|
|
new EnsureConstructionCapacityGoal(),
|
|
];
|
|
|
|
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
|
|
[
|
|
new SetAttackObjectiveAction(),
|
|
new SetMiningObjectiveAction(),
|
|
new SetPatrolObjectiveAction(),
|
|
new SetConstructionObjectiveAction(),
|
|
new SetTradeObjectiveAction(),
|
|
new SetIdleObjectiveAction(),
|
|
];
|
|
|
|
private static readonly GoapGoal<ShipPlanningState> _shipGoal = new AssignObjectiveGoal();
|
|
|
|
internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
|
{
|
|
// Faction commanders run first so their directives are available to ship commanders in the same tick.
|
|
foreach (var commander in world.Commanders)
|
|
{
|
|
if (!commander.IsAlive || commander.Kind != CommanderKind.Faction)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
TickCommander(commander, deltaSeconds);
|
|
UpdateFactionCommander(engine, world, commander);
|
|
}
|
|
|
|
foreach (var commander in world.Commanders)
|
|
{
|
|
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
TickCommander(commander, deltaSeconds);
|
|
UpdateShipCommander(engine, world, commander);
|
|
}
|
|
}
|
|
|
|
private static void TickCommander(CommanderRuntime commander, float deltaSeconds)
|
|
{
|
|
if (commander.ReplanTimer > 0f)
|
|
{
|
|
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
|
|
}
|
|
}
|
|
|
|
private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
|
{
|
|
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
|
|
{
|
|
return;
|
|
}
|
|
|
|
commander.ReplanTimer = FactionCommanderReplanInterval;
|
|
commander.NeedsReplan = false;
|
|
|
|
var state = BuildFactionPlanningState(world, commander.FactionId);
|
|
var actions = BuildFactionActions(world);
|
|
|
|
// Clear stale directives — actions will re-assert what is still needed.
|
|
commander.ActiveDirectives.Clear();
|
|
|
|
var rankedGoals = _factionGoals
|
|
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
|
|
.Where(x => x.priority > 0f)
|
|
.OrderByDescending(x => x.priority)
|
|
.ToList();
|
|
|
|
commander.LastPlanningState = state;
|
|
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
|
|
|
|
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
|
|
foreach (var (goal, _) in rankedGoals.Take(3))
|
|
{
|
|
var plan = _factionPlanner.Plan(state, goal, actions);
|
|
plan?.CurrentAction?.Execute(engine, world, commander);
|
|
}
|
|
|
|
if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null)
|
|
{
|
|
if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal)))
|
|
{
|
|
TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId));
|
|
}
|
|
else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal)))
|
|
{
|
|
TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"));
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
|
{
|
|
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
|
|
{
|
|
return;
|
|
}
|
|
|
|
commander.ReplanTimer = ShipCommanderReplanInterval;
|
|
commander.NeedsReplan = false;
|
|
|
|
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
|
if (ship is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var state = BuildShipPlanningState(world, ship, commander);
|
|
var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions);
|
|
if (plan?.CurrentAction is { } action)
|
|
{
|
|
commander.ActiveGoalName = _shipGoal.Name;
|
|
commander.ActiveActionName = action.Name;
|
|
action.Execute(engine, world, commander);
|
|
}
|
|
}
|
|
|
|
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
|
|
{
|
|
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
|
|
var economy = FactionEconomyAnalyzer.Build(world, factionId);
|
|
var refinedMetals = economy.GetCommodity("refinedmetals");
|
|
var hullparts = economy.GetCommodity("hullparts");
|
|
var claytronics = economy.GetCommodity("claytronics");
|
|
var water = economy.GetCommodity("water");
|
|
|
|
return new FactionPlanningState
|
|
{
|
|
EnemyFactionCount = world.Factions.Count(f => f.Id != factionId),
|
|
EnemyShipCount = world.Ships.Count(s =>
|
|
s.Health > 0f &&
|
|
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
|
|
EnemyStationCount = world.Stations.Count(s =>
|
|
!string.Equals(s.FactionId, factionId, StringComparison.Ordinal)),
|
|
MilitaryShipCount = world.Ships.Count(s =>
|
|
s.FactionId == factionId &&
|
|
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
|
|
MinerShipCount = world.Ships.Count(s =>
|
|
s.FactionId == factionId &&
|
|
string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)),
|
|
TransportShipCount = world.Ships.Count(s =>
|
|
s.FactionId == factionId &&
|
|
string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)),
|
|
ConstructorShipCount = world.Ships.Count(s =>
|
|
s.FactionId == factionId &&
|
|
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
|
|
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
|
|
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
|
|
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
|
|
OreStockpile = economy.GetCommodity("ore").OnHand,
|
|
RefinedMetalsStockpile = refinedMetals.OnHand,
|
|
RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
|
|
RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds,
|
|
HullpartsStockpile = hullparts.OnHand,
|
|
HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond,
|
|
HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds,
|
|
ClaytronicsStockpile = claytronics.OnHand,
|
|
ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond,
|
|
ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds,
|
|
WaterStockpile = water.OnHand,
|
|
WaterProductionRate = water.ProjectedProductionRatePerSecond,
|
|
WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds,
|
|
};
|
|
}
|
|
|
|
private static ShipPlanningState BuildShipPlanningState(
|
|
SimulationWorld world,
|
|
ShipRuntime ship,
|
|
CommanderRuntime commander)
|
|
{
|
|
var factionCommander = world.Commanders.FirstOrDefault(c =>
|
|
c.FactionId == commander.FactionId &&
|
|
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
|
|
|
|
var enemyTarget = SelectEnemyTarget(world, ship);
|
|
var tradeRoute = SelectTradeRoute(world, ship.FactionId);
|
|
var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId);
|
|
if (commander.ActiveBehavior is not null)
|
|
{
|
|
commander.ActiveBehavior.AreaSystemId = enemyTarget?.SystemId;
|
|
commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId;
|
|
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
|
|
{
|
|
commander.ActiveBehavior.ItemId = tradeRoute?.ItemId;
|
|
commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId;
|
|
commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId;
|
|
}
|
|
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null)
|
|
{
|
|
commander.ActiveBehavior.StationId = expansionProject.SupportStationId;
|
|
commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId;
|
|
commander.ActiveBehavior.ModuleId = expansionProject.ModuleId;
|
|
commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId;
|
|
}
|
|
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
|
|
{
|
|
commander.ActiveBehavior.TargetEntityId = null;
|
|
commander.ActiveBehavior.ModuleId = null;
|
|
commander.ActiveBehavior.AreaSystemId = ship.SystemId;
|
|
}
|
|
}
|
|
|
|
return new ShipPlanningState
|
|
{
|
|
ShipKind = ship.Definition.Kind,
|
|
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
|
|
FactionWantsOre = true,
|
|
FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false,
|
|
FactionWantsExpansion = factionCommander?.ActiveDirectives
|
|
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
|
|
FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false)
|
|
? false
|
|
: !world.Stations.Any(station =>
|
|
string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)
|
|
&& station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
|
|
TargetEnemySystemId = enemyTarget?.SystemId,
|
|
TargetEnemyEntityId = enemyTarget?.EntityId,
|
|
TradeItemId = tradeRoute?.ItemId,
|
|
TradeSourceStationId = tradeRoute?.SourceStationId,
|
|
TradeDestinationStationId = tradeRoute?.DestinationStationId,
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
|
|
{
|
|
var actions = new List<GoapAction<FactionPlanningState>>();
|
|
|
|
actions.Add(new PlanWarIndustryAction());
|
|
actions.Add(new PlanCommoditySupplyAction("water"));
|
|
|
|
foreach (var (shipId, def) in world.ShipDefinitions)
|
|
{
|
|
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
|
|
}
|
|
|
|
actions.Add(new LaunchExterminationCampaignAction());
|
|
actions.Add(new ExpandToSystemAction());
|
|
return actions;
|
|
}
|
|
|
|
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
|
|
world.Commanders.FirstOrDefault(c =>
|
|
c.FactionId == factionId &&
|
|
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
|
|
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
|
|
|
|
private static void TryQueueFactionExpansionProject(
|
|
SimulationWorld world,
|
|
CommanderRuntime commander,
|
|
IndustryExpansionProject? project)
|
|
{
|
|
if (project is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
|
|
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
|
|
}
|
|
|
|
private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject(
|
|
SimulationWorld world,
|
|
FactionPlanningState state,
|
|
string factionId)
|
|
{
|
|
if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f)
|
|
{
|
|
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals");
|
|
}
|
|
|
|
if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f)
|
|
{
|
|
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts");
|
|
}
|
|
|
|
if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f)
|
|
{
|
|
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics");
|
|
}
|
|
|
|
if (!state.HasShipFactory)
|
|
{
|
|
return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship)
|
|
{
|
|
var hostileShip = world.Ships
|
|
.Where(candidate =>
|
|
candidate.Health > 0f &&
|
|
!string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
|
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
|
|
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
|
.Select(candidate => (candidate.Id, candidate.SystemId))
|
|
.FirstOrDefault();
|
|
|
|
if (hostileShip != default)
|
|
{
|
|
return hostileShip;
|
|
}
|
|
|
|
var hostileStation = world.Stations
|
|
.Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal))
|
|
.OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1)
|
|
.ThenBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
|
.Select(candidate => (candidate.Id, candidate.SystemId))
|
|
.FirstOrDefault();
|
|
|
|
return hostileStation == default ? null : hostileStation;
|
|
}
|
|
|
|
private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId)
|
|
{
|
|
var stationsById = world.Stations
|
|
.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
|
|
.ToDictionary(station => station.Id, StringComparer.Ordinal);
|
|
|
|
foreach (var demand in world.MarketOrders
|
|
.Where(order =>
|
|
string.Equals(order.FactionId, factionId, StringComparison.Ordinal)
|
|
&& order.Kind == MarketOrderKinds.Buy
|
|
&& order.RemainingAmount > 0.01f
|
|
&& order.StationId is not null)
|
|
.OrderByDescending(order => order.Valuation))
|
|
{
|
|
if (!stationsById.TryGetValue(demand.StationId!, out var destination))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var source = stationsById.Values
|
|
.Where(station =>
|
|
station.Id != destination.Id
|
|
&& GetInventoryAmount(station.Inventory, demand.ItemId) > 1f)
|
|
.OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId))
|
|
.FirstOrDefault();
|
|
if (source is not null)
|
|
{
|
|
return (demand.ItemId, source.Id, destination.Id);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId)
|
|
{
|
|
if (!world.ItemDefinitions.TryGetValue(itemId, out var definition))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var requiredModule = GetStorageRequirement(definition.CargoKind);
|
|
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var capacity = GetStationStorageCapacity(station, definition.CargoKind);
|
|
if (capacity <= 0.01f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var used = station.Inventory
|
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal))
|
|
.Sum(entry => entry.Value);
|
|
|
|
return used <= capacity - 1f;
|
|
}
|
|
}
|