feat: goap ai for faction and ship
This commit is contained in:
@@ -12,7 +12,9 @@ public sealed record ShipSnapshot(
|
|||||||
string State,
|
string State,
|
||||||
string? OrderKind,
|
string? OrderKind,
|
||||||
string DefaultBehaviorKind,
|
string DefaultBehaviorKind,
|
||||||
|
string? BehaviorPhase,
|
||||||
string ControllerTaskKind,
|
string ControllerTaskKind,
|
||||||
|
string? CommanderObjective,
|
||||||
string? NodeId,
|
string? NodeId,
|
||||||
string? BubbleId,
|
string? BubbleId,
|
||||||
string? DockedStationId,
|
string? DockedStationId,
|
||||||
@@ -41,7 +43,9 @@ public sealed record ShipDelta(
|
|||||||
string State,
|
string State,
|
||||||
string? OrderKind,
|
string? OrderKind,
|
||||||
string DefaultBehaviorKind,
|
string DefaultBehaviorKind,
|
||||||
|
string? BehaviorPhase,
|
||||||
string ControllerTaskKind,
|
string ControllerTaskKind,
|
||||||
|
string? CommanderObjective,
|
||||||
string? NodeId,
|
string? NodeId,
|
||||||
string? BubbleId,
|
string? BubbleId,
|
||||||
string? DockedStationId,
|
string? DockedStationId,
|
||||||
|
|||||||
141
apps/backend/Simulation/AI/FactionController.cs
Normal file
141
apps/backend/Simulation/AI/FactionController.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
|
// ─── 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/backend/Simulation/AI/GoapCore.cs
Normal file
91
apps/backend/Simulation/AI/GoapCore.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
|
public abstract class GoapAction<TState>
|
||||||
|
{
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public abstract float Cost { get; }
|
||||||
|
public abstract bool CheckPreconditions(TState state);
|
||||||
|
public abstract TState ApplyEffects(TState state);
|
||||||
|
public abstract void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class GoapGoal<TState>
|
||||||
|
{
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public abstract bool IsSatisfied(TState state);
|
||||||
|
public abstract float ComputePriority(TState state, SimulationWorld world, CommanderRuntime commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GoapPlan<TState>
|
||||||
|
{
|
||||||
|
public static readonly GoapPlan<TState> Empty = new() { Actions = [], TotalCost = 0f };
|
||||||
|
|
||||||
|
public required IReadOnlyList<GoapAction<TState>> Actions { get; init; }
|
||||||
|
public required float TotalCost { get; init; }
|
||||||
|
public int CurrentStep { get; set; }
|
||||||
|
|
||||||
|
public GoapAction<TState>? CurrentAction => CurrentStep < Actions.Count ? Actions[CurrentStep] : null;
|
||||||
|
public bool IsComplete => CurrentStep >= Actions.Count;
|
||||||
|
public void Advance() => CurrentStep++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GoapPlanner<TState>
|
||||||
|
{
|
||||||
|
private readonly Func<TState, TState> cloneState;
|
||||||
|
|
||||||
|
public GoapPlanner(Func<TState, TState> cloneState)
|
||||||
|
{
|
||||||
|
this.cloneState = cloneState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GoapPlan<TState>? Plan(
|
||||||
|
TState initialState,
|
||||||
|
GoapGoal<TState> goal,
|
||||||
|
IReadOnlyList<GoapAction<TState>> availableActions)
|
||||||
|
{
|
||||||
|
if (goal.IsSatisfied(initialState))
|
||||||
|
{
|
||||||
|
return GoapPlan<TState>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var openSet = new PriorityQueue<PlanNode, float>();
|
||||||
|
openSet.Enqueue(new PlanNode(cloneState(initialState), [], 0f), 0f);
|
||||||
|
|
||||||
|
const int MaxIterations = 256;
|
||||||
|
var iterations = 0;
|
||||||
|
|
||||||
|
while (openSet.Count > 0 && iterations++ < MaxIterations)
|
||||||
|
{
|
||||||
|
var current = openSet.Dequeue();
|
||||||
|
|
||||||
|
if (goal.IsSatisfied(current.State))
|
||||||
|
{
|
||||||
|
return new GoapPlan<TState>
|
||||||
|
{
|
||||||
|
Actions = current.Actions,
|
||||||
|
TotalCost = current.Cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var action in availableActions)
|
||||||
|
{
|
||||||
|
if (!action.CheckPreconditions(current.State))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newState = action.ApplyEffects(cloneState(current.State));
|
||||||
|
var newCost = current.Cost + action.Cost;
|
||||||
|
var newActions = new List<GoapAction<TState>>(current.Actions) { action };
|
||||||
|
openSet.Enqueue(new PlanNode(newState, newActions, newCost), newCost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PlanNode(
|
||||||
|
TState State,
|
||||||
|
IReadOnlyList<GoapAction<TState>> Actions,
|
||||||
|
float Cost);
|
||||||
|
}
|
||||||
154
apps/backend/Simulation/AI/ShipController.cs
Normal file
154
apps/backend/Simulation/AI/ShipController.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
|
// ─── Planning State ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed class ShipPlanningState
|
||||||
|
{
|
||||||
|
public string ShipKind { get; set; } = string.Empty;
|
||||||
|
public bool HasMiningCapability { get; set; }
|
||||||
|
public bool FactionWantsOre { get; set; }
|
||||||
|
public bool FactionWantsExpansion { get; set; }
|
||||||
|
public string? CurrentObjective { get; set; }
|
||||||
|
|
||||||
|
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Goals ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// A ship should always have an assigned objective. The planner picks the best one.
|
||||||
|
public sealed class AssignObjectiveGoal : GoapGoal<ShipPlanningState>
|
||||||
|
{
|
||||||
|
public override string Name => "assign-objective";
|
||||||
|
|
||||||
|
public override bool IsSatisfied(ShipPlanningState state) => state.CurrentObjective is not null;
|
||||||
|
|
||||||
|
public override float ComputePriority(ShipPlanningState state, SimulationWorld world, CommanderRuntime commander) =>
|
||||||
|
100f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed class SetMiningObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
|
{
|
||||||
|
public override string Name => "set-mining-objective";
|
||||||
|
public override float Cost => 1f;
|
||||||
|
|
||||||
|
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||||
|
state.HasMiningCapability && state.FactionWantsOre;
|
||||||
|
|
||||||
|
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||||
|
{
|
||||||
|
state.CurrentObjective = "auto-mine";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||||
|
{
|
||||||
|
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||||
|
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "auto-mine", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ship.DefaultBehavior.Kind = "auto-mine";
|
||||||
|
ship.DefaultBehavior.Phase = null;
|
||||||
|
ship.DefaultBehavior.NodeId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
|
{
|
||||||
|
public override string Name => "set-patrol-objective";
|
||||||
|
public override float Cost => 2f;
|
||||||
|
|
||||||
|
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||||
|
string.Equals(state.ShipKind, "military", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||||
|
{
|
||||||
|
state.CurrentObjective = "patrol";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||||
|
{
|
||||||
|
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||||
|
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "patrol", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
|
||||||
|
{
|
||||||
|
var station = world.Stations.FirstOrDefault(s =>
|
||||||
|
s.FactionId == ship.FactionId &&
|
||||||
|
string.Equals(s.SystemId, ship.SystemId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (station is not null)
|
||||||
|
{
|
||||||
|
var radius = station.Radius + 90f;
|
||||||
|
ship.DefaultBehavior.PatrolPoints.AddRange(
|
||||||
|
[
|
||||||
|
new Vector3(station.Position.X + radius, station.Position.Y, station.Position.Z),
|
||||||
|
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + radius),
|
||||||
|
new Vector3(station.Position.X - radius, station.Position.Y, station.Position.Z),
|
||||||
|
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - radius),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ship.DefaultBehavior.Kind = "patrol";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
|
{
|
||||||
|
public override string Name => "set-construction-objective";
|
||||||
|
public override float Cost => 1f;
|
||||||
|
|
||||||
|
public override bool CheckPreconditions(ShipPlanningState state) =>
|
||||||
|
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) && state.FactionWantsExpansion;
|
||||||
|
|
||||||
|
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||||
|
{
|
||||||
|
state.CurrentObjective = "construct-station";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||||
|
{
|
||||||
|
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||||
|
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "construct-station", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ship.DefaultBehavior.Kind = "construct-station";
|
||||||
|
ship.DefaultBehavior.Phase = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
|
||||||
|
{
|
||||||
|
public override string Name => "set-idle-objective";
|
||||||
|
public override float Cost => 10f;
|
||||||
|
|
||||||
|
public override bool CheckPreconditions(ShipPlanningState state) => true;
|
||||||
|
|
||||||
|
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
|
||||||
|
{
|
||||||
|
state.CurrentObjective = "idle";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
|
||||||
|
{
|
||||||
|
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
|
||||||
|
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "idle", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ship.DefaultBehavior.Kind = "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ public sealed class CommanderRuntime
|
|||||||
public string? PolicySetId { get; set; }
|
public string? PolicySetId { get; set; }
|
||||||
public string? Doctrine { get; set; }
|
public string? Doctrine { get; set; }
|
||||||
public List<string> Goals { get; } = [];
|
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 CommanderBehaviorRuntime? ActiveBehavior { get; set; }
|
||||||
public CommanderOrderRuntime? ActiveOrder { get; set; }
|
public CommanderOrderRuntime? ActiveOrder { get; set; }
|
||||||
public CommanderTaskRuntime? ActiveTask { get; set; }
|
public CommanderTaskRuntime? ActiveTask { get; set; }
|
||||||
|
|||||||
183
apps/backend/Simulation/SimulationEngine.CommanderSystem.cs
Normal file
183
apps/backend/Simulation/SimulationEngine.CommanderSystem.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using SpaceGame.Simulation.Api.Contracts;
|
||||||
|
|
||||||
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
|
public sealed partial class SimulationEngine
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
private void UpdateCommanders(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(world, commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var commander in world.Commanders)
|
||||||
|
{
|
||||||
|
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TickCommander(commander, deltaSeconds);
|
||||||
|
UpdateShipCommander(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(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);
|
||||||
|
|
||||||
|
// 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(this, world, commander);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateShipCommander(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(this, 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 = GetFactionControlledSystemsCount(world, factionId),
|
||||||
|
TargetSystemCount = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)),
|
||||||
|
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("ship-factory", StringComparer.Ordinal)),
|
||||||
|
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
|
||||||
|
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refined-metals")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -152,7 +152,9 @@ public sealed partial class SimulationEngine
|
|||||||
ship.State,
|
ship.State,
|
||||||
ship.OrderKind,
|
ship.OrderKind,
|
||||||
ship.DefaultBehaviorKind,
|
ship.DefaultBehaviorKind,
|
||||||
|
ship.BehaviorPhase,
|
||||||
ship.ControllerTaskKind,
|
ship.ControllerTaskKind,
|
||||||
|
ship.CommanderObjective,
|
||||||
ship.NodeId,
|
ship.NodeId,
|
||||||
ship.BubbleId,
|
ship.BubbleId,
|
||||||
ship.DockedStationId,
|
ship.DockedStationId,
|
||||||
@@ -474,6 +476,7 @@ public sealed partial class SimulationEngine
|
|||||||
ship.State.ToContractValue(),
|
ship.State.ToContractValue(),
|
||||||
ship.Order?.Kind ?? "none",
|
ship.Order?.Kind ?? "none",
|
||||||
ship.DefaultBehavior.Kind,
|
ship.DefaultBehavior.Kind,
|
||||||
|
ship.DefaultBehavior.Phase ?? "none",
|
||||||
ship.ControllerTask.Kind.ToContractValue(),
|
ship.ControllerTask.Kind.ToContractValue(),
|
||||||
ship.SpatialState.CurrentNodeId ?? "none",
|
ship.SpatialState.CurrentNodeId ?? "none",
|
||||||
ship.SpatialState.CurrentBubbleId ?? "none",
|
ship.SpatialState.CurrentBubbleId ?? "none",
|
||||||
@@ -660,7 +663,12 @@ public sealed partial class SimulationEngine
|
|||||||
policy.ConstructionAccessPolicy,
|
policy.ConstructionAccessPolicy,
|
||||||
policy.OperationalRangePolicy);
|
policy.OperationalRangePolicy);
|
||||||
|
|
||||||
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new(
|
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship)
|
||||||
|
{
|
||||||
|
var commander = ship.CommanderId is null ? null
|
||||||
|
: world.Commanders.FirstOrDefault(c => c.Id == ship.CommanderId && c.Kind == CommanderKind.Ship);
|
||||||
|
|
||||||
|
return new ShipDelta(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
ship.Definition.Label,
|
ship.Definition.Label,
|
||||||
ship.Definition.Kind,
|
ship.Definition.Kind,
|
||||||
@@ -672,7 +680,9 @@ public sealed partial class SimulationEngine
|
|||||||
ship.State.ToContractValue(),
|
ship.State.ToContractValue(),
|
||||||
ship.Order?.Kind,
|
ship.Order?.Kind,
|
||||||
ship.DefaultBehavior.Kind,
|
ship.DefaultBehavior.Kind,
|
||||||
|
ship.DefaultBehavior.Phase,
|
||||||
ship.ControllerTask.Kind.ToContractValue(),
|
ship.ControllerTask.Kind.ToContractValue(),
|
||||||
|
commander?.ActiveActionName,
|
||||||
ship.SpatialState.CurrentNodeId,
|
ship.SpatialState.CurrentNodeId,
|
||||||
ship.SpatialState.CurrentBubbleId,
|
ship.SpatialState.CurrentBubbleId,
|
||||||
ship.DockedStationId,
|
ship.DockedStationId,
|
||||||
@@ -688,6 +698,7 @@ public sealed partial class SimulationEngine
|
|||||||
ship.History.ToList(),
|
ship.History.ToList(),
|
||||||
ToShipActionProgressSnapshot(world, ship),
|
ToShipActionProgressSnapshot(world, ship),
|
||||||
ToShipSpatialStateSnapshot(ship.SpatialState));
|
ToShipSpatialStateSnapshot(ship.SpatialState));
|
||||||
|
}
|
||||||
|
|
||||||
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
|
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
|
||||||
{
|
{
|
||||||
|
|||||||
327
apps/backend/Simulation/SimulationEngine.StationController.cs
Normal file
327
apps/backend/Simulation/SimulationEngine.StationController.cs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
using SpaceGame.Simulation.Api.Data;
|
||||||
|
using SpaceGame.Simulation.Api.Contracts;
|
||||||
|
|
||||||
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
|
public sealed partial class SimulationEngine
|
||||||
|
{
|
||||||
|
private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
||||||
|
{
|
||||||
|
if (station.CommanderId is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredOrders = new List<DesiredMarketOrder>();
|
||||||
|
var waterReserve = MathF.Max(30f, station.Population * 3f);
|
||||||
|
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
|
||||||
|
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
||||||
|
var shipPartsReserve = HasStationModules(station, "fabricator-array")
|
||||||
|
&& !HasStationModules(station, "component-factory", "ship-factory")
|
||||||
|
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
|
||||||
|
? 90f
|
||||||
|
: 0f;
|
||||||
|
|
||||||
|
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
||||||
|
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
||||||
|
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
|
||||||
|
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
|
||||||
|
|
||||||
|
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
|
||||||
|
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
|
||||||
|
AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
|
||||||
|
|
||||||
|
ReconcileStationMarketOrders(world, station, desiredOrders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||||
|
{
|
||||||
|
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
|
||||||
|
foreach (var laneKey in GetStationProductionLanes(world, station))
|
||||||
|
{
|
||||||
|
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||||
|
if (recipe is null)
|
||||||
|
{
|
||||||
|
station.ProductionLaneTimers[laneKey] = 0f;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var throughput = GetStationProductionThroughput(world, station, recipe);
|
||||||
|
|
||||||
|
var produced = 0f;
|
||||||
|
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
|
||||||
|
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
|
||||||
|
{
|
||||||
|
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
|
||||||
|
foreach (var input in recipe.Inputs)
|
||||||
|
{
|
||||||
|
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.ShipOutputId is not null)
|
||||||
|
{
|
||||||
|
produced += CompleteShipRecipe(world, station, recipe, events);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var output in recipe.Outputs)
|
||||||
|
{
|
||||||
|
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (produced <= 0.01f)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
||||||
|
if (faction is not null)
|
||||||
|
{
|
||||||
|
faction.GoodsProduced += produced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
|
||||||
|
{
|
||||||
|
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return moduleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
|
||||||
|
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
|
||||||
|
|
||||||
|
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
|
||||||
|
world.Recipes.Values
|
||||||
|
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
|
||||||
|
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
||||||
|
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
|
||||||
|
|
||||||
|
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
||||||
|
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
||||||
|
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
|
||||||
|
|
||||||
|
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||||
|
{
|
||||||
|
var laneModuleId = GetStationProductionLaneKey(world, recipe);
|
||||||
|
if (laneModuleId is null)
|
||||||
|
{
|
||||||
|
return 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Max(1, CountModules(station.InstalledModules, laneModuleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||||
|
{
|
||||||
|
var priority = (float)recipe.Priority;
|
||||||
|
|
||||||
|
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||||
|
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
|
||||||
|
priority += recipe.Id switch
|
||||||
|
{
|
||||||
|
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
|
||||||
|
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
||||||
|
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
|
"hull-fabrication" => 180f * expansionPressure,
|
||||||
|
"equipment-assembly" => 170f * expansionPressure,
|
||||||
|
"gun-assembly" => 160f * expansionPressure,
|
||||||
|
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
|
||||||
|
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
|
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
|
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
|
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
|
"ammo-fabrication" => -80f * expansionPressure,
|
||||||
|
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
|
||||||
|
=> -120f * expansionPressure,
|
||||||
|
_ => 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
return priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||||
|
{
|
||||||
|
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|
||||||
|
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|
||||||
|
|| string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal);
|
||||||
|
return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||||
|
{
|
||||||
|
if (recipe.ShipOutputId is not null)
|
||||||
|
{
|
||||||
|
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)
|
||||||
|
|| !FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
|
||||||
|
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind);
|
||||||
|
if (capacity <= 0.01f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var used = station.Inventory
|
||||||
|
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind)
|
||||||
|
.Sum(entry => entry.Value);
|
||||||
|
return used + amount <= capacity + 0.001f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasRefineryCapability(StationRuntime station) =>
|
||||||
|
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
|
||||||
|
|
||||||
|
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
|
||||||
|
{
|
||||||
|
var current = GetInventoryAmount(station.Inventory, itemId);
|
||||||
|
if (current >= targetAmount - 0.01f)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deficit = targetAmount - current;
|
||||||
|
var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount);
|
||||||
|
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSupplyOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase)
|
||||||
|
{
|
||||||
|
var current = GetInventoryAmount(station.Inventory, itemId);
|
||||||
|
if (current <= triggerAmount + 0.01f)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var surplus = current - reserveFloor;
|
||||||
|
if (surplus <= 0.01f)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
|
||||||
|
{
|
||||||
|
var existingOrders = world.MarketOrders
|
||||||
|
.Where(order => order.StationId == station.Id && order.ConstructionSiteId is null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var desired in desiredOrders)
|
||||||
|
{
|
||||||
|
var order = existingOrders.FirstOrDefault(candidate =>
|
||||||
|
candidate.Kind == desired.Kind &&
|
||||||
|
candidate.ItemId == desired.ItemId &&
|
||||||
|
candidate.ConstructionSiteId is null);
|
||||||
|
|
||||||
|
if (order is null)
|
||||||
|
{
|
||||||
|
order = new MarketOrderRuntime
|
||||||
|
{
|
||||||
|
Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}",
|
||||||
|
FactionId = station.FactionId,
|
||||||
|
StationId = station.Id,
|
||||||
|
Kind = desired.Kind,
|
||||||
|
ItemId = desired.ItemId,
|
||||||
|
Amount = desired.Amount,
|
||||||
|
RemainingAmount = desired.Amount,
|
||||||
|
Valuation = desired.Valuation,
|
||||||
|
ReserveThreshold = desired.ReserveThreshold,
|
||||||
|
State = MarketOrderStateKinds.Open,
|
||||||
|
};
|
||||||
|
world.MarketOrders.Add(order);
|
||||||
|
station.MarketOrderIds.Add(order.Id);
|
||||||
|
existingOrders.Add(order);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
order.RemainingAmount = desired.Amount;
|
||||||
|
order.Valuation = desired.Valuation;
|
||||||
|
order.ReserveThreshold = desired.ReserveThreshold;
|
||||||
|
order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId)))
|
||||||
|
{
|
||||||
|
order.RemainingAmount = 0f;
|
||||||
|
order.State = MarketOrderStateKinds.Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
|
||||||
|
{
|
||||||
|
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||||
|
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||||
|
var deficit = Math.Max(0, targetSystems - controlledSystems);
|
||||||
|
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
|
||||||
|
{
|
||||||
|
return world.Claims
|
||||||
|
.Where(claim => claim.State != ClaimStateKinds.Destroyed)
|
||||||
|
.Select(claim => claim.SystemId)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.Count(systemId => FactionControlsSystem(world, factionId, systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
||||||
|
{
|
||||||
|
var buildableLocations = world.Claims
|
||||||
|
.Where(claim =>
|
||||||
|
claim.SystemId == systemId &&
|
||||||
|
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active)
|
||||||
|
.ToList();
|
||||||
|
if (buildableLocations.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId);
|
||||||
|
return ownedLocations > (buildableLocations.Count / 2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
|
||||||
|
}
|
||||||
@@ -54,280 +54,6 @@ public sealed partial class SimulationEngine
|
|||||||
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
|
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
|
||||||
{
|
|
||||||
if (station.CommanderId is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var desiredOrders = new List<DesiredMarketOrder>();
|
|
||||||
var waterReserve = MathF.Max(30f, station.Population * 3f);
|
|
||||||
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
|
|
||||||
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
|
||||||
var shipPartsReserve = HasStationModules(station, "fabricator-array")
|
|
||||||
&& !HasStationModules(station, "component-factory", "ship-factory")
|
|
||||||
&& FactionNeedsMoreWarships(world, station.FactionId)
|
|
||||||
? 90f
|
|
||||||
: 0f;
|
|
||||||
|
|
||||||
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
|
||||||
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
|
||||||
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
|
|
||||||
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
|
|
||||||
|
|
||||||
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
|
|
||||||
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
|
|
||||||
AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
|
|
||||||
|
|
||||||
ReconcileStationMarketOrders(world, station, desiredOrders);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
|
||||||
{
|
|
||||||
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
|
|
||||||
foreach (var laneKey in GetStationProductionLanes(world, station))
|
|
||||||
{
|
|
||||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
|
||||||
if (recipe is null)
|
|
||||||
{
|
|
||||||
station.ProductionLaneTimers[laneKey] = 0f;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var throughput = GetStationProductionThroughput(world, station, recipe);
|
|
||||||
|
|
||||||
var produced = 0f;
|
|
||||||
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
|
|
||||||
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
|
|
||||||
{
|
|
||||||
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
|
|
||||||
foreach (var input in recipe.Inputs)
|
|
||||||
{
|
|
||||||
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipe.ShipOutputId is not null)
|
|
||||||
{
|
|
||||||
produced += CompleteShipRecipe(world, station, recipe, events);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var output in recipe.Outputs)
|
|
||||||
{
|
|
||||||
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (produced <= 0.01f)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
|
||||||
if (faction is not null)
|
|
||||||
{
|
|
||||||
faction.GoodsProduced += produced;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
|
|
||||||
{
|
|
||||||
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
|
|
||||||
{
|
|
||||||
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return moduleId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
|
|
||||||
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
|
|
||||||
|
|
||||||
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
|
|
||||||
world.Recipes.Values
|
|
||||||
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
|
|
||||||
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
|
||||||
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
|
|
||||||
|
|
||||||
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
|
|
||||||
recipe.RequiredModules.FirstOrDefault(moduleId =>
|
|
||||||
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
|
|
||||||
|
|
||||||
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
|
||||||
{
|
|
||||||
var priority = (float)recipe.Priority;
|
|
||||||
|
|
||||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
|
||||||
var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f;
|
|
||||||
priority += recipe.Id switch
|
|
||||||
{
|
|
||||||
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
|
|
||||||
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
|
||||||
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
|
||||||
"hull-fabrication" => 180f * expansionPressure,
|
|
||||||
"equipment-assembly" => 170f * expansionPressure,
|
|
||||||
"gun-assembly" => 160f * expansionPressure,
|
|
||||||
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
|
|
||||||
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
|
||||||
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
|
||||||
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
|
|
||||||
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
|
|
||||||
"ammo-fabrication" => -80f * expansionPressure,
|
|
||||||
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
|
|
||||||
=> -120f * expansionPressure,
|
|
||||||
_ => 0f,
|
|
||||||
};
|
|
||||||
|
|
||||||
return priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
|
||||||
{
|
|
||||||
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|
|
||||||
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|
|
||||||
|| string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal);
|
|
||||||
return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
|
||||||
{
|
|
||||||
if (recipe.ShipOutputId is not null)
|
|
||||||
{
|
|
||||||
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)
|
|
||||||
|| !FactionNeedsMoreWarships(world, station.FactionId))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
|
||||||
{
|
|
||||||
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
|
|
||||||
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind);
|
|
||||||
if (capacity <= 0.01f)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var used = station.Inventory
|
|
||||||
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind)
|
|
||||||
.Sum(entry => entry.Value);
|
|
||||||
return used + amount <= capacity + 0.001f;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
|
|
||||||
{
|
|
||||||
var current = GetInventoryAmount(station.Inventory, itemId);
|
|
||||||
if (current >= targetAmount - 0.01f)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var deficit = targetAmount - current;
|
|
||||||
var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount);
|
|
||||||
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddSupplyOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase)
|
|
||||||
{
|
|
||||||
var current = GetInventoryAmount(station.Inventory, itemId);
|
|
||||||
if (current <= triggerAmount + 0.01f)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var surplus = current - reserveFloor;
|
|
||||||
if (surplus <= 0.01f)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
|
|
||||||
{
|
|
||||||
var existingOrders = world.MarketOrders
|
|
||||||
.Where(order => order.StationId == station.Id && order.ConstructionSiteId is null)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var desired in desiredOrders)
|
|
||||||
{
|
|
||||||
var order = existingOrders.FirstOrDefault(candidate =>
|
|
||||||
candidate.Kind == desired.Kind &&
|
|
||||||
candidate.ItemId == desired.ItemId &&
|
|
||||||
candidate.ConstructionSiteId is null);
|
|
||||||
|
|
||||||
if (order is null)
|
|
||||||
{
|
|
||||||
order = new MarketOrderRuntime
|
|
||||||
{
|
|
||||||
Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}",
|
|
||||||
FactionId = station.FactionId,
|
|
||||||
StationId = station.Id,
|
|
||||||
Kind = desired.Kind,
|
|
||||||
ItemId = desired.ItemId,
|
|
||||||
Amount = desired.Amount,
|
|
||||||
RemainingAmount = desired.Amount,
|
|
||||||
Valuation = desired.Valuation,
|
|
||||||
ReserveThreshold = desired.ReserveThreshold,
|
|
||||||
State = MarketOrderStateKinds.Open,
|
|
||||||
};
|
|
||||||
world.MarketOrders.Add(order);
|
|
||||||
station.MarketOrderIds.Add(order.Id);
|
|
||||||
existingOrders.Add(order);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
order.RemainingAmount = desired.Amount;
|
|
||||||
order.Valuation = desired.Valuation;
|
|
||||||
order.ReserveThreshold = desired.ReserveThreshold;
|
|
||||||
order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId)))
|
|
||||||
{
|
|
||||||
order.RemainingAmount = 0f;
|
|
||||||
order.State = MarketOrderStateKinds.Cancelled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasRefineryCapability(StationRuntime station) =>
|
|
||||||
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
|
|
||||||
|
|
||||||
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
|
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
|
||||||
{
|
{
|
||||||
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
|
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
|
||||||
@@ -360,52 +86,6 @@ public sealed partial class SimulationEngine
|
|||||||
return 1f;
|
return 1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId)
|
|
||||||
{
|
|
||||||
var militaryShipCount = world.Ships.Count(ship =>
|
|
||||||
ship.FactionId == factionId
|
|
||||||
&& string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal));
|
|
||||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
|
||||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
|
||||||
var expansionDeficit = Math.Max(0, targetSystems - controlledSystems);
|
|
||||||
var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3));
|
|
||||||
return militaryShipCount < targetWarships;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
|
|
||||||
{
|
|
||||||
return world.Claims
|
|
||||||
.Where(claim => claim.State != ClaimStateKinds.Destroyed)
|
|
||||||
.Select(claim => claim.SystemId)
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.Count(systemId => FactionControlsSystem(world, factionId, systemId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
|
|
||||||
{
|
|
||||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
|
||||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
|
||||||
var deficit = Math.Max(0, targetSystems - controlledSystems);
|
|
||||||
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
|
||||||
{
|
|
||||||
var buildableLocations = world.Claims
|
|
||||||
.Where(claim =>
|
|
||||||
claim.SystemId == systemId &&
|
|
||||||
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active)
|
|
||||||
.ToList();
|
|
||||||
if (buildableLocations.Count == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId);
|
|
||||||
return ownedLocations > (buildableLocations.Count / 2f);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
|
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
|
||||||
{
|
{
|
||||||
CurrentSystemId = station.SystemId,
|
CurrentSystemId = station.SystemId,
|
||||||
@@ -421,10 +101,7 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
|
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime { Kind = "idle" };
|
||||||
{
|
|
||||||
Kind = "idle",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var patrolRadius = station.Radius + 90f;
|
var patrolRadius = station.Radius + 90f;
|
||||||
@@ -440,17 +117,4 @@ public sealed partial class SimulationEngine
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
|
||||||
{
|
|
||||||
var laneModuleId = GetStationProductionLaneKey(world, recipe);
|
|
||||||
if (laneModuleId is null)
|
|
||||||
{
|
|
||||||
return 1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.Max(1, CountModules(station.InstalledModules, laneModuleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed partial class SimulationEngine
|
|||||||
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
|
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
|
||||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
|
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
|
||||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
|
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
|
||||||
|
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateCommanders(world, deltaSeconds, events)),
|
||||||
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)),
|
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)),
|
||||||
];
|
];
|
||||||
private static readonly IReadOnlyList<ShipUpdateStep> _shipUpdatePipeline =
|
private static readonly IReadOnlyList<ShipUpdateStep> _shipUpdatePipeline =
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export interface ShipSnapshot {
|
|||||||
state: string;
|
state: string;
|
||||||
orderKind: string | null;
|
orderKind: string | null;
|
||||||
defaultBehaviorKind: string;
|
defaultBehaviorKind: string;
|
||||||
|
behaviorPhase: string | null;
|
||||||
controllerTaskKind: string;
|
controllerTaskKind: string;
|
||||||
|
commanderObjective: string | null;
|
||||||
nodeId?: string | null;
|
nodeId?: string | null;
|
||||||
bubbleId?: string | null;
|
bubbleId?: string | null;
|
||||||
dockedStationId?: string | null;
|
dockedStationId?: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { inventoryAmount } from "./viewerMath";
|
import { inventoryAmount } from "./viewerMath";
|
||||||
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
|
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
||||||
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
||||||
|
|
||||||
export function renderFactionStrip(
|
export function renderFactionStrip(
|
||||||
@@ -65,8 +65,8 @@ export function renderFactionStrip(
|
|||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
<div class="ship-card-ai">
|
<div class="ship-card-ai">
|
||||||
<p>Order ${ship.orderKind ?? "none"}</p>
|
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
|
||||||
<p>Behavior ${ship.defaultBehaviorKind}</p>
|
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
|
||||||
<p>Task ${ship.controllerTaskKind}</p>
|
<p>Task ${ship.controllerTaskKind}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
formatSystemDistance,
|
formatSystemDistance,
|
||||||
inventoryAmount,
|
inventoryAmount,
|
||||||
} from "./viewerMath";
|
} from "./viewerMath";
|
||||||
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
HistoryWindowState,
|
HistoryWindowState,
|
||||||
@@ -203,6 +203,9 @@ export function updateDetailPanel(
|
|||||||
detailBodyEl.innerHTML = `
|
detailBodyEl.innerHTML = `
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>State ${shipState}</p>
|
<p>State ${shipState}</p>
|
||||||
|
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
|
||||||
|
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
|
||||||
|
<p>Task ${ship.controllerTaskKind}</p>
|
||||||
${shipAction ? `
|
${shipAction ? `
|
||||||
<div class="detail-progress">
|
<div class="detail-progress">
|
||||||
<div class="detail-progress-label">
|
<div class="detail-progress-label">
|
||||||
|
|||||||
@@ -349,6 +349,16 @@ function describeControllerTask(taskKind: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function describeShipObjective(objective: string): string {
|
||||||
|
switch (objective) {
|
||||||
|
case "set-mining-objective": return "mine resources";
|
||||||
|
case "set-patrol-objective": return "patrol";
|
||||||
|
case "set-construction-objective": return "build station";
|
||||||
|
case "set-idle-objective": return "idle";
|
||||||
|
default: return objective;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
|
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
|
||||||
if (!ship.currentAction) {
|
if (!ship.currentAction) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user