diff --git a/apps/backend/Contracts/WorldContracts.Ships.cs b/apps/backend/Contracts/WorldContracts.Ships.cs index 2c35cf5..e0ef934 100644 --- a/apps/backend/Contracts/WorldContracts.Ships.cs +++ b/apps/backend/Contracts/WorldContracts.Ships.cs @@ -12,7 +12,9 @@ public sealed record ShipSnapshot( string State, string? OrderKind, string DefaultBehaviorKind, + string? BehaviorPhase, string ControllerTaskKind, + string? CommanderObjective, string? NodeId, string? BubbleId, string? DockedStationId, @@ -41,7 +43,9 @@ public sealed record ShipDelta( string State, string? OrderKind, string DefaultBehaviorKind, + string? BehaviorPhase, string ControllerTaskKind, + string? CommanderObjective, string? NodeId, string? BubbleId, string? DockedStationId, diff --git a/apps/backend/Simulation/AI/FactionController.cs b/apps/backend/Simulation/AI/FactionController.cs new file mode 100644 index 0000000..661cc59 --- /dev/null +++ b/apps/backend/Simulation/AI/FactionController.cs @@ -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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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"); + } +} diff --git a/apps/backend/Simulation/AI/GoapCore.cs b/apps/backend/Simulation/AI/GoapCore.cs new file mode 100644 index 0000000..b546671 --- /dev/null +++ b/apps/backend/Simulation/AI/GoapCore.cs @@ -0,0 +1,91 @@ +namespace SpaceGame.Simulation.Api.Simulation; + +public abstract class GoapAction +{ + 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 +{ + 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 +{ + public static readonly GoapPlan Empty = new() { Actions = [], TotalCost = 0f }; + + public required IReadOnlyList> Actions { get; init; } + public required float TotalCost { get; init; } + public int CurrentStep { get; set; } + + public GoapAction? CurrentAction => CurrentStep < Actions.Count ? Actions[CurrentStep] : null; + public bool IsComplete => CurrentStep >= Actions.Count; + public void Advance() => CurrentStep++; +} + +public sealed class GoapPlanner +{ + private readonly Func cloneState; + + public GoapPlanner(Func cloneState) + { + this.cloneState = cloneState; + } + + public GoapPlan? Plan( + TState initialState, + GoapGoal goal, + IReadOnlyList> availableActions) + { + if (goal.IsSatisfied(initialState)) + { + return GoapPlan.Empty; + } + + var openSet = new PriorityQueue(); + 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 + { + 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>(current.Actions) { action }; + openSet.Enqueue(new PlanNode(newState, newActions, newCost), newCost); + } + } + + return null; + } + + private sealed record PlanNode( + TState State, + IReadOnlyList> Actions, + float Cost); +} diff --git a/apps/backend/Simulation/AI/ShipController.cs b/apps/backend/Simulation/AI/ShipController.cs new file mode 100644 index 0000000..5381261 --- /dev/null +++ b/apps/backend/Simulation/AI/ShipController.cs @@ -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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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"; + } +} diff --git a/apps/backend/Simulation/Model/FactionRuntimeModels.cs b/apps/backend/Simulation/Model/FactionRuntimeModels.cs index 8c798fa..dcf1b52 100644 --- a/apps/backend/Simulation/Model/FactionRuntimeModels.cs +++ b/apps/backend/Simulation/Model/FactionRuntimeModels.cs @@ -26,6 +26,11 @@ public sealed class CommanderRuntime public string? PolicySetId { get; set; } public string? Doctrine { get; set; } public List Goals { get; } = []; + public HashSet ActiveDirectives { get; } = new(StringComparer.Ordinal); + public string? ActiveGoalName { get; set; } + public string? ActiveActionName { get; set; } + public float ReplanTimer { get; set; } + public bool NeedsReplan { get; set; } = true; public CommanderBehaviorRuntime? ActiveBehavior { get; set; } public CommanderOrderRuntime? ActiveOrder { get; set; } public CommanderTaskRuntime? ActiveTask { get; set; } diff --git a/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs b/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs new file mode 100644 index 0000000..f5ad231 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs @@ -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 _factionPlanner = new(s => s.Clone()); + private static readonly GoapPlanner _shipPlanner = new(s => s.Clone()); + + private static readonly IReadOnlyList> _factionGoals = + [ + new ExpandTerritoryGoal(), + new EnsureWarFleetGoal(), + new EnsureMiningCapacityGoal(), + new EnsureConstructionCapacityGoal(), + ]; + + private static readonly IReadOnlyList> _shipActions = + [ + new SetMiningObjectiveAction(), + new SetPatrolObjectiveAction(), + new SetConstructionObjectiveAction(), + new SetIdleObjectiveAction(), + ]; + + private static readonly GoapGoal _shipGoal = new AssignObjectiveGoal(); + + private void UpdateCommanders(SimulationWorld world, float deltaSeconds, ICollection 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> BuildFactionActions(SimulationWorld world) + { + var actions = new List>(); + + 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; +} diff --git a/apps/backend/Simulation/SimulationEngine.Replication.cs b/apps/backend/Simulation/SimulationEngine.Replication.cs index c8b68f0..62af945 100644 --- a/apps/backend/Simulation/SimulationEngine.Replication.cs +++ b/apps/backend/Simulation/SimulationEngine.Replication.cs @@ -152,7 +152,9 @@ public sealed partial class SimulationEngine ship.State, ship.OrderKind, ship.DefaultBehaviorKind, + ship.BehaviorPhase, ship.ControllerTaskKind, + ship.CommanderObjective, ship.NodeId, ship.BubbleId, ship.DockedStationId, @@ -474,6 +476,7 @@ public sealed partial class SimulationEngine ship.State.ToContractValue(), ship.Order?.Kind ?? "none", ship.DefaultBehavior.Kind, + ship.DefaultBehavior.Phase ?? "none", ship.ControllerTask.Kind.ToContractValue(), ship.SpatialState.CurrentNodeId ?? "none", ship.SpatialState.CurrentBubbleId ?? "none", @@ -660,7 +663,12 @@ public sealed partial class SimulationEngine policy.ConstructionAccessPolicy, 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.Definition.Label, ship.Definition.Kind, @@ -672,7 +680,9 @@ public sealed partial class SimulationEngine ship.State.ToContractValue(), ship.Order?.Kind, ship.DefaultBehavior.Kind, + ship.DefaultBehavior.Phase, ship.ControllerTask.Kind.ToContractValue(), + commander?.ActiveActionName, ship.SpatialState.CurrentNodeId, ship.SpatialState.CurrentBubbleId, ship.DockedStationId, @@ -688,6 +698,7 @@ public sealed partial class SimulationEngine ship.History.ToList(), ToShipActionProgressSnapshot(world, ship), ToShipSpatialStateSnapshot(ship.SpatialState)); + } private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship) { diff --git a/apps/backend/Simulation/SimulationEngine.StationController.cs b/apps/backend/Simulation/SimulationEngine.StationController.cs new file mode 100644 index 0000000..17bade0 --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.StationController.cs @@ -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(); + 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 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 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 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 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 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); +} diff --git a/apps/backend/Simulation/SimulationEngine.StationSystems.cs b/apps/backend/Simulation/SimulationEngine.StationSystems.cs index 23cf3c3..6e2ab6e 100644 --- a/apps/backend/Simulation/SimulationEngine.StationSystems.cs +++ b/apps/backend/Simulation/SimulationEngine.StationSystems.cs @@ -54,280 +54,6 @@ public sealed partial class SimulationEngine station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); } - private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) - { - if (station.CommanderId is null) - { - return; - } - - var desiredOrders = new List(); - 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 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 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 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 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 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 events) { if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition)) @@ -360,52 +86,6 @@ public sealed partial class SimulationEngine 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() { CurrentSystemId = station.SystemId, @@ -421,10 +101,7 @@ public sealed partial class SimulationEngine { if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) { - return new DefaultBehaviorRuntime - { - Kind = "idle", - }; + return new DefaultBehaviorRuntime { Kind = "idle" }; } var patrolRadius = station.Radius + 90f; @@ -432,25 +109,12 @@ public sealed partial class SimulationEngine { Kind = "patrol", PatrolPoints = - [ - new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), - new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius), - new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z), - new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius), - ], + [ + new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), + new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius), + new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z), + new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius), + ], }; } - - 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); } diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index 793e42d..41346df 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -14,7 +14,8 @@ public sealed partial class SimulationEngine new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)), new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)), new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)), - new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)), + new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateCommanders(world, deltaSeconds, events)), + new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateStations(world, deltaSeconds, events)), ]; private static readonly IReadOnlyList _shipUpdatePipeline = [ diff --git a/apps/viewer/src/contractsShips.ts b/apps/viewer/src/contractsShips.ts index b7c5d7d..174fb6e 100644 --- a/apps/viewer/src/contractsShips.ts +++ b/apps/viewer/src/contractsShips.ts @@ -12,7 +12,9 @@ export interface ShipSnapshot { state: string; orderKind: string | null; defaultBehaviorKind: string; + behaviorPhase: string | null; controllerTaskKind: string; + commanderObjective: string | null; nodeId?: string | null; bubbleId?: string | null; dockedStationId?: string | null; diff --git a/apps/viewer/src/viewerFactionStrip.ts b/apps/viewer/src/viewerFactionStrip.ts index 4d6fa65..c835525 100644 --- a/apps/viewer/src/viewerFactionStrip.ts +++ b/apps/viewer/src/viewerFactionStrip.ts @@ -1,5 +1,5 @@ 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"; export function renderFactionStrip( @@ -65,8 +65,8 @@ export function renderFactionStrip( ` : ""}
-

Order ${ship.orderKind ?? "none"}

-

Behavior ${ship.defaultBehaviorKind}

+ ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} +

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

Task ${ship.controllerTaskKind}

diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 374410c..112c90f 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -5,7 +5,7 @@ import { formatSystemDistance, inventoryAmount, } 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 { CameraMode, HistoryWindowState, @@ -203,6 +203,9 @@ export function updateDetailPanel( detailBodyEl.innerHTML = `

Parent ${parent}

State ${shipState}

+ ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} +

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

+

Task ${ship.controllerTaskKind}

${shipAction ? `
diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts index 2b10b3a..574e055 100644 --- a/apps/viewer/src/viewerSelection.ts +++ b/apps/viewer/src/viewerSelection.ts @@ -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 { if (!ship.currentAction) { return undefined;