369 lines
15 KiB
C#
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;
|
|
}
|
|
}
|