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

369 lines
15 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 readonly FactionObjectivePlanner _objectivePlanner = new();
private readonly FactionObjectiveExecutor _objectiveExecutor = new();
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;
commander.PlanningCycle += 1;
var state = BuildFactionPlanningState(world, commander.FactionId);
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
.Where(x => x.priority > 0f)
.OrderByDescending(x => x.priority)
.ToList();
commander.LastStrategicAssessment = state;
commander.LastStrategicPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
_objectivePlanner.UpdateBlackboard(world, commander, state);
_objectivePlanner.RefreshObjectives(
world,
commander,
state,
rankedGoals.Select(entry => (entry.goal.Name, entry.priority)).ToList());
_objectiveExecutor.Execute(engine, world, commander, state);
}
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
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,
RefinedMetalsAvailableStock = refinedMetals.AvailableStock,
RefinedMetalsUsageRate = refinedMetals.OperationalUsageRatePerSecond,
RefinedMetalsProjectedProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
RefinedMetalsProjectedNetRate = refinedMetals.ProjectedNetRatePerSecond,
RefinedMetalsLevelSeconds = refinedMetals.LevelSeconds,
RefinedMetalsLevel = refinedMetals.Level.ToString().ToLowerInvariant(),
HullpartsAvailableStock = hullparts.AvailableStock,
HullpartsUsageRate = hullparts.OperationalUsageRatePerSecond,
HullpartsProjectedProductionRate = hullparts.ProjectedProductionRatePerSecond,
HullpartsProjectedNetRate = hullparts.ProjectedNetRatePerSecond,
HullpartsLevelSeconds = hullparts.LevelSeconds,
HullpartsLevel = hullparts.Level.ToString().ToLowerInvariant(),
ClaytronicsAvailableStock = claytronics.AvailableStock,
ClaytronicsUsageRate = claytronics.OperationalUsageRatePerSecond,
ClaytronicsProjectedProductionRate = claytronics.ProjectedProductionRatePerSecond,
ClaytronicsProjectedNetRate = claytronics.ProjectedNetRatePerSecond,
ClaytronicsLevelSeconds = claytronics.LevelSeconds,
ClaytronicsLevel = claytronics.Level.ToString().ToLowerInvariant(),
WaterAvailableStock = water.AvailableStock,
WaterUsageRate = water.OperationalUsageRatePerSecond,
WaterProjectedProductionRate = water.ProjectedProductionRatePerSecond,
WaterProjectedNetRate = water.ProjectedNetRatePerSecond,
WaterLevelSeconds = water.LevelSeconds,
WaterLevel = water.Level.ToString().ToLowerInvariant(),
};
}
private static ShipPlanningState BuildShipPlanningState(
SimulationWorld world,
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = FindFactionCommander(world, commander.FactionId);
var enemyTarget = SelectEnemyTarget(world, ship);
var tradeRoute = SelectTradeRoute(world, ship.FactionId);
var expansionTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ExpandIndustry);
var attackTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.AttackFactionAssets);
var shipyardExpansionTask = factionCommander?.IssuedTasks
.Where(task =>
task.Kind == FactionIssuedTaskKind.ExpandIndustry
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active
&& string.Equals(task.ModuleId, "module_gen_build_l_01", StringComparison.Ordinal))
.OrderByDescending(task => task.Priority)
.FirstOrDefault();
var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId);
if (commander.ActiveBehavior is not null)
{
commander.ActiveBehavior.AreaSystemId = attackTask?.TargetSystemId ?? expansionTask?.TargetSystemId ?? enemyTarget?.SystemId;
commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId;
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
{
commander.ActiveBehavior.ItemId = tradeRoute?.ItemId;
commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId;
commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && (expansionTask is not null || expansionProject is not null))
{
commander.ActiveBehavior.StationId = expansionProject?.SupportStationId;
commander.ActiveBehavior.TargetEntityId = expansionTask?.TargetSiteId ?? expansionProject?.SiteId;
commander.ActiveBehavior.ModuleId = expansionTask?.ModuleId ?? expansionProject?.ModuleId;
commander.ActiveBehavior.AreaSystemId = expansionTask?.TargetSystemId ?? expansionProject?.SystemId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
{
commander.ActiveBehavior.TargetEntityId = null;
commander.ActiveBehavior.ModuleId = null;
commander.ActiveBehavior.AreaSystemId = ship.SystemId;
}
}
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsCombat = attackTask is not null,
FactionWantsExpansion = expansionTask is not null,
FactionNeedsShipyard = shipyardExpansionTask is not null
&& !world.Stations.Any(station =>
string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)
&& station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
TargetEnemySystemId = attackTask?.TargetSystemId ?? enemyTarget?.SystemId,
TargetEnemyEntityId = enemyTarget?.EntityId,
TradeItemId = tradeRoute?.ItemId,
TradeSourceStationId = tradeRoute?.SourceStationId,
TradeDestinationStationId = tradeRoute?.DestinationStationId,
};
}
internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
internal static bool FactionCommanderHasIssuedTask(
SimulationWorld world,
string factionId,
FactionIssuedTaskKind kind,
string? shipRole = null) =>
FindFactionCommander(world, factionId)?
.IssuedTasks.Any(task =>
task.Kind == kind
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked
&& (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) ?? false;
internal static FactionIssuedTaskRuntime? GetHighestPriorityIssuedTask(
CommanderRuntime? factionCommander,
FactionIssuedTaskKind kind,
string? shipRole = null) =>
factionCommander?.IssuedTasks
.Where(task =>
task.Kind == kind
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked
&& (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal)))
.OrderByDescending(task => task.Priority)
.FirstOrDefault();
private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship)
{
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;
}
}