From cd1fe776a5b27c2bf69391e4ddfb8be82e9d4ab4 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 19 Mar 2026 23:34:06 -0400 Subject: [PATCH] Deepen faction economy and station planning --- apps/backend/Definitions/WorldDefinitions.cs | 2 + .../Factions/AI/CommanderPlanningService.cs | 220 +++++- apps/backend/Factions/AI/FactionController.cs | 192 ++++- apps/backend/Factions/Contracts/Factions.cs | 10 +- .../Factions/Runtime/FactionRuntimeModels.cs | 2 + apps/backend/GlobalUsings.cs | 1 + .../Planning/FactionEconomySnapshot.cs | 228 ++++++ .../Planning/FactionIndustryPlanner.cs | 492 +++++++++++++ .../Industry/Planning/ProductionGraph.cs | 53 ++ .../Planning/ProductionGraphBuilder.cs | 105 +++ .../backend/Shared/Runtime/SimulationKinds.cs | 4 + .../Ships/AI/ShipBehaviorStateMachine.cs | 2 + apps/backend/Ships/AI/ShipBehaviorStates.cs | 58 ++ apps/backend/Ships/AI/ShipController.cs | 74 +- .../Ships/Runtime/ShipRuntimeModels.cs | 2 + .../Ships/Simulation/ShipControlService.cs | 348 +++++++++- .../ShipTaskExecutionService.Actions.cs | 151 +++- .../Simulation/ShipTaskExecutionService.cs | 80 +++ .../Simulation/Core/SimulationEngine.cs | 62 +- .../Core/SimulationProjectionService.cs | 17 +- .../Stations/Contracts/Infrastructure.cs | 2 + .../Stations/Runtime/StationRuntimeModels.cs | 3 + .../InfrastructureSimulationService.cs | 653 ++++++++++++++++-- .../Simulation/StationLifecycleService.cs | 83 +++ .../Simulation/StationSimulationService.cs | 172 ++++- .../Universe/Runtime/SimulationWorld.cs | 1 + .../Universe/Scenario/DataCatalogLoader.cs | 9 +- .../backend/Universe/Scenario/WorldBuilder.cs | 51 +- .../Universe/Scenario/WorldSeedingService.cs | 104 +-- apps/backend/appsettings.Development.json | 2 +- shared/data/scenario.json | 132 +++- shared/data/ships.json | 3 +- shared/data/systems.json | 27 +- 33 files changed, 3170 insertions(+), 175 deletions(-) create mode 100644 apps/backend/Industry/Planning/FactionEconomySnapshot.cs create mode 100644 apps/backend/Industry/Planning/FactionIndustryPlanner.cs create mode 100644 apps/backend/Industry/Planning/ProductionGraph.cs create mode 100644 apps/backend/Industry/Planning/ProductionGraphBuilder.cs diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index 06bb039..dce2e50 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -293,6 +293,7 @@ public sealed class InitialStationDefinition public required string SystemId { get; set; } public string Label { get; set; } = "Orbital Station"; public string Color { get; set; } = "#8df0d2"; + public string Objective { get; set; } = "general"; public List StartingModules { get; set; } = []; public string? FactionId { get; set; } public int? PlanetIndex { get; set; } @@ -307,6 +308,7 @@ public sealed class ShipFormationDefinition public required float[] Center { get; set; } public required string SystemId { get; set; } public string? FactionId { get; set; } + public Dictionary StartingInventory { get; set; } = new(StringComparer.Ordinal); } public sealed class PatrolRouteDefinition diff --git a/apps/backend/Factions/AI/CommanderPlanningService.cs b/apps/backend/Factions/AI/CommanderPlanningService.cs index 3699683..5565fcc 100644 --- a/apps/backend/Factions/AI/CommanderPlanningService.cs +++ b/apps/backend/Factions/AI/CommanderPlanningService.cs @@ -12,17 +12,22 @@ internal sealed class CommanderPlanningService private static readonly IReadOnlyList> _factionGoals = [ + new ExterminateRivalGoal(), + new EnsureWarIndustryGoal(), new ExpandTerritoryGoal(), new EnsureWarFleetGoal(), + new EnsureWaterSecurityGoal(), new EnsureMiningCapacityGoal(), new EnsureConstructionCapacityGoal(), ]; private static readonly IReadOnlyList> _shipActions = [ + new SetAttackObjectiveAction(), new SetMiningObjectiveAction(), new SetPatrolObjectiveAction(), new SetConstructionObjectiveAction(), + new SetTradeObjectiveAction(), new SetIdleObjectiveAction(), ]; @@ -93,6 +98,19 @@ internal sealed class CommanderPlanningService var plan = _factionPlanner.Plan(state, goal, actions); plan?.CurrentAction?.Execute(engine, world, commander); } + + if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null) + { + if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal))) + { + TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId)); + } + else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal))) + { + TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water")); + } + } + } private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) @@ -124,9 +142,20 @@ internal sealed class CommanderPlanningService internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId) { var stations = world.Stations.Where(s => s.FactionId == factionId).ToList(); + var economy = FactionEconomyAnalyzer.Build(world, factionId); + var refinedMetals = economy.GetCommodity("refinedmetals"); + var hullparts = economy.GetCommodity("hullparts"); + var claytronics = economy.GetCommodity("claytronics"); + var water = economy.GetCommodity("water"); return new FactionPlanningState { + EnemyFactionCount = world.Factions.Count(f => f.Id != factionId), + EnemyShipCount = world.Ships.Count(s => + s.Health > 0f && + !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), + EnemyStationCount = world.Stations.Count(s => + !string.Equals(s.FactionId, factionId, StringComparison.Ordinal)), MilitaryShipCount = world.Ships.Count(s => s.FactionId == factionId && string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)), @@ -142,8 +171,19 @@ internal sealed class CommanderPlanningService ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId), TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)), HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), - OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")), - RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")), + OreStockpile = economy.GetCommodity("ore").OnHand, + RefinedMetalsStockpile = refinedMetals.OnHand, + RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond, + RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds, + HullpartsStockpile = hullparts.OnHand, + HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond, + HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds, + ClaytronicsStockpile = claytronics.OnHand, + ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond, + ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds, + WaterStockpile = water.OnHand, + WaterProductionRate = water.ProjectedProductionRatePerSecond, + WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds, }; } @@ -156,13 +196,52 @@ internal sealed class CommanderPlanningService c.FactionId == commander.FactionId && string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); + var enemyTarget = SelectEnemyTarget(world, ship); + var tradeRoute = SelectTradeRoute(world, ship.FactionId); + var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId); + if (commander.ActiveBehavior is not null) + { + commander.ActiveBehavior.AreaSystemId = enemyTarget?.SystemId; + commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId; + if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal)) + { + commander.ActiveBehavior.ItemId = tradeRoute?.ItemId; + commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId; + commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId; + } + else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null) + { + commander.ActiveBehavior.StationId = expansionProject.SupportStationId; + commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId; + commander.ActiveBehavior.ModuleId = expansionProject.ModuleId; + commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId; + } + else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal)) + { + commander.ActiveBehavior.TargetEntityId = null; + commander.ActiveBehavior.ModuleId = null; + commander.ActiveBehavior.AreaSystemId = ship.SystemId; + } + } + return new ShipPlanningState { ShipKind = ship.Definition.Kind, HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"), FactionWantsOre = true, + FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false, FactionWantsExpansion = factionCommander?.ActiveDirectives .Contains("expand-territory", StringComparer.Ordinal) ?? false, + FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false) + ? false + : !world.Stations.Any(station => + string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal) + && station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)), + TargetEnemySystemId = enemyTarget?.SystemId, + TargetEnemyEntityId = enemyTarget?.EntityId, + TradeItemId = tradeRoute?.ItemId, + TradeSourceStationId = tradeRoute?.SourceStationId, + TradeDestinationStationId = tradeRoute?.DestinationStationId, }; } @@ -170,11 +249,15 @@ internal sealed class CommanderPlanningService { var actions = new List>(); + actions.Add(new PlanWarIndustryAction()); + actions.Add(new PlanCommoditySupplyAction("water")); + foreach (var (shipId, def) in world.ShipDefinitions) { actions.Add(new OrderShipProductionAction(def.Kind, shipId)); } + actions.Add(new LaunchExterminationCampaignAction()); actions.Add(new ExpandToSystemAction()); return actions; } @@ -184,4 +267,137 @@ internal sealed class CommanderPlanningService c.FactionId == factionId && string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)) ?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false; + + private static void TryQueueFactionExpansionProject( + SimulationWorld world, + CommanderRuntime commander, + IndustryExpansionProject? project) + { + if (project is null) + { + return; + } + + FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); + commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); + } + + private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject( + SimulationWorld world, + FactionPlanningState state, + string factionId) + { + if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f) + { + return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals"); + } + + if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f) + { + return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts"); + } + + if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f) + { + return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics"); + } + + if (!state.HasShipFactory) + { + return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId); + } + + return null; + } + + private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship) + { + var hostileShip = world.Ships + .Where(candidate => + candidate.Health > 0f && + !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) + .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .Select(candidate => (candidate.Id, candidate.SystemId)) + .FirstOrDefault(); + + if (hostileShip != default) + { + return hostileShip; + } + + var hostileStation = world.Stations + .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) + .OrderBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .Select(candidate => (candidate.Id, candidate.SystemId)) + .FirstOrDefault(); + + return hostileStation == default ? null : hostileStation; + } + + private static (string ItemId, string SourceStationId, string DestinationStationId)? SelectTradeRoute(SimulationWorld world, string factionId) + { + var stationsById = world.Stations + .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) + .ToDictionary(station => station.Id, StringComparer.Ordinal); + + foreach (var demand in world.MarketOrders + .Where(order => + string.Equals(order.FactionId, factionId, StringComparison.Ordinal) + && order.Kind == MarketOrderKinds.Buy + && order.RemainingAmount > 0.01f + && order.StationId is not null) + .OrderByDescending(order => order.Valuation)) + { + if (!stationsById.TryGetValue(demand.StationId!, out var destination)) + { + continue; + } + + if (!CanStationAcceptAdditionalItem(world, destination, demand.ItemId)) + { + continue; + } + + var source = stationsById.Values + .Where(station => + station.Id != destination.Id + && GetInventoryAmount(station.Inventory, demand.ItemId) > 1f) + .OrderByDescending(station => GetInventoryAmount(station.Inventory, demand.ItemId)) + .FirstOrDefault(); + if (source is not null) + { + return (demand.ItemId, source.Id, destination.Id); + } + } + + return null; + } + + private static bool CanStationAcceptAdditionalItem(SimulationWorld world, StationRuntime station, string itemId) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var definition)) + { + return false; + } + + var requiredModule = GetStorageRequirement(definition.CargoKind); + if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) + { + return false; + } + + var capacity = GetStationStorageCapacity(station, definition.CargoKind); + if (capacity <= 0.01f) + { + return false; + } + + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var item) && string.Equals(item.CargoKind, definition.CargoKind, StringComparison.Ordinal)) + .Sum(entry => entry.Value); + + return used <= capacity - 1f; + } } diff --git a/apps/backend/Factions/AI/FactionController.cs b/apps/backend/Factions/AI/FactionController.cs index 17b9358..e9cd1cc 100644 --- a/apps/backend/Factions/AI/FactionController.cs +++ b/apps/backend/Factions/AI/FactionController.cs @@ -12,20 +12,89 @@ public sealed class FactionPlanningState public int ControlledSystemCount { get; set; } public int TargetSystemCount { get; set; } public bool HasShipFactory { get; set; } + public int EnemyFactionCount { get; set; } + public int EnemyShipCount { get; set; } + public int EnemyStationCount { get; set; } public float OreStockpile { get; set; } public float RefinedMetalsStockpile { get; set; } + public float RefinedMetalsProductionRate { get; set; } + public float RefinedMetalsShortageHorizonSeconds { get; set; } + public float HullpartsStockpile { get; set; } + public float HullpartsProductionRate { get; set; } + public float HullpartsShortageHorizonSeconds { get; set; } + public float ClaytronicsStockpile { get; set; } + public float ClaytronicsProductionRate { get; set; } + public float ClaytronicsShortageHorizonSeconds { get; set; } + public float WaterStockpile { get; set; } + public float WaterProductionRate { get; set; } + public float WaterShortageHorizonSeconds { get; set; } + + public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f; + public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f; + public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f; + public bool HasWaterProduction => WaterProductionRate > 0.01f; + + public bool HasWarIndustrySupplyChain => + HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction; 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)); + return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount)); } } // ─── Goals ───────────────────────────────────────────────────────────────────── +public sealed class EnsureWarIndustryGoal : GoapGoal +{ + public override string Name => "ensure-war-industry"; + + public override bool IsSatisfied(FactionPlanningState state) => + state.EnemyFactionCount <= 0 || (state.HasWarIndustrySupplyChain && state.HasShipFactory); + + public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) + { + if (state.EnemyFactionCount <= 0) + { + return 0f; + } + + var missingStages = + (state.HasRefinedMetalsProduction ? 0 : 1) + + (state.HasHullpartsProduction ? 0 : 1) + + (state.HasClaytronicsProduction ? 0 : 1) + + (state.HasShipFactory ? 0 : 1); + + return missingStages <= 0 ? 0f : 125f + (missingStages * 18f); + } +} + +public sealed class EnsureWaterSecurityGoal : GoapGoal +{ + public override string Name => "ensure-water-security"; + + public override bool IsSatisfied(FactionPlanningState state) => + state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f; + + public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) + { + if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f) + { + return 0f; + } + + if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds)) + { + return state.HasWaterProduction ? 0f : 85f; + } + + return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f; + } +} + public sealed class EnsureWarFleetGoal : GoapGoal { public override string Name => "ensure-war-fleet"; @@ -40,6 +109,24 @@ public sealed class EnsureWarFleetGoal : GoapGoal } } +public sealed class ExterminateRivalGoal : GoapGoal +{ + public override string Name => "exterminate-rival"; + + public override bool IsSatisfied(FactionPlanningState state) => + state.EnemyFactionCount <= 0 || (state.EnemyShipCount <= 0 && state.EnemyStationCount <= 0); + + public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander) + { + if (state.EnemyFactionCount <= 0) + { + return 0f; + } + + return 140f + (state.EnemyStationCount * 25f) + (state.EnemyShipCount * 6f); + } +} + public sealed class ExpandTerritoryGoal : GoapGoal { public override string Name => "expand-territory"; @@ -100,7 +187,8 @@ public sealed class OrderShipProductionAction : GoapAction public override string Name => $"order-{shipId}-production"; public override float Cost => 1f; - public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory; + public override bool CheckPreconditions(FactionPlanningState state) => + state.HasShipFactory && state.HasWarIndustrySupplyChain; public override FactionPlanningState ApplyEffects(FactionPlanningState state) { @@ -121,13 +209,86 @@ public sealed class OrderShipProductionAction : GoapAction } } +public sealed class PlanWarIndustryAction : GoapAction +{ + public override string Name => "plan-war-industry"; + public override float Cost => 2f; + + public override bool CheckPreconditions(FactionPlanningState state) => + state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory); + + public override FactionPlanningState ApplyEffects(FactionPlanningState state) + { + state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f); + state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f); + state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f); + state.HasShipFactory = true; + return state; + } + + public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) + { + commander.ActiveDirectives.Add("bootstrap-war-industry"); + + if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project) + { + return; + } + + FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); + commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); + } +} + +public sealed class PlanCommoditySupplyAction : GoapAction +{ + private readonly string commodityId; + + public PlanCommoditySupplyAction(string commodityId) + { + this.commodityId = commodityId; + } + + public override string Name => $"plan-{commodityId}-supply"; + public override float Cost => 2f; + + public override bool CheckPreconditions(FactionPlanningState state) => + commodityId switch + { + "water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f, + _ => false, + }; + + public override FactionPlanningState ApplyEffects(FactionPlanningState state) + { + if (string.Equals(commodityId, "water", StringComparison.Ordinal)) + { + state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f); + state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f); + } + + return state; + } + + public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) + { + if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project) + { + return; + } + + FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project); + commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}"); + } +} + 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; + state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain; public override FactionPlanningState ApplyEffects(FactionPlanningState state) { @@ -140,3 +301,28 @@ public sealed class ExpandToSystemAction : GoapAction commander.ActiveDirectives.Add("expand-territory"); } } + +public sealed class LaunchExterminationCampaignAction : GoapAction +{ + public override string Name => "launch-extermination-campaign"; + public override float Cost => 1f; + + public override bool CheckPreconditions(FactionPlanningState state) => + state.EnemyFactionCount > 0 + && state.HasShipFactory + && state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2); + + public override FactionPlanningState ApplyEffects(FactionPlanningState state) + { + state.EnemyShipCount = 0; + state.EnemyStationCount = 0; + state.EnemyFactionCount = 0; + return state; + } + + public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander) + { + commander.ActiveDirectives.Add("attack-rival"); + commander.ActiveDirectives.Add("produce-military-ships"); + } +} diff --git a/apps/backend/Factions/Contracts/Factions.cs b/apps/backend/Factions/Contracts/Factions.cs index c8d84ea..a582eca 100644 --- a/apps/backend/Factions/Contracts/Factions.cs +++ b/apps/backend/Factions/Contracts/Factions.cs @@ -9,7 +9,15 @@ public sealed record FactionGoapStateSnapshot( int TargetSystemCount, bool HasShipFactory, float OreStockpile, - float RefinedMetalsStockpile); + float RefinedMetalsStockpile, + float RefinedMetalsProductionRate, + float HullpartsStockpile, + float HullpartsProductionRate, + float ClaytronicsStockpile, + float ClaytronicsProductionRate, + float WaterStockpile, + float WaterProductionRate, + float WaterShortageHorizonSeconds); public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority); diff --git a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs index 9b1a695..222b914 100644 --- a/apps/backend/Factions/Runtime/FactionRuntimeModels.cs +++ b/apps/backend/Factions/Runtime/FactionRuntimeModels.cs @@ -45,6 +45,8 @@ public sealed class CommanderBehaviorRuntime { public required string Kind { get; set; } public string? Phase { get; set; } + public string? TargetEntityId { get; set; } + public string? ItemId { get; set; } public string? NodeId { get; set; } public string? StationId { get; set; } public string? ModuleId { get; set; } diff --git a/apps/backend/GlobalUsings.cs b/apps/backend/GlobalUsings.cs index f15d054..48ddc05 100644 --- a/apps/backend/GlobalUsings.cs +++ b/apps/backend/GlobalUsings.cs @@ -4,6 +4,7 @@ global using SpaceGame.Api.Economy.Runtime; global using SpaceGame.Api.Factions.AI; global using SpaceGame.Api.Factions.Contracts; global using SpaceGame.Api.Factions.Runtime; +global using SpaceGame.Api.Industry.Planning; global using SpaceGame.Api.Shared.AI; global using SpaceGame.Api.Shared.Contracts; global using SpaceGame.Api.Shared.Runtime; diff --git a/apps/backend/Industry/Planning/FactionEconomySnapshot.cs b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs new file mode 100644 index 0000000..175184d --- /dev/null +++ b/apps/backend/Industry/Planning/FactionEconomySnapshot.cs @@ -0,0 +1,228 @@ +namespace SpaceGame.Api.Industry.Planning; + +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; + +internal sealed class FactionEconomySnapshot +{ + private readonly Dictionary commodities = new(StringComparer.Ordinal); + + internal IReadOnlyDictionary Commodities => commodities; + + internal FactionCommoditySnapshot GetCommodity(string itemId) + { + if (!commodities.TryGetValue(itemId, out var commodity)) + { + commodity = new FactionCommoditySnapshot(itemId); + commodities[itemId] = commodity; + } + + return commodity; + } +} + +internal sealed class FactionCommoditySnapshot +{ + internal FactionCommoditySnapshot(string itemId) + { + ItemId = itemId; + } + + internal string ItemId { get; } + internal float OnHand { get; set; } + internal float ReservedForConstruction { get; set; } + internal float BuyBacklog { get; set; } + internal float SellBacklog { get; set; } + internal float Inbound { get; set; } + internal float ProductionRatePerSecond { get; set; } + internal float CommittedProductionRatePerSecond { get; set; } + internal float ConsumptionRatePerSecond { get; set; } + + internal float AvailableStock => MathF.Max(0f, OnHand + Inbound - ReservedForConstruction); + internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond; + internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond; + internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond; + + internal float ShortageHorizonSeconds + { + get + { + if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f) + { + return float.PositiveInfinity; + } + + if (NetRatePerSecond >= -0.01f) + { + return float.PositiveInfinity; + } + + return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond); + } + } + + internal float ProjectedShortageHorizonSeconds + { + get + { + if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f) + { + return float.PositiveInfinity; + } + + if (ProjectedNetRatePerSecond >= -0.01f) + { + return float.PositiveInfinity; + } + + return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond); + } + } + + internal float PressureScore => + MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound)) + + MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f; + + internal float ProjectedPressureScore => + MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound)) + + MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f; +} + +internal static class FactionEconomyAnalyzer +{ + internal static FactionEconomySnapshot Build(SimulationWorld world, string factionId) + { + var snapshot = new FactionEconomySnapshot(); + + foreach (var station in world.Stations.Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))) + { + foreach (var (itemId, amount) in station.Inventory) + { + snapshot.GetCommodity(itemId).OnHand += amount; + } + + foreach (var laneKey in StationSimulationService.GetStationProductionLanes(world, station)) + { + var recipe = StationSimulationService.SelectProductionRecipe(world, station, laneKey); + if (recipe is null) + { + continue; + } + + var throughput = StationSimulationService.GetStationProductionThroughput(world, station, recipe); + var cyclesPerSecond = (station.WorkforceEffectiveRatio * throughput) / MathF.Max(recipe.Duration, 0.01f); + if (cyclesPerSecond <= 0.0001f) + { + continue; + } + + foreach (var input in recipe.Inputs) + { + snapshot.GetCommodity(input.ItemId).ConsumptionRatePerSecond += input.Amount * cyclesPerSecond; + } + + foreach (var output in recipe.Outputs) + { + snapshot.GetCommodity(output.ItemId).ProductionRatePerSecond += output.Amount * cyclesPerSecond; + } + } + } + + foreach (var order in world.MarketOrders.Where(order => + string.Equals(order.FactionId, factionId, StringComparison.Ordinal) + && order.State != MarketOrderStateKinds.Cancelled + && order.RemainingAmount > 0.01f)) + { + var commodity = snapshot.GetCommodity(order.ItemId); + if (string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal)) + { + commodity.BuyBacklog += order.RemainingAmount; + } + else if (string.Equals(order.Kind, MarketOrderKinds.Sell, StringComparison.Ordinal)) + { + commodity.SellBacklog += order.RemainingAmount; + } + } + + foreach (var site in world.ConstructionSites.Where(site => + string.Equals(site.FactionId, factionId, StringComparison.Ordinal) + && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed)) + { + ApplyCommittedProduction(world, snapshot, site); + + foreach (var required in site.RequiredItems) + { + var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)); + if (remaining > 0.01f) + { + snapshot.GetCommodity(required.Key).ReservedForConstruction += remaining; + } + } + } + + return snapshot; + } + + private static void ApplyCommittedProduction( + SimulationWorld world, + FactionEconomySnapshot snapshot, + ConstructionSiteRuntime site) + { + if (string.IsNullOrWhiteSpace(site.BlueprintId) + || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) + { + return; + } + + var recipeOutputs = world.Recipes.Values + .Where(candidate => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, candidate), site.BlueprintId, StringComparison.Ordinal)) + .SelectMany(candidate => candidate.Outputs) + .GroupBy(output => output.ItemId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal); + if (recipeOutputs.Count == 0) + { + return; + } + + var materialFraction = 0f; + var materialTerms = 0; + foreach (var required in site.RequiredItems) + { + materialTerms += 1; + materialFraction += required.Value <= 0.01f + ? 1f + : Math.Clamp(GetConstructionDeliveredAmount(world, site, required.Key) / required.Value, 0f, 1f); + } + + materialFraction = materialTerms == 0 ? 1f : materialFraction / materialTerms; + + var buildFraction = recipe.Duration <= 0.01f + ? 0f + : Math.Clamp(site.Progress / recipe.Duration, 0f, 1f); + var readiness = site.State switch + { + ConstructionSiteStateKinds.Active => 0.3f, + ConstructionSiteStateKinds.Planned => 0.15f, + _ => 0f, + }; + + readiness += materialFraction * 0.45f; + readiness += buildFraction * 0.25f; + + if (site.AssignedConstructorShipIds.Count > 0) + { + readiness += 0.1f; + } + + readiness = Math.Clamp(readiness, 0f, 1f); + if (readiness <= 0.01f) + { + return; + } + + var cyclesPerSecond = readiness / MathF.Max(recipe.Duration, 0.01f); + foreach (var (productItemId, amount) in recipeOutputs) + { + snapshot.GetCommodity(productItemId).CommittedProductionRatePerSecond += amount * cyclesPerSecond; + } + } +} diff --git a/apps/backend/Industry/Planning/FactionIndustryPlanner.cs b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs new file mode 100644 index 0000000..0c0cad7 --- /dev/null +++ b/apps/backend/Industry/Planning/FactionIndustryPlanner.cs @@ -0,0 +1,492 @@ +namespace SpaceGame.Api.Industry.Planning; + +using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; + +internal static class FactionIndustryPlanner +{ + internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId) + { + if (HasActiveExpansionProject(world, factionId)) + { + return null; + } + + var bottleneckCommodity = ResolveBottleneckCommodity(world, factionId, commodityId); + var moduleId = world.ProductionGraph.GetPrimaryProducerModule(bottleneckCommodity); + if (moduleId is null) + { + return null; + } + + var targetCelestial = SelectFoundationCelestial(world, factionId, bottleneckCommodity); + if (targetCelestial is null) + { + return null; + } + + var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); + if (supportStation is null) + { + return null; + } + + return new IndustryExpansionProject( + bottleneckCommodity, + moduleId, + targetCelestial.SystemId, + targetCelestial.Id, + supportStation.Id); + } + + internal static IndustryExpansionProject? AnalyzeShipyardNeed(SimulationWorld world, string factionId) + { + if (HasActiveExpansionProject(world, factionId)) + { + return null; + } + + const string shipyardModuleId = "module_gen_build_l_01"; + if (world.Stations.Any(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && station.InstalledModules.Contains(shipyardModuleId, StringComparer.Ordinal))) + { + return null; + } + + if (!world.ModuleRecipes.TryGetValue(shipyardModuleId, out var shipyardRecipe)) + { + return null; + } + + var bottleneckCommodity = shipyardRecipe.Inputs + .Select(input => ResolveBottleneckCommodity(world, factionId, input.ItemId)) + .Distinct(StringComparer.Ordinal) + .Select(itemId => new + { + ItemId = itemId, + HasProducer = FactionHasProducerForCommodity(world, factionId, itemId), + Pressure = GetCommodityPressure(world, factionId, itemId), + Stockpile = GetCommodityStockpile(world, factionId, itemId), + }) + .Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f) + .OrderByDescending(entry => !entry.HasProducer ? 1 : 0) + .ThenByDescending(entry => entry.Pressure) + .ThenBy(entry => entry.Stockpile) + .Select(entry => entry.ItemId) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(bottleneckCommodity)) + { + return AnalyzeCommodityNeed(world, factionId, bottleneckCommodity); + } + + return CreateShipyardFoundationProject(world, factionId); + } + + internal static IndustryExpansionProject? CreateShipyardFoundationProject(SimulationWorld world, string factionId) + { + const string shipyardModuleId = "module_gen_build_l_01"; + if (HasActiveExpansionProject(world, factionId)) + { + return null; + } + + var targetCelestial = SelectLogisticsFoundationCelestial(world, factionId); + if (targetCelestial is null) + { + return null; + } + + var supportStation = SelectSupportStation(world, factionId, shipyardModuleId, targetCelestial.SystemId); + if (supportStation is null) + { + return null; + } + + return new IndustryExpansionProject( + "shipyard", + shipyardModuleId, + targetCelestial.SystemId, + targetCelestial.Id, + supportStation.Id); + } + + internal static IndustryExpansionProject? AnalyzeExpansionNeed(SimulationWorld world, string factionId) + { + if (HasActiveExpansionProject(world, factionId)) + { + return null; + } + + var bootstrapCommodity = SelectBootstrapCommodity(world, factionId); + if (bootstrapCommodity is not null) + { + var bootstrapModuleId = world.ProductionGraph.GetPrimaryProducerModule(bootstrapCommodity); + if (bootstrapModuleId is null) + { + return null; + } + + var bootstrapCelestial = SelectFoundationCelestial(world, factionId, bootstrapCommodity); + if (bootstrapCelestial is null) + { + return null; + } + + var bootstrapSupportStation = SelectSupportStation(world, factionId, bootstrapModuleId, bootstrapCelestial.SystemId); + if (bootstrapSupportStation is null) + { + return null; + } + + return new IndustryExpansionProject( + bootstrapCommodity, + bootstrapModuleId, + bootstrapCelestial.SystemId, + bootstrapCelestial.Id, + bootstrapSupportStation.Id); + } + + var commodityId = SelectCommodityToExpand(world, factionId); + if (commodityId is null) + { + return null; + } + + var moduleId = world.ProductionGraph.GetPrimaryProducerModule(commodityId); + if (moduleId is null) + { + return null; + } + + var targetCelestial = SelectFoundationCelestial(world, factionId, commodityId); + if (targetCelestial is null) + { + return null; + } + + var supportStation = SelectSupportStation(world, factionId, moduleId, targetCelestial.SystemId); + if (supportStation is null) + { + return null; + } + + return new IndustryExpansionProject( + commodityId, + moduleId, + targetCelestial.SystemId, + targetCelestial.Id, + supportStation.Id); + } + + internal static IndustryExpansionProject? GetActiveExpansionProject(SimulationWorld world, string factionId) + { + var site = world.ConstructionSites.FirstOrDefault(candidate => + string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(candidate.TargetKind, "station-foundation", StringComparison.Ordinal) + && candidate.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); + if (site is null || site.BlueprintId is null) + { + return null; + } + + var supportStationId = world.Stations + .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) + .OrderByDescending(station => station.SystemId == site.SystemId ? 1 : 0) + .ThenByDescending(station => station.Inventory.Values.Sum()) + .Select(station => station.Id) + .FirstOrDefault(); + if (supportStationId is null) + { + return null; + } + + return new IndustryExpansionProject( + site.TargetDefinitionId, + site.BlueprintId, + site.SystemId, + site.CelestialId, + supportStationId, + site.Id); + } + + internal static void EnsureExpansionSite(SimulationWorld world, string factionId, IndustryExpansionProject project) + { + if (project.SiteId is not null) + { + return; + } + + var nowUtc = DateTimeOffset.UtcNow; + var claimId = $"claim-{factionId}-{project.CelestialId}"; + if (world.Claims.All(candidate => candidate.Id != claimId)) + { + world.Claims.Add(new ClaimRuntime + { + Id = claimId, + FactionId = factionId, + SystemId = project.SystemId, + CelestialId = project.CelestialId, + PlacedAtUtc = nowUtc, + ActivatesAtUtc = nowUtc.AddSeconds(8), + State = ClaimStateKinds.Activating, + Health = 100f, + }); + } + + if (!world.ModuleRecipes.TryGetValue(project.ModuleId, out var recipe)) + { + return; + } + + var siteId = $"site-{factionId}-{project.CelestialId}"; + if (world.ConstructionSites.Any(candidate => candidate.Id == siteId)) + { + return; + } + + var site = new ConstructionSiteRuntime + { + Id = siteId, + FactionId = factionId, + SystemId = project.SystemId, + CelestialId = project.CelestialId, + TargetKind = "station-foundation", + TargetDefinitionId = project.CommodityId, + BlueprintId = project.ModuleId, + ClaimId = claimId, + StationId = null, + State = ConstructionSiteStateKinds.Planned, + }; + + foreach (var input in recipe.Inputs) + { + site.RequiredItems[input.ItemId] = input.Amount; + site.DeliveredItems[input.ItemId] = 0f; + var orderId = $"market-order-{site.Id}-{input.ItemId}"; + site.MarketOrderIds.Add(orderId); + world.MarketOrders.Add(new MarketOrderRuntime + { + Id = orderId, + FactionId = factionId, + StationId = project.SupportStationId, + ConstructionSiteId = site.Id, + Kind = MarketOrderKinds.Buy, + ItemId = input.ItemId, + Amount = input.Amount, + RemainingAmount = input.Amount, + Valuation = 1.1f, + State = MarketOrderStateKinds.Open, + }); + } + + if (world.Stations.FirstOrDefault(station => station.Id == project.SupportStationId) is { } supportStation) + { + foreach (var orderId in site.MarketOrderIds) + { + supportStation.MarketOrderIds.Add(orderId); + } + } + + world.ConstructionSites.Add(site); + } + + private static string? SelectCommodityToExpand(SimulationWorld world, string factionId) + { + var demandByItem = world.MarketOrders + .Where(order => + string.Equals(order.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(order.Kind, MarketOrderKinds.Buy, StringComparison.Ordinal) + && order.RemainingAmount > 0.01f) + .GroupBy(order => order.ItemId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal); + + if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships")) + { + demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f; + demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f; + } + + return demandByItem + .Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value)) + .Where(entry => entry.ItemId is not null) + .GroupBy(entry => entry.ItemId!, StringComparer.Ordinal) + .Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score))) + .OrderByDescending(entry => entry.Score) + .Select(entry => entry.ItemId) + .FirstOrDefault(); + } + + private static string? SelectBootstrapCommodity(SimulationWorld world, string factionId) + { + if (!FactionHasProducerForCommodity(world, factionId, "refinedmetals")) + { + return "refinedmetals"; + } + + return null; + } + + private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId) + { + var visited = new HashSet(StringComparer.Ordinal); + return ResolveBottleneckCommodity(world, factionId, itemId, visited); + } + + private static string ResolveBottleneckCommodity(SimulationWorld world, string factionId, string itemId, HashSet visited) + { + if (!visited.Add(itemId)) + { + return itemId; + } + + var producers = world.ProductionGraph.GetProcessesForOutput(itemId); + if (producers.Count == 0) + { + return itemId; + } + + var hasFactionProducer = producers + .SelectMany(process => process.RequiredModuleIds) + .Any(moduleId => world.Stations.Any(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && station.InstalledModules.Contains(moduleId, StringComparer.Ordinal))); + if (!hasFactionProducer) + { + return itemId; + } + + var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId) + .Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId)) + .Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId))) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Stockpile) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(weakestUnproducedInput.ItemId) + && (weakestUnproducedInput.Score > 0.01f || weakestUnproducedInput.Stockpile < 120f)) + { + return ResolveBottleneckCommodity(world, factionId, weakestUnproducedInput.ItemId, visited); + } + + var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId) + .Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId))) + .OrderByDescending(entry => entry.Score) + .FirstOrDefault(); + + return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f + ? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited) + : itemId; + } + + internal static bool FactionHasProducerForCommodity(SimulationWorld world, string factionId, string itemId) + => FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedProductionRatePerSecond > 0.01f; + + internal static IReadOnlyCollection ResolveRootResourceItems(SimulationWorld world, string commodityId) + { + var frontier = new Queue(); + var resources = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(StringComparer.Ordinal); + frontier.Enqueue(commodityId); + + while (frontier.Count > 0) + { + var current = frontier.Dequeue(); + if (!visited.Add(current)) + { + continue; + } + + var inputs = world.ProductionGraph.GetImmediateInputs(current); + if (inputs.Count == 0) + { + resources.Add(current); + continue; + } + + foreach (var input in inputs) + { + frontier.Enqueue(input); + } + } + + return resources.Count > 0 ? resources : [commodityId]; + } + + private static bool HasActiveExpansionProject(SimulationWorld world, string factionId) => + world.ConstructionSites.Any(site => + string.Equals(site.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal) + && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); + + private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId) + { + return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore; + } + + private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) => + FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock; + + private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId) + { + var resourceItems = ResolveRootResourceItems(world, commodityId); + return world.Celestials + .Where(celestial => + celestial.Kind == SpatialNodeKind.LagrangePoint + && celestial.OccupyingStructureId is null + && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) + .OrderByDescending(celestial => ScoreCelestial(world, factionId, celestial, resourceItems)) + .FirstOrDefault(); + } + + private static CelestialRuntime? SelectLogisticsFoundationCelestial(SimulationWorld world, string factionId) + { + return world.Celestials + .Where(celestial => + celestial.Kind == SpatialNodeKind.LagrangePoint + && celestial.OccupyingStructureId is null + && world.Claims.All(claim => claim.CelestialId != celestial.Id || claim.State == ClaimStateKinds.Destroyed)) + .OrderByDescending(celestial => world.Stations.Count(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal))) + .ThenByDescending(celestial => world.Stations + .Where(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)) + .Sum(station => station.Inventory.Values.Sum())) + .FirstOrDefault(); + } + + private static float ScoreCelestial(SimulationWorld world, string factionId, CelestialRuntime celestial, IReadOnlyCollection resourceItems) + { + var resourceScore = world.Nodes + .Where(node => node.SystemId == celestial.SystemId && resourceItems.Contains(node.ItemId, StringComparer.Ordinal)) + .Sum(node => node.OreRemaining); + var factionPresence = world.Stations.Count(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(station.SystemId, celestial.SystemId, StringComparison.Ordinal)); + return resourceScore + (factionPresence * 5_000f); + } + + private static StationRuntime? SelectSupportStation(SimulationWorld world, string factionId, string moduleId, string targetSystemId) + { + var constructionInputs = world.ModuleRecipes.TryGetValue(moduleId, out var recipe) + ? recipe.Inputs.Select(input => input.ItemId).ToList() + : []; + + return world.Stations + .Where(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) + .OrderByDescending(station => station.SystemId == targetSystemId ? 1 : 0) + .ThenByDescending(station => constructionInputs.Sum(inputId => GetInventoryAmount(station.Inventory, inputId))) + .ThenByDescending(station => station.Inventory.Values.Sum()) + .FirstOrDefault(); + } +} + +internal sealed record IndustryExpansionProject( + string CommodityId, + string ModuleId, + string SystemId, + string CelestialId, + string SupportStationId, + string? SiteId = null); diff --git a/apps/backend/Industry/Planning/ProductionGraph.cs b/apps/backend/Industry/Planning/ProductionGraph.cs new file mode 100644 index 0000000..adb352e --- /dev/null +++ b/apps/backend/Industry/Planning/ProductionGraph.cs @@ -0,0 +1,53 @@ +namespace SpaceGame.Api.Industry.Planning; + +public sealed class ProductionGraph +{ + public required IReadOnlyDictionary Commodities { get; init; } + public required IReadOnlyDictionary Processes { get; init; } + public required IReadOnlyDictionary> ProcessesByOutputId { get; init; } + public required IReadOnlyDictionary> ProcessesByInputId { get; init; } + public required IReadOnlyDictionary> OutputsByModuleId { get; init; } + + public IReadOnlyList GetProcessesForOutput(string itemId) => + ProcessesByOutputId.TryGetValue(itemId, out var processes) ? processes : []; + + public IReadOnlyList GetProcessesForInput(string itemId) => + ProcessesByInputId.TryGetValue(itemId, out var processes) ? processes : []; + + public string? GetPrimaryProducerModule(string itemId) => + GetProcessesForOutput(itemId) + .SelectMany(process => process.RequiredModuleIds) + .FirstOrDefault(); + + public string? GetPrimaryOutputForModule(string moduleId) => + OutputsByModuleId.TryGetValue(moduleId, out var outputs) + ? outputs.FirstOrDefault() + : null; + + public IReadOnlyList GetImmediateInputs(string itemId) => + GetProcessesForOutput(itemId) + .SelectMany(process => process.Inputs.Keys) + .Distinct(StringComparer.Ordinal) + .ToList(); +} + +public sealed class ProductionCommodityNode +{ + public required string ItemId { get; init; } + public required string Name { get; init; } + public required string Group { get; init; } + public required string CargoKind { get; init; } + public List ProducerProcessIds { get; } = []; + public List ConsumerProcessIds { get; } = []; +} + +public sealed class ProductionProcessNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string FacilityCategory { get; init; } + public required IReadOnlyList RequiredModuleIds { get; init; } + public required IReadOnlyDictionary Inputs { get; init; } + public required IReadOnlyDictionary Outputs { get; init; } + public required bool ProducesShip { get; init; } +} diff --git a/apps/backend/Industry/Planning/ProductionGraphBuilder.cs b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs new file mode 100644 index 0000000..4aecbbc --- /dev/null +++ b/apps/backend/Industry/Planning/ProductionGraphBuilder.cs @@ -0,0 +1,105 @@ +namespace SpaceGame.Api.Industry.Planning; + +internal static class ProductionGraphBuilder +{ + internal static ProductionGraph Build( + IReadOnlyCollection items, + IReadOnlyCollection recipes, + IReadOnlyCollection modules) + { + var commodities = items.ToDictionary( + item => item.Id, + item => new ProductionCommodityNode + { + ItemId = item.Id, + Name = item.Name, + Group = item.Group, + CargoKind = item.CargoKind, + }, + StringComparer.Ordinal); + + var processes = new Dictionary(StringComparer.Ordinal); + var processesByOutputId = new Dictionary>(StringComparer.Ordinal); + var processesByInputId = new Dictionary>(StringComparer.Ordinal); + var outputsByModuleId = new Dictionary>(StringComparer.Ordinal); + + foreach (var recipe in recipes) + { + var outputs = recipe.Outputs + .GroupBy(output => output.ItemId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(output => output.Amount), StringComparer.Ordinal); + var inputs = recipe.Inputs + .GroupBy(input => input.ItemId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(input => input.Amount), StringComparer.Ordinal); + var process = new ProductionProcessNode + { + Id = recipe.Id, + Label = recipe.Label, + FacilityCategory = recipe.FacilityCategory, + RequiredModuleIds = recipe.RequiredModules.ToList(), + Inputs = inputs, + Outputs = outputs, + ProducesShip = recipe.ShipOutputId is not null, + }; + + processes[process.Id] = process; + + foreach (var output in outputs.Keys) + { + if (!commodities.ContainsKey(output)) + { + continue; + } + + commodities[output].ProducerProcessIds.Add(process.Id); + if (!processesByOutputId.TryGetValue(output, out var outputProcesses)) + { + outputProcesses = []; + processesByOutputId[output] = outputProcesses; + } + + outputProcesses.Add(process); + } + + foreach (var input in inputs.Keys) + { + if (!commodities.ContainsKey(input)) + { + continue; + } + + commodities[input].ConsumerProcessIds.Add(process.Id); + if (!processesByInputId.TryGetValue(input, out var inputProcesses)) + { + inputProcesses = []; + processesByInputId[input] = inputProcesses; + } + + inputProcesses.Add(process); + } + } + + foreach (var module in modules) + { + if (!outputsByModuleId.TryGetValue(module.Id, out var outputs)) + { + outputs = new HashSet(StringComparer.Ordinal); + outputsByModuleId[module.Id] = outputs; + } + + foreach (var product in module.Products) + { + outputs.Add(product); + } + } + + return new ProductionGraph + { + Commodities = commodities, + Processes = processes, + ProcessesByOutputId = processesByOutputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value, StringComparer.Ordinal), + ProcessesByInputId = processesByInputId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value, StringComparer.Ordinal), + OutputsByModuleId = outputsByModuleId.ToDictionary(entry => entry.Key, entry => (IReadOnlyList)entry.Value.OrderBy(value => value, StringComparer.Ordinal).ToList(), StringComparer.Ordinal), + }; + } +} diff --git a/apps/backend/Shared/Runtime/SimulationKinds.cs b/apps/backend/Shared/Runtime/SimulationKinds.cs index ea1cb72..dcf3827 100644 --- a/apps/backend/Shared/Runtime/SimulationKinds.cs +++ b/apps/backend/Shared/Runtime/SimulationKinds.cs @@ -48,6 +48,7 @@ public enum ShipState DeliveringConstruction, Blocked, Undocking, + EngagingTarget, } public enum ControllerTaskKind @@ -60,6 +61,7 @@ public enum ControllerTaskKind Unload, DeliverConstruction, BuildConstructionSite, + AttackTarget, ConstructModule, Undock, @@ -210,6 +212,7 @@ public static class SimulationEnumMappings ShipState.DeliveringConstruction => "delivering-construction", ShipState.Blocked => "blocked", ShipState.Undocking => "undocking", + ShipState.EngagingTarget => "engaging-target", _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), }; @@ -223,6 +226,7 @@ public static class SimulationEnumMappings ControllerTaskKind.Unload => "unload", ControllerTaskKind.DeliverConstruction => "deliver-construction", ControllerTaskKind.BuildConstructionSite => "build-construction-site", + ControllerTaskKind.AttackTarget => "attack-target", ControllerTaskKind.ConstructModule => "construct-module", ControllerTaskKind.Undock => "undock", diff --git a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs b/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs index 24d25e4..19dbf8d 100644 --- a/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs +++ b/apps/backend/Ships/AI/ShipBehaviorStateMachine.cs @@ -19,6 +19,8 @@ internal sealed class ShipBehaviorStateMachine { idleState, new PatrolShipBehaviorState(), + new AttackTargetShipBehaviorState(), + new TradeHaulShipBehaviorState(), new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"), new ConstructStationShipBehaviorState(), }; diff --git a/apps/backend/Ships/AI/ShipBehaviorStates.cs b/apps/backend/Ships/AI/ShipBehaviorStates.cs index 9249525..45c03ef 100644 --- a/apps/backend/Ships/AI/ShipBehaviorStates.cs +++ b/apps/backend/Ships/AI/ShipBehaviorStates.cs @@ -93,6 +93,9 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState case ("dock", "docked"): ship.DefaultBehavior.Phase = "unload"; break; + case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = "undock"; + break; case ("undock", "undocked"): ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.NodeId = null; @@ -126,3 +129,58 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState } } } + +internal sealed class AttackTargetShipBehaviorState : IShipBehaviorState +{ + public string Kind => "attack-target"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + engine.PlanAttackTarget(ship, world); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + if (controllerEvent is "target-destroyed" or "target-lost") + { + ship.DefaultBehavior.TargetEntityId = null; + } + } +} + +internal sealed class TradeHaulShipBehaviorState : IShipBehaviorState +{ + public string Kind => "trade-haul"; + + public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) => + engine.PlanTransportHaul(ship, world); + + public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) + { + switch (ship.DefaultBehavior.Phase, controllerEvent) + { + case ("travel-to-source", "arrived"): + ship.DefaultBehavior.Phase = "dock-source"; + break; + case ("dock-source", "docked"): + ship.DefaultBehavior.Phase = "load"; + break; + case ("load", "loaded"): + ship.DefaultBehavior.Phase = "undock-from-source"; + break; + case ("undock-from-source", "undocked"): + ship.DefaultBehavior.Phase = "travel-to-destination"; + break; + case ("travel-to-destination", "arrived"): + ship.DefaultBehavior.Phase = "dock-destination"; + break; + case ("dock-destination", "docked"): + ship.DefaultBehavior.Phase = "unload"; + break; + case ("unload", "unloaded"): + ship.DefaultBehavior.Phase = "undock-from-destination"; + break; + case ("undock-from-destination", "undocked"): + ship.DefaultBehavior.Phase = "travel-to-source"; + break; + } + } +} diff --git a/apps/backend/Ships/AI/ShipController.cs b/apps/backend/Ships/AI/ShipController.cs index 0dd3511..06c5aa9 100644 --- a/apps/backend/Ships/AI/ShipController.cs +++ b/apps/backend/Ships/AI/ShipController.cs @@ -9,6 +9,13 @@ public sealed class ShipPlanningState public bool HasMiningCapability { get; set; } public bool FactionWantsOre { get; set; } public bool FactionWantsExpansion { get; set; } + public bool FactionWantsCombat { get; set; } + public bool FactionNeedsShipyard { get; set; } + public string? TargetEnemySystemId { get; set; } + public string? TargetEnemyEntityId { get; set; } + public string? TradeItemId { get; set; } + public string? TradeSourceStationId { get; set; } + public string? TradeDestinationStationId { get; set; } public string? CurrentObjective { get; set; } public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone(); @@ -102,13 +109,45 @@ public sealed class SetPatrolObjectiveAction : GoapAction } } +public sealed class SetAttackObjectiveAction : GoapAction +{ + public override string Name => "set-attack-objective"; + public override float Cost => 1f; + + public override bool CheckPreconditions(ShipPlanningState state) => + string.Equals(state.ShipKind, "military", StringComparison.Ordinal) + && state.FactionWantsCombat + && state.TargetEnemyEntityId is not null; + + public override ShipPlanningState ApplyEffects(ShipPlanningState state) + { + state.CurrentObjective = "attack-target"; + 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) + { + return; + } + + ship.DefaultBehavior.Kind = "attack-target"; + ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior?.AreaSystemId ?? ship.DefaultBehavior.AreaSystemId; + ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior?.TargetEntityId ?? ship.DefaultBehavior.TargetEntityId; + ship.DefaultBehavior.Phase = null; + } +} + 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; + string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) + && (state.FactionWantsExpansion || state.FactionNeedsShipyard); public override ShipPlanningState ApplyEffects(ShipPlanningState state) { @@ -129,6 +168,39 @@ public sealed class SetConstructionObjectiveAction : GoapAction +{ + public override string Name => "set-trade-objective"; + public override float Cost => 1f; + + public override bool CheckPreconditions(ShipPlanningState state) => + string.Equals(state.ShipKind, "transport", StringComparison.Ordinal) + && state.TradeItemId is not null + && state.TradeSourceStationId is not null + && state.TradeDestinationStationId is not null; + + public override ShipPlanningState ApplyEffects(ShipPlanningState state) + { + state.CurrentObjective = "trade-haul"; + 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 || commander.ActiveBehavior is null) + { + return; + } + + ship.DefaultBehavior.Kind = "trade-haul"; + ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId; + ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId; + ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId; + ship.DefaultBehavior.Phase ??= "travel-to-source"; + } +} + public sealed class SetIdleObjectiveAction : GoapAction { public override string Name => "set-idle-objective"; diff --git a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs index 8c58d34..2cd2f84 100644 --- a/apps/backend/Ships/Runtime/ShipRuntimeModels.cs +++ b/apps/backend/Ships/Runtime/ShipRuntimeModels.cs @@ -42,6 +42,8 @@ public sealed class DefaultBehaviorRuntime { public required string Kind { get; set; } public string? AreaSystemId { get; set; } + public string? TargetEntityId { get; set; } + public string? ItemId { get; set; } public string? StationId { get; set; } public string? RefineryId { get; set; } public string? NodeId { get; set; } diff --git a/apps/backend/Ships/Simulation/ShipControlService.cs b/apps/backend/Ships/Simulation/ShipControlService.cs index a947a1b..551a74e 100644 --- a/apps/backend/Ships/Simulation/ShipControlService.cs +++ b/apps/backend/Ships/Simulation/ShipControlService.cs @@ -18,6 +18,8 @@ internal sealed class ShipControlService { ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind; ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId; + ship.DefaultBehavior.TargetEntityId = commander.ActiveBehavior.TargetEntityId; + ship.DefaultBehavior.ItemId = commander.ActiveBehavior.ItemId; ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId; ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId; ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase; @@ -61,6 +63,8 @@ internal sealed class ShipControlService commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind }; commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind; commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId; + commander.ActiveBehavior.TargetEntityId = ship.DefaultBehavior.TargetEntityId; + commander.ActiveBehavior.ItemId = ship.DefaultBehavior.ItemId; commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId; commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId; commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase; @@ -140,24 +144,193 @@ internal sealed class ShipControlService SyncCommanderTask(commander, ship.ControllerTask); } + internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) + { + var behavior = ship.DefaultBehavior; + var target = ResolveAttackTarget(ship, world); + if (target is null) + { + behavior.Kind = "idle"; + behavior.TargetEntityId = null; + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + behavior.TargetEntityId = target.EntityId; + behavior.AreaSystemId = target.SystemId; + ship.ControllerTask = new ControllerTaskRuntime + { + Kind = ControllerTaskKind.AttackTarget, + TargetEntityId = target.EntityId, + TargetSystemId = target.SystemId, + TargetPosition = target.Position, + Threshold = target.AttackRange, + }; + } + + internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) + { + var behavior = ship.DefaultBehavior; + var sourceStation = behavior.StationId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); + var destinationStation = behavior.TargetEntityId is null ? null : world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId); + if (sourceStation is null || destinationStation is null || string.IsNullOrWhiteSpace(behavior.ItemId)) + { + behavior.Kind = "idle"; + ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); + return; + } + + var carryingCargo = GetShipCargoAmount(ship) > 0.01f; + if (carryingCargo) + { + if (ship.DockedStationId == destinationStation.Id) + { + behavior.Phase = "unload"; + } + else if (ship.DockedStationId is not null) + { + behavior.Phase = "undock-from-source"; + } + else if (behavior.Phase is not "travel-to-destination" and not "dock-destination" and not "unload") + { + behavior.Phase = "travel-to-destination"; + } + } + else + { + if (ship.DockedStationId == sourceStation.Id) + { + var available = GetInventoryAmount(sourceStation.Inventory, behavior.ItemId); + behavior.Phase = available > 0.01f ? "load" : "wait-source"; + } + else if (ship.DockedStationId == destinationStation.Id) + { + behavior.Phase = "undock-from-destination"; + } + else if (behavior.Phase is not "travel-to-source" and not "dock-source" and not "load") + { + behavior.Phase = "travel-to-source"; + } + } + + ship.ControllerTask = behavior.Phase switch + { + "travel-to-source" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Travel, + TargetEntityId = sourceStation.Id, + TargetSystemId = sourceStation.SystemId, + TargetPosition = sourceStation.Position, + Threshold = sourceStation.Radius + 8f, + ItemId = behavior.ItemId, + }, + "dock-source" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Dock, + TargetEntityId = sourceStation.Id, + TargetSystemId = sourceStation.SystemId, + TargetPosition = sourceStation.Position, + Threshold = sourceStation.Radius + 4f, + ItemId = behavior.ItemId, + }, + "load" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Load, + TargetEntityId = sourceStation.Id, + TargetSystemId = sourceStation.SystemId, + TargetPosition = sourceStation.Position, + Threshold = 0f, + ItemId = behavior.ItemId, + }, + "undock-from-source" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Undock, + TargetEntityId = sourceStation.Id, + TargetSystemId = sourceStation.SystemId, + TargetPosition = new Vector3(sourceStation.Position.X + world.Balance.UndockDistance, sourceStation.Position.Y, sourceStation.Position.Z), + Threshold = 8f, + ItemId = behavior.ItemId, + }, + "travel-to-destination" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Travel, + TargetEntityId = destinationStation.Id, + TargetSystemId = destinationStation.SystemId, + TargetPosition = destinationStation.Position, + Threshold = destinationStation.Radius + 8f, + ItemId = behavior.ItemId, + }, + "dock-destination" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Dock, + TargetEntityId = destinationStation.Id, + TargetSystemId = destinationStation.SystemId, + TargetPosition = destinationStation.Position, + Threshold = destinationStation.Radius + 4f, + ItemId = behavior.ItemId, + }, + "unload" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Unload, + TargetEntityId = destinationStation.Id, + TargetSystemId = destinationStation.SystemId, + TargetPosition = destinationStation.Position, + Threshold = 0f, + ItemId = behavior.ItemId, + }, + "undock-from-destination" => new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Undock, + TargetEntityId = destinationStation.Id, + TargetSystemId = destinationStation.SystemId, + TargetPosition = new Vector3(destinationStation.Position.X + world.Balance.UndockDistance, destinationStation.Position.Y, destinationStation.Position.Z), + Threshold = 8f, + ItemId = behavior.ItemId, + }, + _ => CreateIdleTask(world.Balance.ArrivalThreshold), + }; + } + internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule) { var behavior = ship.DefaultBehavior; - var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId); + var cargoItemId = ship.Inventory.Keys.FirstOrDefault(); + var targetResourceItemId = SelectMiningResourceItem(world, ship, cargoItemId ?? behavior.ItemId ?? resourceItemId); + if (!string.Equals(behavior.ItemId, targetResourceItemId, StringComparison.Ordinal)) + { + behavior.ItemId = targetResourceItemId; + behavior.NodeId = null; + } + + var refinery = SelectBestBuyStation(world, ship, targetResourceItemId, behavior.StationId); behavior.StationId = refinery?.Id; var node = behavior.NodeId is null ? world.Nodes .Where(candidate => - (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && - candidate.ItemId == resourceItemId && - candidate.OreRemaining > 0.01f) - .OrderByDescending(candidate => candidate.OreRemaining) + candidate.ItemId == targetResourceItemId && + candidate.OreRemaining > 0.01f && + CanShipMineItem(world, ship, candidate.ItemId)) + .OrderByDescending(candidate => candidate.SystemId == behavior.AreaSystemId ? 1 : 0) + .ThenByDescending(candidate => candidate.OreRemaining) .FirstOrDefault() - : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f); + : world.Nodes.FirstOrDefault(candidate => + candidate.Id == behavior.NodeId && + string.Equals(candidate.ItemId, targetResourceItemId, StringComparison.Ordinal) && + candidate.OreRemaining > 0.01f); + + if (node is not null) + { + behavior.AreaSystemId = node.SystemId; + } if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule)) { - behavior.Kind = "idle"; + if (refinery is null && GetShipCargoAmount(ship) > 0.01f) + { + ship.Inventory.Clear(); + } + + behavior.Phase = null; ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold); return; } @@ -253,6 +426,55 @@ internal sealed class ShipControlService } } + private static string SelectMiningResourceItem(SimulationWorld world, ShipRuntime ship, string fallbackItemId) + { + var candidateItemId = world.MarketOrders + .Where(order => + string.Equals(order.FactionId, ship.FactionId, StringComparison.Ordinal) + && order.Kind == MarketOrderKinds.Buy + && order.RemainingAmount > 0.01f) + .SelectMany(order => FactionIndustryPlanner.ResolveRootResourceItems(world, order.ItemId) + .Select(itemId => new + { + ItemId = itemId, + Score = order.RemainingAmount * MathF.Max(0.25f, order.Valuation), + })) + .Where(entry => + CanShipMineItem(world, ship, entry.ItemId) + && world.Nodes.Any(node => string.Equals(node.ItemId, entry.ItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) + .GroupBy(entry => entry.ItemId, StringComparer.Ordinal) + .Select(group => new + { + ItemId = group.Key, + Score = group.Sum(entry => entry.Score) + (string.Equals(group.Key, ship.DefaultBehavior.ItemId, StringComparison.Ordinal) ? 15f : 0f), + }) + .OrderByDescending(entry => entry.Score) + .Select(entry => entry.ItemId) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(candidateItemId)) + { + return candidateItemId; + } + + if (CanShipMineItem(world, ship, fallbackItemId) + && world.Nodes.Any(node => string.Equals(node.ItemId, fallbackItemId, StringComparison.Ordinal) && node.OreRemaining > 0.01f)) + { + return fallbackItemId; + } + + return world.Nodes + .Where(node => node.OreRemaining > 0.01f && CanShipMineItem(world, ship, node.ItemId)) + .OrderByDescending(node => node.OreRemaining) + .Select(node => node.ItemId) + .FirstOrDefault() ?? fallbackItemId; + } + + private static bool CanShipMineItem(SimulationWorld world, ShipRuntime ship, string itemId) => + world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition) + && string.Equals(itemDefinition.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal) + && HasShipCapabilities(ship.Definition, "mining"); + internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId) { var preferred = preferredStationId is null @@ -267,7 +489,8 @@ internal sealed class ShipControlService order.ItemId == itemId && order.RemainingAmount > 0.01f) .Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId))) - .Where(entry => entry.Station is not null) + .Where(entry => entry.Station is not null && string.Equals(entry.Station.FactionId, ship.FactionId, StringComparison.Ordinal)) + .Where(entry => CanStationReceiveItem(world, entry.Station!, itemId)) .OrderByDescending(entry => { var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f; @@ -275,7 +498,18 @@ internal sealed class ShipControlService }) .FirstOrDefault(); - return bestOrder.Station ?? preferred; + return bestOrder.Station ?? (preferred is not null && CanStationReceiveItem(world, preferred, itemId) ? preferred : null); + } + + private static bool CanStationReceiveItem(SimulationWorld world, StationRuntime station, string itemId) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + return false; + } + + var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); + return requiredModule is null || station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal); } private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) => @@ -320,7 +554,9 @@ internal sealed class ShipControlService { var behavior = ship.DefaultBehavior; var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId); - var site = station is null ? null : GetConstructionSiteForStation(world, station.Id); + var site = !string.IsNullOrWhiteSpace(behavior.TargetEntityId) + ? world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == behavior.TargetEntityId) + : station is null ? null : GetConstructionSiteForStation(world, station.Id); if (station is null) { behavior.Kind = "idle"; @@ -328,6 +564,13 @@ internal sealed class ShipControlService return; } + if (site is null && !string.IsNullOrWhiteSpace(behavior.TargetEntityId)) + { + behavior.TargetEntityId = null; + behavior.ModuleId = null; + site = GetConstructionSiteForStation(world, station.Id); + } + var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); behavior.ModuleId = moduleId; if (moduleId is null) @@ -347,13 +590,17 @@ internal sealed class ShipControlService ship.DockedStationId = null; ship.AssignedDockingPadIndex = null; - ship.Position = GetConstructionHoldPosition(station, ship.Id); + ship.Position = ResolveConstructionHoldPosition(ship, station, site, world); ship.TargetPosition = ship.Position; } - var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id); - var isAtConstructionHold = ship.SystemId == station.SystemId - && ship.Position.DistanceTo(constructionHoldPosition) <= 10f; + var constructionHoldPosition = ResolveConstructionHoldPosition(ship, station, site, world); + var targetSystemId = site?.SystemId ?? station.SystemId; + var targetCelestialId = site?.CelestialId ?? station.CelestialId; + var isAtTargetCelestial = !string.IsNullOrWhiteSpace(targetCelestialId) + && string.Equals(ship.SpatialState.CurrentCelestialId, targetCelestialId, StringComparison.Ordinal); + var isAtConstructionHold = ship.SystemId == targetSystemId + && (ship.Position.DistanceTo(constructionHoldPosition) <= 10f || isAtTargetCelestial); if (isAtConstructionHold) { @@ -390,7 +637,7 @@ internal sealed class ShipControlService { Kind = ControllerTaskKind.ConstructModule, TargetEntityId = station.Id, - TargetSystemId = station.SystemId, + TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; @@ -400,7 +647,7 @@ internal sealed class ShipControlService { Kind = ControllerTaskKind.DeliverConstruction, TargetEntityId = site?.Id, - TargetSystemId = station.SystemId, + TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; @@ -410,7 +657,7 @@ internal sealed class ShipControlService { Kind = ControllerTaskKind.BuildConstructionSite, TargetEntityId = site?.Id, - TargetSystemId = station.SystemId, + TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; @@ -419,8 +666,8 @@ internal sealed class ShipControlService ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, + TargetEntityId = site?.Id ?? station.Id, + TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 0f, }; @@ -429,8 +676,8 @@ internal sealed class ShipControlService ship.ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Travel, - TargetEntityId = station.Id, - TargetSystemId = station.SystemId, + TargetEntityId = site?.Id ?? station.Id, + TargetSystemId = targetSystemId, TargetPosition = constructionHoldPosition, Threshold = 10f, }; @@ -539,6 +786,7 @@ internal sealed class ShipControlService "unload" => ControllerTaskKind.Unload, "deliver-construction" => ControllerTaskKind.DeliverConstruction, "build-construction-site" => ControllerTaskKind.BuildConstructionSite, + "attack-target" => ControllerTaskKind.AttackTarget, "construct-module" => ControllerTaskKind.ConstructModule, "undock" => ControllerTaskKind.Undock, @@ -563,4 +811,62 @@ internal sealed class ShipControlService Threshold = task.Threshold, }; } + + private static Vector3 ResolveConstructionHoldPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) + { + if (site is null || site.StationId is not null) + { + return GetConstructionHoldPosition(station, ship.Id); + } + + var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + var anchorPosition = anchor?.Position ?? station.Position; + return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + } + + private static AttackTargetCandidate? ResolveAttackTarget(ShipRuntime ship, SimulationWorld world) + { + if (!string.IsNullOrWhiteSpace(ship.DefaultBehavior.TargetEntityId)) + { + var direct = ResolveAttackTargetCandidate(world, ship.DefaultBehavior.TargetEntityId!); + if (direct is not null && !string.Equals(direct.FactionId, ship.FactionId, StringComparison.Ordinal)) + { + return direct; + } + } + + var hostileShips = world.Ships + .Where(candidate => candidate.Health > 0f && !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) + .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, 26f)) + .ToList(); + + var hostileStations = world.Stations + .Where(candidate => !string.Equals(candidate.FactionId, ship.FactionId, StringComparison.Ordinal)) + .Select(candidate => new AttackTargetCandidate(candidate.Id, candidate.FactionId, candidate.SystemId, candidate.Position, candidate.Radius + 18f)) + .ToList(); + + var preferredSystemId = ship.DefaultBehavior.AreaSystemId; + return hostileShips + .Concat(hostileStations) + .OrderBy(candidate => preferredSystemId is null || candidate.SystemId == preferredSystemId ? 0 : 1) + .ThenBy(candidate => candidate.SystemId == ship.SystemId ? 0 : 1) + .ThenBy(candidate => candidate.Position.DistanceTo(ship.Position)) + .FirstOrDefault(); + } + + private static AttackTargetCandidate? ResolveAttackTargetCandidate(SimulationWorld world, string entityId) + { + var ship = world.Ships.FirstOrDefault(candidate => candidate.Id == entityId && candidate.Health > 0f); + if (ship is not null) + { + return new AttackTargetCandidate(ship.Id, ship.FactionId, ship.SystemId, ship.Position, 26f); + } + + var station = world.Stations.FirstOrDefault(candidate => candidate.Id == entityId); + return station is null + ? null + : new AttackTargetCandidate(station.Id, station.FactionId, station.SystemId, station.Position, station.Radius + 18f); + } + + private sealed record AttackTargetCandidate(string EntityId, string FactionId, string SystemId, Vector3 Position, float AttackRange); } diff --git a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs b/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs index d920867..1880559 100644 --- a/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs +++ b/apps/backend/Ships/Simulation/ShipTaskExecutionService.Actions.cs @@ -166,10 +166,12 @@ internal sealed partial class ShipTaskExecutionService BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship)); var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId); + var transferredAny = false; foreach (var (itemId, amount) in ship.Inventory.ToList()) { var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds); var accepted = TryAddStationInventory(world, station, itemId, moved); + transferredAny |= accepted > 0.01f; RemoveInventory(ship.Inventory, itemId, accepted); if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal)) { @@ -178,6 +180,12 @@ internal sealed partial class ShipTaskExecutionService } } + if (!transferredAny && GetShipCargoAmount(ship) > 0.01f && HasShipCapabilities(ship.Definition, "mining")) + { + ship.Inventory.Clear(); + return "unloaded"; + } + return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none"; } @@ -239,7 +247,7 @@ internal sealed partial class ShipTaskExecutionService return "none"; } - var supportPosition = ResolveShipSupportPosition(ship, station); + var supportPosition = ResolveShipSupportPosition(ship, station, null, world); if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) { ship.State = ShipState.LocalFlight; @@ -296,7 +304,7 @@ internal sealed partial class ShipTaskExecutionService return "none"; } - var supportPosition = ResolveShipSupportPosition(ship, station); + var supportPosition = ResolveShipSupportPosition(ship, station, site, world); if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) { ship.State = ShipState.LocalFlight; @@ -313,6 +321,28 @@ internal sealed partial class ShipTaskExecutionService if (site.StationId is not null) { + foreach (var required in site.RequiredItems) + { + var delivered = GetInventoryAmount(site.DeliveredItems, required.Key); + var remaining = MathF.Max(0f, required.Value - delivered); + if (remaining <= 0.01f) + { + continue; + } + + var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds); + moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key)); + if (moved <= 0.01f) + { + continue; + } + + RemoveInventory(station.Inventory, required.Key, moved); + AddInventory(site.Inventory, required.Key, moved); + AddInventory(site.DeliveredItems, required.Key, moved); + return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; + } + return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none"; } @@ -359,7 +389,7 @@ internal sealed partial class ShipTaskExecutionService return "none"; } - var supportPosition = ResolveShipSupportPosition(ship, station); + var supportPosition = ResolveShipSupportPosition(ship, station, site, world); if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold)) { ship.State = ShipState.LocalFlight; @@ -386,8 +416,16 @@ internal sealed partial class ShipTaskExecutionService return "none"; } - AddStationModule(world, station, site.BlueprintId); - PrepareNextConstructionSiteStep(world, station, site); + if (site.StationId is null) + { + CompleteStationFoundation(world, station, site); + } + else + { + AddStationModule(world, station, site.BlueprintId); + PrepareNextConstructionSiteStep(world, station, site); + } + return "site-constructed"; } @@ -398,10 +436,21 @@ internal sealed partial class ShipTaskExecutionService ? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) : null; - private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) => - ship.DockedStationId is not null - ? GetShipDockedPosition(ship, station) - : GetConstructionHoldPosition(station, ship.Id); + private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station, ConstructionSiteRuntime? site, SimulationWorld world) + { + if (ship.DockedStationId is not null) + { + return GetShipDockedPosition(ship, station); + } + + if (site?.StationId is null && site is not null) + { + var anchorPosition = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId)?.Position ?? station.Position; + return GetResourceHoldPosition(anchorPosition, ship.Id, 78f); + } + + return GetConstructionHoldPosition(station, ship.Id); + } private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) => ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f); @@ -453,4 +502,88 @@ internal sealed partial class ShipTaskExecutionService internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) => site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key))); + + private static void CompleteStationFoundation(SimulationWorld world, StationRuntime supportStation, ConstructionSiteRuntime site) + { + var anchor = world.Celestials.FirstOrDefault(candidate => candidate.Id == site.CelestialId); + if (anchor is null || site.BlueprintId is null) + { + site.State = ConstructionSiteStateKinds.Destroyed; + return; + } + + var station = new StationRuntime + { + Id = $"station-{world.Stations.Count + 1}", + SystemId = site.SystemId, + Label = BuildFoundedStationLabel(site.TargetDefinitionId), + Category = "station", + Objective = DetermineFoundationObjective(site.TargetDefinitionId), + Color = world.Factions.FirstOrDefault(candidate => candidate.Id == site.FactionId)?.Color ?? supportStation.Color, + Position = anchor.Position, + FactionId = site.FactionId, + CelestialId = site.CelestialId, + Health = 600f, + MaxHealth = 600f, + }; + + foreach (var moduleId in GetFoundationModules(world, site.BlueprintId)) + { + AddStationModule(world, station, moduleId); + } + + world.Stations.Add(station); + StationLifecycleService.EnsureStationCommander(world, station); + anchor.OccupyingStructureId = station.Id; + site.StationId = station.Id; + PrepareNextConstructionSiteStep(world, station, site); + } + + private static IReadOnlyList GetFoundationModules(SimulationWorld world, string primaryModuleId) + { + var modules = new List { "module_arg_dock_m_01_lowtech" }; + foreach (var itemId in world.ProductionGraph.OutputsByModuleId.GetValueOrDefault(primaryModuleId, [])) + { + if (world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + var storageModule = GetStorageRequirement(itemDefinition.CargoKind); + if (storageModule is not null && !modules.Contains(storageModule, StringComparer.Ordinal)) + { + modules.Add(storageModule); + } + else if (storageModule is null && !modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + } + } + + if (!modules.Contains("module_arg_stor_container_m_01", StringComparer.Ordinal)) + { + modules.Add("module_arg_stor_container_m_01"); + } + + if (!string.Equals(primaryModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)) + { + modules.Add("module_gen_prod_energycells_01"); + } + + modules.Add(primaryModuleId); + return modules.Distinct(StringComparer.Ordinal).ToList(); + } + + private static string DetermineFoundationObjective(string commodityId) => + commodityId switch + { + "energycells" => "power", + "water" => "water", + "refinedmetals" => "refinery", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + "shipyard" => "shipyard", + _ => "general", + }; + + private static string BuildFoundedStationLabel(string commodityId) => + $"{char.ToUpperInvariant(commodityId[0])}{commodityId[1..]} Foundry"; } diff --git a/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs b/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs index c530048..606c822 100644 --- a/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs +++ b/apps/backend/Ships/Simulation/ShipTaskExecutionService.cs @@ -6,6 +6,10 @@ namespace SpaceGame.Api.Ships.Simulation; internal sealed partial class ShipTaskExecutionService { private const float WarpEngageDistanceKilometers = 250_000f; + private const float FrigateDps = 7f; + private const float DestroyerDps = 12f; + private const float CruiserDps = 18f; + private const float CapitalDps = 26f; private static float GetLocalTravelSpeed(ShipRuntime ship) => SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed); @@ -30,6 +34,7 @@ internal sealed partial class ShipTaskExecutionService ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds), ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds), ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds), + ControllerTaskKind.AttackTarget => UpdateAttackTarget(ship, world, deltaSeconds), ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds), ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds), @@ -47,6 +52,11 @@ internal sealed partial class ShipTaskExecutionService private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; + return UpdateTravel(ship, world, deltaSeconds, task); + } + + private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ControllerTaskRuntime task) + { if (task.TargetPosition is null || task.TargetSystemId is null) { ship.State = ShipState.Idle; @@ -94,6 +104,66 @@ internal sealed partial class ShipTaskExecutionService return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold); } + private string UpdateAttackTarget(ShipRuntime ship, SimulationWorld world, float deltaSeconds) + { + var task = ship.ControllerTask; + if (string.IsNullOrWhiteSpace(task.TargetEntityId)) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return "target-lost"; + } + + var hostileShip = world.Ships.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId && candidate.Health > 0f); + var hostileStation = hostileShip is null + ? world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId) + : null; + + if ((hostileShip is not null && string.Equals(hostileShip.FactionId, ship.FactionId, StringComparison.Ordinal)) + || (hostileStation is not null && string.Equals(hostileStation.FactionId, ship.FactionId, StringComparison.Ordinal))) + { + return "target-lost"; + } + + if (hostileShip is null && hostileStation is null) + { + ship.State = ShipState.Idle; + ship.TargetPosition = ship.Position; + return "target-lost"; + } + + var targetSystemId = hostileShip?.SystemId ?? hostileStation!.SystemId; + var targetPosition = hostileShip?.Position ?? hostileStation!.Position; + var attackRange = hostileShip is null ? hostileStation!.Radius + 18f : 26f; + var attackTask = new ControllerTaskRuntime + { + Kind = ControllerTaskKind.Travel, + TargetEntityId = task.TargetEntityId, + TargetSystemId = targetSystemId, + TargetPosition = targetPosition, + Threshold = attackRange, + }; + + if (ship.SystemId != targetSystemId || ship.Position.DistanceTo(targetPosition) > attackRange) + { + return UpdateTravel(ship, world, deltaSeconds, attackTask); + } + + ship.State = ShipState.EngagingTarget; + ship.TargetPosition = targetPosition; + ship.Position = ship.Position.MoveToward(targetPosition, MathF.Min(GetLocalTravelSpeed(ship) * deltaSeconds, 8f)); + var damage = GetShipDamagePerSecond(ship) * deltaSeconds; + + if (hostileShip is not null) + { + hostileShip.Health = MathF.Max(0f, hostileShip.Health - damage); + return hostileShip.Health <= 0f ? "target-destroyed" : "none"; + } + + hostileStation!.Health = MathF.Max(0f, hostileStation.Health - damage * 0.6f); + return hostileStation.Health <= 0f ? "target-destroyed" : "none"; + } + private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task) { if (!string.IsNullOrWhiteSpace(task.TargetEntityId)) @@ -309,4 +379,14 @@ internal sealed partial class ShipTaskExecutionService ship.State = ShipState.Arriving; return "none"; } + + private static float GetShipDamagePerSecond(ShipRuntime ship) => + ship.Definition.Class switch + { + "frigate" => FrigateDps, + "destroyer" => DestroyerDps, + "cruiser" => CruiserDps, + "capital" => CapitalDps, + _ => 4f, + }; } diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index 0b4f092..b074326 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -39,8 +39,13 @@ public sealed class SimulationEngine _commanderPlanning.UpdateCommanders(this, world, deltaSeconds, events); _stationLifecycle.UpdateStations(world, deltaSeconds, events); - foreach (var ship in world.Ships) + foreach (var ship in world.Ships.ToList()) { + if (ship.Health <= 0f) + { + continue; + } + var previousPosition = ship.Position; var previousState = ship.State; var previousBehavior = ship.DefaultBehavior.Kind; @@ -58,6 +63,7 @@ public sealed class SimulationEngine } _orbitalStateUpdater.SyncSpatialState(world); + CleanupDestroyedEntities(world, events); world.GeneratedAtUtc = nowUtc; return _projection.BuildDelta(world, sequence, events); @@ -75,6 +81,60 @@ public sealed class SimulationEngine internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) => _shipControl.PlanStationConstruction(ship, world); + internal void PlanAttackTarget(ShipRuntime ship, SimulationWorld world) => + _shipControl.PlanAttackTarget(ship, world); + + internal void PlanTransportHaul(ShipRuntime ship, SimulationWorld world) => + _shipControl.PlanTransportHaul(ship, world); + internal static float GetShipCargoAmount(ShipRuntime ship) => SimulationRuntimeSupport.GetShipCargoAmount(ship); + + private static void CleanupDestroyedEntities(SimulationWorld world, ICollection events) + { + foreach (var ship in world.Ships.Where(candidate => candidate.Health <= 0f).ToList()) + { + world.Ships.Remove(ship); + if (ship.DockedStationId is not null && world.Stations.FirstOrDefault(station => station.Id == ship.DockedStationId) is { } dockedStation) + { + dockedStation.DockedShipIds.Remove(ship.Id); + dockedStation.DockingPadAssignments.Remove(ship.AssignedDockingPadIndex ?? -1); + } + + if (world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId) is { } faction) + { + faction.ShipsLost += 1; + } + + if (ship.CommanderId is not null && world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId) is { } commander) + { + commander.IsAlive = false; + } + + events.Add(new SimulationEventRecord("ship", ship.Id, "destroyed", $"{ship.Definition.Label} was destroyed.", DateTimeOffset.UtcNow)); + } + + foreach (var station in world.Stations.Where(candidate => candidate.Health <= 0f).ToList()) + { + world.Stations.Remove(station); + + if (station.CelestialId is not null && world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId) is { } celestial) + { + celestial.OccupyingStructureId = null; + } + + foreach (var claim in world.Claims.Where(candidate => candidate.CelestialId == station.CelestialId)) + { + claim.Health = 0f; + claim.State = ClaimStateKinds.Destroyed; + } + + foreach (var site in world.ConstructionSites.Where(candidate => candidate.StationId == station.Id)) + { + site.State = ConstructionSiteStateKinds.Destroyed; + } + + events.Add(new SimulationEventRecord("station", station.Id, "destroyed", $"{station.Label} was destroyed.", DateTimeOffset.UtcNow)); + } + } } diff --git a/apps/backend/Simulation/Core/SimulationProjectionService.cs b/apps/backend/Simulation/Core/SimulationProjectionService.cs index 728c562..b6da8c3 100644 --- a/apps/backend/Simulation/Core/SimulationProjectionService.cs +++ b/apps/backend/Simulation/Core/SimulationProjectionService.cs @@ -101,6 +101,7 @@ internal sealed class SimulationProjectionService station.Id, station.Label, station.Category, + station.Objective, station.SystemId, station.LocalPosition, station.CelestialId, @@ -544,6 +545,7 @@ internal sealed class SimulationProjectionService station.Id, station.Label, station.Category, + station.Objective, station.SystemId, ToDto(station.Position), station.CelestialId, @@ -770,8 +772,16 @@ internal sealed class SimulationProjectionService ps.ControlledSystemCount, ps.TargetSystemCount, ps.HasShipFactory, - ps.OreStockpile, - ps.RefinedMetalsStockpile); + NormalizeFiniteFloat(ps.OreStockpile), + NormalizeFiniteFloat(ps.RefinedMetalsStockpile), + NormalizeFiniteFloat(ps.RefinedMetalsProductionRate), + NormalizeFiniteFloat(ps.HullpartsStockpile), + NormalizeFiniteFloat(ps.HullpartsProductionRate), + NormalizeFiniteFloat(ps.ClaytronicsStockpile), + NormalizeFiniteFloat(ps.ClaytronicsProductionRate), + NormalizeFiniteFloat(ps.WaterStockpile), + NormalizeFiniteFloat(ps.WaterProductionRate), + NormalizeFiniteFloat(ps.WaterShortageHorizonSeconds)); } if (commander?.LastGoalPriorities is { } prios) @@ -811,4 +821,7 @@ internal sealed class SimulationProjectionService state.Transit.Progress)); private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); + + private static float NormalizeFiniteFloat(float value) => + float.IsFinite(value) ? value : -1f; } diff --git a/apps/backend/Stations/Contracts/Infrastructure.cs b/apps/backend/Stations/Contracts/Infrastructure.cs index 028f29a..7b5d7e9 100644 --- a/apps/backend/Stations/Contracts/Infrastructure.cs +++ b/apps/backend/Stations/Contracts/Infrastructure.cs @@ -8,6 +8,7 @@ public sealed record StationSnapshot( string Id, string Label, string Category, + string Objective, string SystemId, Vector3Dto LocalPosition, string? CelestialId, @@ -32,6 +33,7 @@ public sealed record StationDelta( string Id, string Label, string Category, + string Objective, string SystemId, Vector3Dto LocalPosition, string? CelestialId, diff --git a/apps/backend/Stations/Runtime/StationRuntimeModels.cs b/apps/backend/Stations/Runtime/StationRuntimeModels.cs index 56d29c8..62728ff 100644 --- a/apps/backend/Stations/Runtime/StationRuntimeModels.cs +++ b/apps/backend/Stations/Runtime/StationRuntimeModels.cs @@ -6,6 +6,7 @@ public sealed class StationRuntime public required string SystemId { get; init; } public required string Label { get; set; } public string Category { get; set; } = "station"; + public string Objective { get; set; } = "general"; public string Color { get; set; } = "#8df0d2"; public required Vector3 Position { get; set; } public float Radius { get; set; } = 24f; @@ -14,6 +15,8 @@ public sealed class StationRuntime public string? CommanderId { get; set; } public string? PolicySetId { get; set; } public List Modules { get; } = []; + public float Health { get; set; } = 600f; + public float MaxHealth { get; set; } = 600f; public IEnumerable InstalledModules => Modules.Select((module) => module.ModuleId); public Dictionary Inventory { get; } = new(StringComparer.Ordinal); public Dictionary ProductionLaneTimers { get; } = new(StringComparer.Ordinal); diff --git a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs index d47df8a..e08959d 100644 --- a/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs +++ b/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs @@ -105,16 +105,66 @@ internal sealed class InfrastructureSimulationService internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) { - // Expand storage before it becomes a bottleneck - const float StorageExpansionThreshold = 0.85f; - var storageExpansionCandidates = new[] + var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); + return GetModuleExpansionCandidates(world, station, economy) + .Where(candidate => world.ModuleRecipes.ContainsKey(candidate.ModuleId)) + .OrderByDescending(candidate => candidate.Score) + .Select(candidate => candidate.ModuleId) + .FirstOrDefault(); + } + + private static IReadOnlyList GetModuleExpansionCandidates( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy) + { + var role = StationSimulationService.DetermineStationRole(station); + var candidates = new Dictionary(StringComparer.Ordinal); + var constructionDemandByItem = GetOutstandingConstructionDemand(world, station.FactionId); + var objectiveCommodity = GetObjectiveCommodityId(role); + var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodity); + + if (objectiveModuleId is not null && world.ModuleRecipes.TryGetValue(objectiveModuleId, out var objectiveRecipe)) + { + AddOrRaiseCandidate(candidates, objectiveModuleId, ScoreObjectiveModule(world, station, economy, constructionDemandByItem, objectiveCommodity, objectiveModuleId)); + + foreach (var storageModuleId in GetRequiredStorageModules(world, objectiveRecipe)) + { + if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal)) + { + AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: true)); + } + } + + if (objectiveCommodity is not null + && world.ProductionGraph.GetImmediateInputs(objectiveCommodity).Contains("energycells", StringComparer.Ordinal)) + { + AddOrRaiseCandidate(candidates, "module_gen_prod_energycells_01", ScoreEnergySupportModule(world, station, economy, constructionDemandByItem)); + } + } + + AddOrRaiseCandidate(candidates, "module_arg_dock_m_01_lowtech", ScoreDockModule(station)); + AddOrRaiseCandidate(candidates, "module_arg_hab_m_01", ScoreHabitationModule(station, world, economy)); + + foreach (var storageModuleId in GetStoragePressureCandidates(world, station)) + { + AddOrRaiseCandidate(candidates, storageModuleId, ScoreStorageModule(world, station, storageModuleId, objectiveModuleId, objectiveCommodity, requiredByObjective: false)); + } + + return candidates + .Where(entry => entry.Value > 0.01f) + .Select(entry => new ModuleExpansionCandidate(entry.Key, entry.Value)) + .ToList(); + } + + private static IEnumerable GetStoragePressureCandidates(SimulationWorld world, StationRuntime station) + { + foreach (var (storageClass, moduleId) in new[] { ("solid", "module_arg_stor_solid_m_01"), ("liquid", "module_arg_stor_liquid_m_01"), ("container", "module_arg_stor_container_m_01"), - }; - - foreach (var (storageClass, moduleId) in storageExpansionCandidates) + }) { var capacity = GetStationStorageCapacity(station, storageClass); if (capacity <= 0.01f) @@ -123,51 +173,552 @@ internal sealed class InfrastructureSimulationService } var used = station.Inventory - .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) - .Sum(entry => entry.Value); - - if (used / capacity >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId)) + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Sum(entry => entry.Value); + if (used / capacity >= 0.65f) { - return moduleId; + yield return moduleId; } } - - var priorities = StationSimulationService.GetFactionExpansionPressure(world, station.FactionId) > 0f - ? new (string ModuleId, int TargetCount)[] - { - ("module_gen_prod_refinedmetals_01", 1), - ("module_arg_stor_solid_m_01", 1), - ("module_arg_stor_container_m_01", 1), - ("module_gen_prod_hullparts_01", 2), - ("module_gen_prod_advancedelectronics_01", 1), - ("module_gen_build_l_01", 1), - ("module_arg_dock_m_01_lowtech", 2), - ("module_gen_prod_energycells_01", 2), - } - : new (string ModuleId, int TargetCount)[] - { - ("module_gen_prod_refinedmetals_01", 1), - ("module_arg_stor_solid_m_01", 1), - ("module_arg_stor_container_m_01", 1), - ("module_gen_prod_hullparts_01", 2), - ("module_gen_prod_advancedelectronics_01", 1), - ("module_gen_build_l_01", 1), - ("module_gen_prod_energycells_01", 2), - ("module_arg_dock_m_01_lowtech", 2), - }; - - foreach (var (moduleId, targetCount) in priorities) - { - if (CountModules(station.InstalledModules, moduleId) < targetCount - && world.ModuleRecipes.ContainsKey(moduleId)) - { - return moduleId; - } - } - - return null; } + private static IEnumerable GetRequiredStorageModules(SimulationWorld world, ModuleRecipeDefinition recipe) + { + var itemIds = recipe.Inputs.Select(input => input.ItemId); + foreach (var itemId in itemIds) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + continue; + } + + if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + { + yield return storageModuleId; + } + else + { + yield return "module_arg_stor_container_m_01"; + } + } + + if (world.ModuleDefinitions.TryGetValue(recipe.ModuleId, out var moduleDefinition)) + { + foreach (var productItemId in moduleDefinition.Products) + { + if (!world.ItemDefinitions.TryGetValue(productItemId, out var itemDefinition)) + { + continue; + } + + if (GetStorageRequirement(itemDefinition.CargoKind) is { } storageModuleId) + { + yield return storageModuleId; + } + else + { + yield return "module_arg_stor_container_m_01"; + } + } + } + } + + private static string? GetObjectiveCommodityId(string role) => + role switch + { + "power" => "energycells", + "refinery" => "refinedmetals", + "water" => "water", + "hullparts" => "hullparts", + "claytronics" => "claytronics", + _ => null, + }; + + private static string? GetObjectiveModuleId(SimulationWorld world, string role, string? objectiveCommodityId) => + role switch + { + "shipyard" => "module_gen_build_l_01", + _ => objectiveCommodityId is null ? null : world.ProductionGraph.GetPrimaryProducerModule(objectiveCommodityId), + }; + + private static float ScoreObjectiveModule( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + IReadOnlyDictionary constructionDemandByItem, + string? objectiveCommodityId, + string objectiveModuleId) + { + if (string.IsNullOrWhiteSpace(objectiveCommodityId)) + { + var hasShipyard = CountModules(station.InstalledModules, objectiveModuleId); + return hasShipyard == 0 ? 240f : 0f; + } + + var commodity = economy.GetCommodity(objectiveCommodityId); + var currentCount = CountModules(station.InstalledModules, objectiveModuleId); + var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId); + var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem); + var score = 90f + commodity.ProjectedPressureScore + (marginalOutputRate * 900f) + constructionImpact; + + if (currentCount == 0) + { + score += 80f; + } + + if (!float.IsPositiveInfinity(commodity.ProjectedShortageHorizonSeconds)) + { + score += MathF.Max(0f, 300f - commodity.ProjectedShortageHorizonSeconds) * 0.3f; + } + + score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId); + score *= EstimateProducerReadiness(world, station, economy, objectiveModuleId, objectiveCommodityId); + score += EstimateImmediateProducerActivationScore(world, station, economy, objectiveModuleId, objectiveCommodityId); + return score - (currentCount * 35f); + } + + private static float ScoreEnergySupportModule( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + IReadOnlyDictionary constructionDemandByItem) + { + var energy = economy.GetCommodity("energycells"); + var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01"); + var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem); + var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01"); + var score = 40f + energy.ProjectedPressureScore * 0.5f + constructionImpact + readinessUnlock; + + if (currentCount == 0) + { + score += 70f; + } + + if (!float.IsPositiveInfinity(energy.ProjectedShortageHorizonSeconds)) + { + score += MathF.Max(0f, 240f - energy.ProjectedShortageHorizonSeconds) * 0.2f; + } + + return score - (currentCount * 40f); + } + + private static float ScoreStorageModule( + SimulationWorld world, + StationRuntime station, + string storageModuleId, + string? objectiveModuleId, + string? objectiveCommodityId, + bool requiredByObjective) + { + var storageClass = storageModuleId switch + { + "module_arg_stor_solid_m_01" => "solid", + "module_arg_stor_liquid_m_01" => "liquid", + _ => "container", + }; + + var capacity = GetStationStorageCapacity(station, storageClass); + var used = station.Inventory + .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) + .Sum(entry => entry.Value); + var utilization = capacity <= 0.01f ? 0f : used / capacity; + + var score = requiredByObjective ? 140f : 0f; + score += MathF.Max(0f, utilization - 0.6f) * 240f; + + if (!string.IsNullOrWhiteSpace(objectiveModuleId) && !string.IsNullOrWhiteSpace(objectiveCommodityId)) + { + var objectiveUsesStorage = ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass); + if (objectiveUsesStorage) + { + score += 35f; + score += EstimateSupportUnlockScore(world, station, economy: null, supportModuleId: storageModuleId); + } + } + + return score; + } + + private static float ScoreDockModule(StationRuntime station) + { + var dockingPads = GetDockingPadCount(station); + var dockedShips = station.DockedShipIds.Count; + if (dockingPads <= 0) + { + return 150f; + } + + return dockedShips >= dockingPads ? 80f : dockingPads < 4 ? 25f : 0f; + } + + private static float ScoreHabitationModule(StationRuntime station) + { + if (station.WorkforceRequired <= 0.01f) + { + return 0f; + } + + return station.WorkforceEffectiveRatio < 0.75f + ? 30f + : station.WorkforceEffectiveRatio < 0.95f + ? 10f + : 0f; + } + + private static float ScoreHabitationModule(StationRuntime station, SimulationWorld world, FactionEconomySnapshot economy) + { + return ScoreHabitationModule(station) + EstimateSupportUnlockScore(world, station, economy, "module_arg_hab_m_01"); + } + + private static void AddOrRaiseCandidate(IDictionary candidates, string moduleId, float score) + { + if (score <= 0.01f) + { + return; + } + + if (!candidates.TryGetValue(moduleId, out var existing) || score > existing) + { + candidates[moduleId] = score; + } + } + + private static float EstimateMarginalOutputRate( + SimulationWorld world, + StationRuntime station, + string moduleId, + string commodityId) + { + var recipe = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe)) + .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + + if (recipe is null) + { + return 0f; + } + + var amount = recipe.Outputs + .Where(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal)) + .Sum(output => output.Amount); + return amount * station.WorkforceEffectiveRatio / MathF.Max(recipe.Duration, 0.01f); + } + + private static float EstimateObjectiveExpansionFeasibility( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) + { + var recipes = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .ToList(); + if (recipes.Count == 0) + { + return 1f; + } + + var feasibility = 1f; + foreach (var recipe in recipes) + { + foreach (var input in recipe.Inputs) + { + var inputCommodity = economy.GetCommodity(input.ItemId); + if (inputCommodity.AvailableStock <= 0.01f && inputCommodity.ProjectedProductionRatePerSecond <= 0.01f) + { + feasibility *= 0.65f; + continue; + } + + if (!float.IsPositiveInfinity(inputCommodity.ProjectedShortageHorizonSeconds) + && inputCommodity.ProjectedShortageHorizonSeconds < 180f) + { + feasibility *= 0.82f; + } + } + } + + return Math.Clamp(feasibility, 0.35f, 1.15f); + } + + private static float EstimateProducerReadiness( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) + { + var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); + return analysis.Readiness; + } + + private static ProducerLaneAnalysis AnalyzeProducerLane( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) + { + var recipe = world.Recipes.Values + .Where(recipe => + string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal) + && StationSimulationService.RecipeAppliesToStation(station, recipe) + && recipe.Outputs.Any(output => string.Equals(output.ItemId, commodityId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + if (recipe is null) + { + return new ProducerLaneAnalysis(1f, 1f, false, false, false, false); + } + + var workforceFactor = station.WorkforceEffectiveRatio < 0.45f + ? 0.75f + : station.WorkforceEffectiveRatio < 0.75f + ? 0.88f + : 1f; + var inputFactor = 1f; + var missingLocalInputs = false; + var missingFactionInputs = false; + + foreach (var input in recipe.Inputs) + { + var localAmount = GetInventoryAmount(station.Inventory, input.ItemId); + var commodity = economy.GetCommodity(input.ItemId); + if (localAmount + 0.001f >= input.Amount) + { + continue; + } + + missingLocalInputs = true; + var shortage = input.Amount - localAmount; + var availableStockRatio = commodity.AvailableStock <= 0.01f ? 0f : MathF.Min(1f, commodity.AvailableStock / MathF.Max(input.Amount, 0.01f)); + if (commodity.AvailableStock >= shortage) + { + inputFactor *= 0.95f + (availableStockRatio * 0.05f); + } + else if (commodity.ProjectedProductionRatePerSecond > 0.01f) + { + inputFactor *= 0.82f + (availableStockRatio * 0.08f); + } + else + { + inputFactor *= 0.55f + (availableStockRatio * 0.15f); + missingFactionInputs = true; + } + } + + var outputReady = true; + foreach (var output in recipe.Outputs) + { + if (!CanStationAcceptStationOutputSoon(world, station, output.ItemId, output.Amount)) + { + outputReady = false; + } + } + + var readiness = Math.Clamp(workforceFactor * inputFactor * (outputReady ? 1f : 0.72f), 0.4f, 1.1f); + return new ProducerLaneAnalysis( + readiness, + workforceFactor, + missingLocalInputs, + missingFactionInputs, + !outputReady, + outputReady && inputFactor >= 0.9f); + } + + private static float EstimateSupportUnlockScore( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot? economy, + string supportModuleId) + { + var role = StationSimulationService.DetermineStationRole(station); + var objectiveCommodityId = GetObjectiveCommodityId(role); + var objectiveModuleId = GetObjectiveModuleId(world, role, objectiveCommodityId); + if (string.IsNullOrWhiteSpace(objectiveCommodityId) || string.IsNullOrWhiteSpace(objectiveModuleId)) + { + return 0f; + } + + var analysis = economy is null + ? new ProducerLaneAnalysis(0.75f, 1f, false, false, false, false) + : AnalyzeProducerLane(world, station, economy, objectiveModuleId, objectiveCommodityId); + + var unlockScore = 0f; + switch (supportModuleId) + { + case "module_arg_hab_m_01" when analysis.WorkforceFactor < 0.9f + && !analysis.HasMissingFactionInputs + && !analysis.HasMissingOutputStorage: + unlockScore += (1f - analysis.WorkforceFactor) * 150f; + break; + case "module_gen_prod_energycells_01": + if (ObjectiveNeedsEnergy(world, objectiveCommodityId) + && analysis.HasMissingLocalInputs + && (economy?.GetCommodity("energycells").AvailableStock ?? 0f) < 120f) + { + unlockScore += 90f; + } + break; + case "module_arg_stor_container_m_01": + case "module_arg_stor_solid_m_01": + case "module_arg_stor_liquid_m_01": + var storageClass = supportModuleId switch + { + "module_arg_stor_solid_m_01" => "solid", + "module_arg_stor_liquid_m_01" => "liquid", + _ => "container", + }; + if (analysis.HasMissingOutputStorage + && (ModuleNeedsStorageClass(world, objectiveModuleId, storageClass) + || CommodityUsesStorageClass(world, objectiveCommodityId, storageClass))) + { + unlockScore += 70f; + } + break; + } + + return unlockScore * MathF.Max(0.4f, 1f - analysis.Readiness); + } + + private static float EstimateImmediateProducerActivationScore( + SimulationWorld world, + StationRuntime station, + FactionEconomySnapshot economy, + string moduleId, + string commodityId) + { + var analysis = AnalyzeProducerLane(world, station, economy, moduleId, commodityId); + if (analysis.CanRunSoon) + { + return 110f; + } + + if (!analysis.HasMissingFactionInputs && !analysis.HasMissingOutputStorage) + { + return 45f * MathF.Max(0.6f, analysis.WorkforceFactor); + } + + return 0f; + } + + private static float EstimateConstructionBottleneckImpact( + SimulationWorld world, + string moduleId, + IReadOnlyDictionary constructionDemandByItem) + { + if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition)) + { + return 0f; + } + + var score = 0f; + foreach (var productItemId in moduleDefinition.Products) + { + if (!constructionDemandByItem.TryGetValue(productItemId, out var outstandingDemand) || outstandingDemand <= 0.01f) + { + continue; + } + + var outputRate = EstimateModuleOutputRate(world, moduleId, productItemId); + if (outputRate <= 0.0001f) + { + continue; + } + + score += MathF.Min(outstandingDemand, outputRate * 900f) * 0.8f; + } + + return score; + } + + private static float EstimateModuleOutputRate(SimulationWorld world, string moduleId, string itemId) + { + var recipe = world.Recipes.Values + .Where(recipe => string.Equals(StationSimulationService.GetStationProductionLaneKey(world, recipe), moduleId, StringComparison.Ordinal)) + .Where(recipe => recipe.Outputs.Any(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal))) + .OrderByDescending(recipe => recipe.Priority) + .FirstOrDefault(); + if (recipe is null) + { + return 0f; + } + + return recipe.Outputs + .Where(output => string.Equals(output.ItemId, itemId, StringComparison.Ordinal)) + .Sum(output => output.Amount) / MathF.Max(recipe.Duration, 0.01f); + } + + private static IReadOnlyDictionary GetOutstandingConstructionDemand(SimulationWorld world, string factionId) + { + var demand = new Dictionary(StringComparer.Ordinal); + + foreach (var site in world.ConstructionSites.Where(site => + string.Equals(site.FactionId, factionId, StringComparison.Ordinal) + && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed)) + { + foreach (var required in site.RequiredItems) + { + var remaining = MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)); + if (remaining <= 0.01f) + { + continue; + } + + demand[required.Key] = demand.GetValueOrDefault(required.Key) + remaining; + } + } + + return demand; + } + + private static bool ModuleNeedsStorageClass(SimulationWorld world, string moduleId, string storageClass) + { + if (!world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) + { + return false; + } + + return recipe.Inputs.Any(input => + world.ItemDefinitions.TryGetValue(input.ItemId, out var itemDefinition) + && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal)); + } + + private static bool CommodityUsesStorageClass(SimulationWorld world, string commodityId, string storageClass) => + world.ItemDefinitions.TryGetValue(commodityId, out var itemDefinition) + && string.Equals(itemDefinition.CargoKind, storageClass, StringComparison.Ordinal); + + private static bool CanStationAcceptStationOutputSoon(SimulationWorld world, StationRuntime station, string itemId, float amount) + { + if (!world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) + { + 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) && string.Equals(definition.CargoKind, itemDefinition.CargoKind, StringComparison.Ordinal)) + .Sum(entry => entry.Value); + return used + amount <= capacity * 0.95f; + } + + private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) => + world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal); + internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) { var nextModuleId = GetNextStationModuleToBuild(station, world); @@ -223,6 +774,16 @@ internal sealed class InfrastructureSimulationService } } + private sealed record ModuleExpansionCandidate(string ModuleId, float Score); + + private sealed record ProducerLaneAnalysis( + float Readiness, + float WorkforceFactor, + bool HasMissingLocalInputs, + bool HasMissingFactionInputs, + bool HasMissingOutputStorage, + bool CanRunSoon); + internal static int GetDockingPadCount(StationRuntime station) => CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2; diff --git a/apps/backend/Stations/Simulation/StationLifecycleService.cs b/apps/backend/Stations/Simulation/StationLifecycleService.cs index 8a0cbfd..c4ac369 100644 --- a/apps/backend/Stations/Simulation/StationLifecycleService.cs +++ b/apps/backend/Stations/Simulation/StationLifecycleService.cs @@ -85,6 +85,7 @@ internal sealed class StationLifecycleService }; world.Ships.Add(ship); + EnsureSpawnedShipCommander(world, station, ship); if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) { faction.ShipsBuilt += 1; @@ -124,4 +125,86 @@ internal sealed class StationLifecycleService ], }; } + + internal static void EnsureStationCommander(SimulationWorld world, StationRuntime station) + { + if (!string.IsNullOrWhiteSpace(station.CommanderId)) + { + return; + } + + var factionCommander = world.Commanders.FirstOrDefault(candidate => + string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) + && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); + var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); + if (factionCommander is null || faction is null) + { + return; + } + + var commander = new CommanderRuntime + { + Id = $"commander-station-{station.Id}", + Kind = CommanderKind.Station, + FactionId = station.FactionId, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = station.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "station-default", + }; + + station.CommanderId = commander.Id; + station.PolicySetId = factionCommander.PolicySetId; + factionCommander.SubordinateCommanderIds.Add(commander.Id); + faction.CommanderIds.Add(commander.Id); + world.Commanders.Add(commander); + } + + private static void EnsureSpawnedShipCommander(SimulationWorld world, StationRuntime station, ShipRuntime ship) + { + var factionCommander = world.Commanders.FirstOrDefault(candidate => + string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) + && string.Equals(candidate.FactionId, station.FactionId, StringComparison.Ordinal)); + var faction = world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, station.FactionId, StringComparison.Ordinal)); + if (factionCommander is null || faction is null) + { + return; + } + + var commander = new CommanderRuntime + { + Id = $"commander-ship-{ship.Id}", + Kind = CommanderKind.Ship, + FactionId = ship.FactionId, + ParentCommanderId = factionCommander.Id, + ControlledEntityId = ship.Id, + PolicySetId = factionCommander.PolicySetId, + Doctrine = "ship-default", + ActiveBehavior = new CommanderBehaviorRuntime + { + Kind = ship.DefaultBehavior.Kind, + AreaSystemId = ship.DefaultBehavior.AreaSystemId, + TargetEntityId = ship.DefaultBehavior.TargetEntityId, + ItemId = ship.DefaultBehavior.ItemId, + StationId = ship.DefaultBehavior.StationId, + ModuleId = ship.DefaultBehavior.ModuleId, + NodeId = ship.DefaultBehavior.NodeId, + Phase = ship.DefaultBehavior.Phase, + PatrolIndex = ship.DefaultBehavior.PatrolIndex, + }, + ActiveTask = new CommanderTaskRuntime + { + Kind = ShipTaskKinds.Idle, + Status = WorkStatus.Pending, + TargetSystemId = ship.SystemId, + Threshold = 0f, + }, + }; + + ship.CommanderId = commander.Id; + ship.PolicySetId = factionCommander.PolicySetId; + factionCommander.SubordinateCommanderIds.Add(commander.Id); + faction.CommanderIds.Add(commander.Id); + world.Commanders.Add(commander); + } } diff --git a/apps/backend/Stations/Simulation/StationSimulationService.cs b/apps/backend/Stations/Simulation/StationSimulationService.cs index 1e65b1c..f8da69f 100644 --- a/apps/backend/Stations/Simulation/StationSimulationService.cs +++ b/apps/backend/Stations/Simulation/StationSimulationService.cs @@ -15,23 +15,54 @@ internal sealed class StationSimulationService } var desiredOrders = new List(); + var economy = FactionEconomyAnalyzer.Build(world, station.FactionId); + var role = DetermineStationRole(station); + var site = GetConstructionSiteForStation(world, station.Id); var waterReserve = MathF.Max(30f, station.Population * 3f); - var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f; - var oreReserve = HasRefineryCapability(station) ? 180f : 0f; - var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01") - && !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01") + var constructionEnergyReserve = GetConstructionDemandForItem(world, site, "energycells"); + var constructionHullpartsReserve = GetConstructionDemandForItem(world, site, "hullparts"); + var constructionClayReserve = GetConstructionDemandForItem(world, site, "claytronics"); + var constructionRefinedReserve = GetConstructionDemandForItem(world, site, "refinedmetals"); + var iceReserve = role == "water" ? 260f : 0f; + var energyReserve = role switch + { + "power" => 120f, + "refinery" => 160f, + "hullparts" => 180f, + "claytronics" => 220f, + "water" => 140f, + _ => 60f, + } + constructionEnergyReserve; + var refinedReserve = role switch + { + "hullparts" => 220f, + "shipyard" => 260f, + "refinery" => 80f, + _ => 0f, + }; + var oreReserve = role == "refinery" ? 260f : 0f; + var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); + var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f); + var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01") && 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, "refinedmetals", refinedReserve, valuationBase: 1.15f); - AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f); + AddDemandOrder(desiredOrders, station, "water", ScaleReserveByEconomy(economy, "water", waterReserve), valuationBase: ScaleDemandValuation(economy, "water", 1.1f)); + AddDemandOrder(desiredOrders, station, "energycells", ScaleReserveByEconomy(economy, "energycells", energyReserve), valuationBase: ScaleDemandValuation(economy, "energycells", 1.0f)); + AddDemandOrder(desiredOrders, station, "ice", ScaleReserveByEconomy(economy, "ice", iceReserve), valuationBase: ScaleDemandValuation(economy, "ice", 1.0f)); + AddDemandOrder(desiredOrders, station, "ore", ScaleReserveByEconomy(economy, "ore", oreReserve), valuationBase: ScaleDemandValuation(economy, "ore", 1.0f)); + AddDemandOrder(desiredOrders, station, "refinedmetals", ScaleReserveByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve)), valuationBase: ScaleDemandValuation(economy, "refinedmetals", 1.15f)); + AddDemandOrder(desiredOrders, station, "hullparts", ScaleReserveByEconomy(economy, "hullparts", hullpartsReserve + shipPartsReserve), valuationBase: ScaleDemandValuation(economy, "hullparts", 1.3f)); + AddDemandOrder(desiredOrders, station, "claytronics", ScaleReserveByEconomy(economy, "claytronics", claytronicsReserve), valuationBase: ScaleDemandValuation(economy, "claytronics", 1.35f)); - 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, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f); + AddSupplyOrder(desiredOrders, station, "water", ScaleSupplyTriggerByEconomy(economy, "water", waterReserve * 1.5f), reserveFloor: waterReserve, valuationBase: ScaleSupplyValuation(economy, "water", 0.65f)); + AddSupplyOrder(desiredOrders, station, "energycells", ScaleSupplyTriggerByEconomy(economy, "energycells", energyReserve * 1.4f), reserveFloor: energyReserve, valuationBase: ScaleSupplyValuation(economy, "energycells", 0.7f)); + AddSupplyOrder(desiredOrders, station, "ice", ScaleSupplyTriggerByEconomy(economy, "ice", iceReserve * 1.4f), reserveFloor: iceReserve, valuationBase: ScaleSupplyValuation(economy, "ice", 0.5f)); + AddSupplyOrder(desiredOrders, station, "ore", ScaleSupplyTriggerByEconomy(economy, "ore", oreReserve * 1.4f), reserveFloor: oreReserve, valuationBase: ScaleSupplyValuation(economy, "ore", 0.7f)); + AddSupplyOrder(desiredOrders, station, "refinedmetals", ScaleSupplyTriggerByEconomy(economy, "refinedmetals", MathF.Max(refinedReserve, constructionRefinedReserve) * 1.4f), reserveFloor: MathF.Max(refinedReserve, constructionRefinedReserve), valuationBase: ScaleSupplyValuation(economy, "refinedmetals", 0.95f)); + AddSupplyOrder(desiredOrders, station, "hullparts", ScaleSupplyTriggerByEconomy(economy, "hullparts", MathF.Max(hullpartsReserve * 1.35f, hullpartsReserve + 40f)), reserveFloor: hullpartsReserve, valuationBase: ScaleSupplyValuation(economy, "hullparts", 1.05f)); + AddSupplyOrder(desiredOrders, station, "claytronics", ScaleSupplyTriggerByEconomy(economy, "claytronics", MathF.Max(claytronicsReserve * 1.35f, claytronicsReserve + 30f)), reserveFloor: claytronicsReserve, valuationBase: ScaleSupplyValuation(economy, "claytronics", 1.1f)); ReconcileStationMarketOrders(world, station, desiredOrders); } @@ -112,11 +143,11 @@ internal sealed class StationSimulationService .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); - private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) => + internal 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) + internal static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) { var laneModuleId = GetStationProductionLaneKey(world, recipe); if (laneModuleId is null) @@ -180,7 +211,7 @@ internal sealed class StationSimulationService }; } - private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) + internal static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) { var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) || string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal) @@ -240,6 +271,71 @@ internal sealed class StationSimulationService private static bool HasRefineryCapability(StationRuntime station) => HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01"); + internal static string NormalizeStationObjective(string? objective) + { + return objective?.Trim().ToLowerInvariant() switch + { + "power" or "energy" or "energycells" => "power", + "water" or "ice-refinery" => "water", + "refinery" or "refinedmetals" => "refinery", + "hullparts" or "hull" => "hullparts", + "claytronics" or "clay" => "claytronics", + "shipyard" or "ship-production" => "shipyard", + _ => "general", + }; + } + + internal static string DetermineStationRole(StationRuntime station) + { + var objective = NormalizeStationObjective(station.Objective); + if (!string.Equals(objective, "general", StringComparison.Ordinal)) + { + return objective; + } + + if (HasStationModules(station, "module_gen_build_l_01")) + { + return "shipyard"; + } + + if (HasStationModules(station, "module_gen_prod_water_01")) + { + return "water"; + } + + if (HasStationModules(station, "module_gen_prod_claytronics_01")) + { + return "claytronics"; + } + + if (HasStationModules(station, "module_gen_prod_hullparts_01")) + { + return "hullparts"; + } + + if (HasStationModules(station, "module_gen_prod_refinedmetals_01")) + { + return "refinery"; + } + + if (HasStationModules(station, "module_gen_prod_energycells_01")) + { + return "power"; + } + + return "general"; + } + + private static float GetConstructionDemandForItem(SimulationWorld world, ConstructionSiteRuntime? site, string itemId) + { + if (site is null || !site.RequiredItems.TryGetValue(itemId, out var required)) + { + return 0f; + } + + return MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, itemId)); + } + private static void AddDemandOrder(ICollection desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase) { var current = GetInventoryAmount(station.Inventory, itemId); @@ -267,7 +363,9 @@ internal sealed class StationSimulationService return; } - desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor)); + var surplusRatio = triggerAmount <= 0.01f ? 1f : MathF.Min(1f, surplus / triggerAmount); + var liquidationValuation = MathF.Max(0.05f, valuationBase * (1f - (0.85f * surplusRatio))); + desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, liquidationValuation, reserveFloor)); } private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) @@ -330,6 +428,50 @@ internal sealed class StationSimulationService return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id)); } + private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve) + { + var commodity = economy.GetCommodity(itemId); + if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds)) + { + return MathF.Max(0f, baseReserve); + } + + return commodity.ShortageHorizonSeconds < 180f + ? baseReserve * 1.5f + : commodity.ShortageHorizonSeconds < 360f + ? baseReserve * 1.2f + : baseReserve; + } + + private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger) + { + var commodity = economy.GetCommodity(itemId); + return commodity.NetRatePerSecond < -0.01f ? baseTrigger * 1.2f : baseTrigger; + } + + private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) + { + var commodity = economy.GetCommodity(itemId); + if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds)) + { + return commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.3f; + } + + return commodity.ShortageHorizonSeconds < 180f + ? baseValuation * 1.5f + : commodity.ShortageHorizonSeconds < 360f + ? baseValuation * 1.25f + : baseValuation; + } + + private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation) + { + var commodity = economy.GetCommodity(itemId); + return commodity.NetRatePerSecond > 0.01f && commodity.ShortageHorizonSeconds > 600f + ? baseValuation * 0.75f + : baseValuation; + } + private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) { var totalLagrangePoints = world.Celestials.Count(node => diff --git a/apps/backend/Universe/Runtime/SimulationWorld.cs b/apps/backend/Universe/Runtime/SimulationWorld.cs index 29f17f9..8edf39d 100644 --- a/apps/backend/Universe/Runtime/SimulationWorld.cs +++ b/apps/backend/Universe/Runtime/SimulationWorld.cs @@ -22,6 +22,7 @@ public sealed class SimulationWorld public required Dictionary ModuleDefinitions { get; init; } public required Dictionary ModuleRecipes { get; init; } public required Dictionary Recipes { get; init; } + public required ProductionGraph ProductionGraph { get; init; } public int TickIntervalMs { get; init; } = 200; public double OrbitalTimeSeconds { get; set; } public DateTimeOffset GeneratedAtUtc { get; set; } diff --git a/apps/backend/Universe/Scenario/DataCatalogLoader.cs b/apps/backend/Universe/Scenario/DataCatalogLoader.cs index 7be568e..1af0a2d 100644 --- a/apps/backend/Universe/Scenario/DataCatalogLoader.cs +++ b/apps/backend/Universe/Scenario/DataCatalogLoader.cs @@ -20,6 +20,7 @@ internal sealed class DataCatalogLoader(string dataRoot) var balance = Read("balance.json"); var recipes = BuildRecipes(items, ships, modules); var moduleRecipes = BuildModuleRecipes(modules); + var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); return new ScenarioCatalog( authoredSystems, @@ -29,7 +30,8 @@ internal sealed class DataCatalogLoader(string dataRoot) ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal), - moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal)); + moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal), + productionGraph); } internal ScenarioDefinition NormalizeScenarioToAvailableSystems( @@ -56,6 +58,7 @@ internal sealed class DataCatalogLoader(string dataRoot) SystemId = ResolveSystemId(station.SystemId), Label = station.Label, Color = station.Color, + Objective = station.Objective, StartingModules = station.StartingModules.ToList(), FactionId = station.FactionId, PlanetIndex = station.PlanetIndex, @@ -71,6 +74,7 @@ internal sealed class DataCatalogLoader(string dataRoot) Center = formation.Center.ToArray(), SystemId = ResolveSystemId(formation.SystemId), FactionId = formation.FactionId, + StartingInventory = new Dictionary(formation.StartingInventory, StringComparer.Ordinal), }) .ToList(), PatrolRoutes = scenario.PatrolRoutes @@ -297,4 +301,5 @@ internal sealed record ScenarioCatalog( IReadOnlyDictionary ShipDefinitions, IReadOnlyDictionary ItemDefinitions, IReadOnlyDictionary Recipes, - IReadOnlyDictionary ModuleRecipes); + IReadOnlyDictionary ModuleRecipes, + ProductionGraph ProductionGraph); diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index 991d7ff..90687e4 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -39,7 +39,7 @@ internal sealed class WorldBuilder( seedingService.InitializeStationStockpiles(stations); var refinery = seedingService.SelectRefineryStation(stations, scenario); var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); - var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, refinery); + var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery); var factions = seedingService.CreateFactions(stations, ships); seedingService.BootstrapFactionEconomy(factions, stations); @@ -47,7 +47,32 @@ internal sealed class WorldBuilder( var commanders = seedingService.CreateCommanders(factions, stations, ships); var nowUtc = DateTimeOffset.UtcNow; var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); - var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(stations, claims, catalog.ModuleRecipes); + var bootstrapWorld = new SimulationWorld + { + Label = "Split Viewer / Bootstrap World", + Seed = WorldSeed, + Balance = catalog.Balance, + Systems = systemRuntimes, + Celestials = spatialLayout.Celestials, + Nodes = spatialLayout.Nodes, + Stations = stations, + Ships = ships, + Factions = factions, + Commanders = commanders, + Claims = claims, + ConstructionSites = [], + MarketOrders = [], + Policies = policies, + ShipDefinitions = new Dictionary(catalog.ShipDefinitions, StringComparer.Ordinal), + ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), + ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), + ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), + Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), + ProductionGraph = catalog.ProductionGraph, + OrbitalTimeSeconds = WorldSeed * 97d, + GeneratedAtUtc = nowUtc, + }; + var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(bootstrapWorld); return new SimulationWorld { @@ -70,6 +95,7 @@ internal sealed class WorldBuilder( ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), + ProductionGraph = catalog.ProductionGraph, OrbitalTimeSeconds = WorldSeed * 97d, GeneratedAtUtc = DateTimeOffset.UtcNow, }; @@ -99,9 +125,12 @@ internal sealed class WorldBuilder( SystemId = system.Definition.Id, Label = plan.Label, Color = plan.Color, + Objective = StationSimulationService.NormalizeStationObjective(plan.Objective), Position = placement.Position, FactionId = plan.FactionId ?? DefaultFactionId, CelestialId = placement.AnchorCelestial.Id, + Health = 600f, + MaxHealth = 600f, }; stations.Add(station); @@ -142,6 +171,7 @@ internal sealed class WorldBuilder( BalanceDefinition balance, IReadOnlyDictionary shipDefinitions, IReadOnlyDictionary> patrolRoutes, + IReadOnlyCollection stations, StationRuntime? refinery) { var ships = new List(); @@ -168,10 +198,25 @@ internal sealed class WorldBuilder( Position = position, TargetPosition = position, SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials), - DefaultBehavior = WorldSeedingService.CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), + DefaultBehavior = WorldSeedingService.CreateBehavior( + definition, + formation.SystemId, + formation.FactionId ?? DefaultFactionId, + scenario, + patrolRoutes, + stations, + refinery), ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, Health = definition.MaxHealth, }); + + foreach (var (itemId, amount) in formation.StartingInventory) + { + if (amount > 0f) + { + ships[^1].Inventory[itemId] = amount; + } + } } } diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 502d131..6af6731 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -37,7 +37,7 @@ internal sealed class WorldSeedingService .ToList(); var refineries = ownedStations - .Where(station => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01")) + .Where(station => string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)) .ToList(); if (refineries.Count > 0) @@ -65,10 +65,32 @@ internal sealed class WorldSeedingService foreach (var station in stations) { InitializeStationPopulation(station); - station.Inventory["refinedmetals"] = 120f; + if (station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal)) + { + station.Inventory["energycells"] = MathF.Max(GetInventoryAmount(station.Inventory, "energycells"), 240f); + } + + if (station.InstalledModules.Contains("module_gen_prod_refinedmetals_01", StringComparer.Ordinal)) + { + station.Inventory["ore"] = MathF.Max(GetInventoryAmount(station.Inventory, "ore"), 220f); + } + + if (station.InstalledModules.Contains("module_gen_prod_hullparts_01", StringComparer.Ordinal)) + { + station.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(station.Inventory, "refinedmetals"), 240f); + station.Inventory["graphene"] = MathF.Max(GetInventoryAmount(station.Inventory, "graphene"), 80f); + } + + if (station.InstalledModules.Contains("module_gen_prod_claytronics_01", StringComparer.Ordinal)) + { + station.Inventory["antimattercells"] = MathF.Max(GetInventoryAmount(station.Inventory, "antimattercells"), 90f); + station.Inventory["microchips"] = MathF.Max(GetInventoryAmount(station.Inventory, "microchips"), 120f); + station.Inventory["quantumtubes"] = MathF.Max(GetInventoryAmount(station.Inventory, "quantumtubes"), 90f); + } + if (station.Population > 0f) { - station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f); + station.Inventory["water"] = MathF.Max(60f, station.Population * 1.5f); } } } @@ -76,10 +98,10 @@ internal sealed class WorldSeedingService internal StationRuntime? SelectRefineryStation(IReadOnlyCollection stations, ScenarioDefinition scenario) { return stations.FirstOrDefault(station => - HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") && + string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) && station.SystemId == scenario.MiningDefaults.RefinerySystemId) ?? stations.FirstOrDefault(station => - HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01")); + string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal)); } internal List CreateClaims( @@ -116,23 +138,21 @@ internal sealed class WorldSeedingService } internal (List ConstructionSites, List MarketOrders) CreateConstructionSites( - IReadOnlyCollection stations, - IReadOnlyCollection claims, - IReadOnlyDictionary moduleRecipes) + SimulationWorld world) { var sites = new List(); var orders = new List(); - foreach (var station in stations) + foreach (var station in world.Stations) { - var moduleId = GetNextConstructionSiteModule(station, moduleRecipes); + var moduleId = InfrastructureSimulationService.GetNextStationModuleToBuild(station, world); if (moduleId is null || station.CelestialId is null) { continue; } - var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); - if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) + var claim = world.Claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId); + if (claim is null || !world.ModuleRecipes.TryGetValue(moduleId, out var recipe)) { continue; } @@ -294,23 +314,40 @@ internal sealed class WorldSeedingService internal static DefaultBehaviorRuntime CreateBehavior( ShipDefinition definition, string systemId, + string factionId, ScenarioDefinition scenario, IReadOnlyDictionary> patrolRoutes, + IReadOnlyCollection stations, StationRuntime? refinery) { - if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && refinery is not null) + var homeStation = stations.FirstOrDefault(station => + string.Equals(station.FactionId, factionId, StringComparison.Ordinal) + && string.Equals(station.SystemId, systemId, StringComparison.Ordinal)) + ?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal)) + ?? refinery; + + if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null) { return new DefaultBehaviorRuntime { Kind = "construct-station", - StationId = refinery.Id, + StationId = homeStation.Id, Phase = "travel-to-station", }; } - if (HasCapabilities(definition, "mining") && refinery is not null) + if (HasCapabilities(definition, "mining") && homeStation is not null) { - return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id); + return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, homeStation.Id); + } + + if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal)) + { + return new DefaultBehaviorRuntime + { + Kind = "trade-haul", + Phase = "travel-to-source", + }; } if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route)) @@ -318,6 +355,7 @@ internal sealed class WorldSeedingService return new DefaultBehaviorRuntime { Kind = "patrol", + StationId = homeStation?.Id, PatrolPoints = route, PatrolIndex = 0, }; @@ -340,6 +378,13 @@ internal sealed class WorldSeedingService Color = "#7ed4ff", Credits = MinimumFactionCredits, }, + "asterion-league" => new FactionRuntime + { + Id = factionId, + Label = "Asterion League", + Color = "#ff8f70", + Credits = MinimumFactionCredits, + }, _ => new FactionRuntime { Id = factionId, @@ -350,31 +395,6 @@ internal sealed class WorldSeedingService }; } - private static string? GetNextConstructionSiteModule( - StationRuntime station, - IReadOnlyDictionary moduleRecipes) - { - foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[] - { - ("module_gen_prod_refinedmetals_01", 1), - ("module_arg_stor_container_m_01", 1), - ("module_gen_prod_hullparts_01", 2), - ("module_gen_prod_advancedelectronics_01", 1), - ("module_gen_build_l_01", 1), - ("module_gen_prod_energycells_01", 2), - ("module_arg_dock_m_01_lowtech", 2), - }) - { - if (CountModules(station.InstalledModules, moduleId) < targetCount - && moduleRecipes.ContainsKey(moduleId)) - { - return moduleId; - } - } - - return null; - } - private static void InitializeStationPopulation(StationRuntime station) { var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01"); @@ -406,6 +426,8 @@ internal sealed class WorldSeedingService { Kind = behavior.Kind, AreaSystemId = behavior.AreaSystemId, + TargetEntityId = behavior.TargetEntityId, + ItemId = behavior.ItemId, ModuleId = behavior.ModuleId, NodeId = behavior.NodeId, Phase = behavior.Phase, diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index ee8d4d7..cdf8062 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "WorldGeneration": { - "TargetSystemCount": 1, + "TargetSystemCount": 3, "IncludeSolSystem": true }, "OrbitalSimulation": { diff --git a/shared/data/scenario.json b/shared/data/scenario.json index bb974b4..05ce882 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -1,50 +1,138 @@ { "initialStations": [ { - "label": "Orbital Station", + "label": "Dominion Power Relay", + "color": "#7ed4ff", + "objective": "power", "startingModules": [ "module_arg_dock_m_01_lowtech", - "module_gen_prod_energycells_01", - "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01", "module_arg_stor_container_m_01", - "module_gen_prod_refinedmetals_01" + "module_gen_prod_energycells_01" ], "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 1, + "lagrangeSide": -1 + }, + { + "label": "Dominion Hullworks", + "color": "#7ed4ff", + "objective": "hullparts", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_hullparts_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", "planetIndex": 2, "lagrangeSide": -1 + }, + { + "label": "Dominion Clay Grid", + "color": "#7ed4ff", + "objective": "claytronics", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_claytronics_01" + ], + "systemId": "helios", + "factionId": "sol-dominion", + "planetIndex": 0, + "lagrangeSide": 1 + }, + { + "label": "League Power Relay", + "color": "#ff8f70", + "objective": "power", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_liquid_m_01", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 1, + "lagrangeSide": 1 + }, + { + "label": "League Hullworks", + "color": "#ff8f70", + "objective": "hullparts", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_hullparts_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 2, + "lagrangeSide": 1 + }, + { + "label": "League Clay Grid", + "color": "#ff8f70", + "objective": "claytronics", + "startingModules": [ + "module_arg_dock_m_01_lowtech", + "module_arg_stor_container_m_01", + "module_gen_prod_energycells_01", + "module_gen_prod_claytronics_01" + ], + "systemId": "sol", + "factionId": "asterion-league", + "planetIndex": 3, + "lagrangeSide": -1 } ], "shipFormations": [ { "shipId": "constructor", "count": 1, - "center": [ - 45, - 0, - 20 - ], - "systemId": "helios" + "center": [ 40, 0, 12 ], + "systemId": "helios", + "factionId": "sol-dominion" }, { "shipId": "miner", "count": 1, - "center": [ - 52, - 0, - 24 - ], - "systemId": "helios" + "center": [ 54, 0, 18 ], + "systemId": "helios", + "factionId": "sol-dominion" }, { "shipId": "hauler", "count": 1, - "center": [ - 60, - 0, - 28 - ], - "systemId": "helios" + "center": [ 62, 0, 8 ], + "systemId": "helios", + "factionId": "sol-dominion" + }, + { + "shipId": "constructor", + "count": 1, + "center": [ 42, 0, -16 ], + "systemId": "sol", + "factionId": "asterion-league" + }, + { + "shipId": "miner", + "count": 1, + "center": [ 56, 0, -12 ], + "systemId": "sol", + "factionId": "asterion-league" + }, + { + "shipId": "hauler", + "count": 1, + "center": [ 68, 0, -18 ], + "systemId": "sol", + "factionId": "asterion-league" } ], "patrolRoutes": [], diff --git a/shared/data/ships.json b/shared/data/ships.json index 3b8a8e6..584431f 100644 --- a/shared/data/ships.json +++ b/shared/data/ships.json @@ -319,8 +319,7 @@ "warpSpeed": 0.13, "ftlSpeed": 0.48, "spoolTime": 3.5, - "cargoCapacity": 160, - "cargoKind": "manufactured", + "cargoCapacity": 0, "color": "#9af0c1", "hullColor": "#2d5d47", "size": 9, diff --git a/shared/data/systems.json b/shared/data/systems.json index 51662ae..88208d3 100644 --- a/shared/data/systems.json +++ b/shared/data/systems.json @@ -423,23 +423,44 @@ { "angle": 0.45, "radiusOffset": 180000, - "oreAmount": 3000, + "oreAmount": 12000, "itemId": "ore", "shardCount": 7 }, { "angle": 2.544395102, "radiusOffset": 180000, - "oreAmount": 3000, + "oreAmount": 12000, "itemId": "ore", "shardCount": 7 }, { "angle": 4.638790205, "radiusOffset": 180000, - "oreAmount": 3000, + "oreAmount": 12000, "itemId": "ore", "shardCount": 7 + }, + { + "angle": 1.2, + "radiusOffset": 235000, + "oreAmount": 14000, + "itemId": "ore", + "shardCount": 9 + }, + { + "angle": 3.05, + "radiusOffset": 228000, + "oreAmount": 14000, + "itemId": "ore", + "shardCount": 9 + }, + { + "angle": 5.25, + "radiusOffset": 242000, + "oreAmount": 14000, + "itemId": "ore", + "shardCount": 9 } ], "planets": [