feat: goap ai for faction and ship

This commit is contained in:
2026-03-18 00:09:39 -04:00
parent b3508d9d08
commit ad5f733b3e
14 changed files with 945 additions and 349 deletions

View File

@@ -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,

View 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");
}
}

View 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);
}

View 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";
}
}

View File

@@ -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; }

View 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;
}

View File

@@ -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)
{ {

View 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);
}

View File

@@ -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);
} }

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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>

View File

@@ -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">

View File

@@ -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;