Refactor backend into domain-first slices

This commit is contained in:
2026-03-19 18:15:44 -04:00
parent 07a3142316
commit 9a5040cf1f
53 changed files with 94 additions and 140 deletions

View File

@@ -0,0 +1,187 @@
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 ExpandTerritoryGoal(),
new EnsureWarFleetGoal(),
new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(),
];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[
new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(),
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);
}
}
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();
return new FactionPlanningState
{
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 = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
};
}
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));
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
var actions = new List<GoapAction<FactionPlanningState>>();
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
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;
}

View File

@@ -0,0 +1,142 @@
namespace SpaceGame.Api.Factions.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class FactionPlanningState
{
public int MilitaryShipCount { get; set; }
public int MinerShipCount { get; set; }
public int TransportShipCount { get; set; }
public int ConstructorShipCount { get; set; }
public int ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; }
public bool HasShipFactory { get; set; }
public float OreStockpile { get; set; }
public float RefinedMetalsStockpile { get; set; }
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
internal static int ComputeTargetWarships(FactionPlanningState state)
{
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
return Math.Max(2, (state.ControlledSystemCount * 2) + (expansionDeficit * 3));
}
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-war-fleet";
public override bool IsSatisfied(FactionPlanningState state) =>
state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount;
return deficit <= 0 ? 0f : 50f + (deficit * 10f);
}
}
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "expand-territory";
public override bool IsSatisfied(FactionPlanningState state) =>
state.ControlledSystemCount >= state.TargetSystemCount;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = state.TargetSystemCount - state.ControlledSystemCount;
return deficit <= 0 ? 0f : 80f + (deficit * 15f);
}
}
public sealed class EnsureMiningCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinMiners = 2;
public override string Name => "ensure-mining-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinMiners - state.MinerShipCount;
return deficit <= 0 ? 0f : 70f + (deficit * 12f);
}
}
public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinConstructors = 1;
public override string Name => "ensure-construction-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinConstructors - state.ConstructorShipCount;
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
{
private readonly string shipKind;
private readonly string shipId;
public OrderShipProductionAction(string shipKind, string shipId)
{
this.shipKind = shipKind;
this.shipId = shipId;
}
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
switch (shipKind)
{
case "military": state.MilitaryShipCount++; break;
case "mining": state.MinerShipCount++; break;
case "transport": state.TransportShipCount++; break;
case "construction": state.ConstructorShipCount++; break;
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
}
}
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
{
public override string Name => "expand-to-system";
public override float Cost => 3f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.ControlledSystemCount++;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("expand-territory");
}
}

View File

@@ -0,0 +1,42 @@
namespace SpaceGame.Api.Factions.Contracts;
public sealed record FactionGoapStateSnapshot(
int MilitaryShipCount,
int MinerShipCount,
int TransportShipCount,
int ConstructorShipCount,
int ControlledSystemCount,
int TargetSystemCount,
bool HasShipFactory,
float OreStockpile,
float RefinedMetalsStockpile);
public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority);
public sealed record FactionSnapshot(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionGoapStateSnapshot? GoapState,
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);
public sealed record FactionDelta(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionGoapStateSnapshot? GoapState,
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);

View File

@@ -0,0 +1,74 @@
namespace SpaceGame.Api.Factions.Runtime;
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
public string? ActiveGoalName { get; set; }
public string? ActiveActionName { get; set; }
public float ReplanTimer { get; set; }
public bool NeedsReplan { get; set; } = true;
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
public FactionPlanningState? LastPlanningState { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}