using SpaceGame.Simulation.Api.Data; using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class SimulationEngine { private const int StrategicControlTargetSystems = 5; private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection events) { var factionPopulation = new Dictionary(StringComparer.Ordinal); foreach (var station in world.Stations) { UpdateStationPopulation(station, deltaSeconds, events); ReviewStationMarketOrders(world, station); RunStationProduction(world, station, deltaSeconds, events); factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population; } foreach (var faction in world.Factions) { faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id); } } private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection events) { station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f); var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds; var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater); var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater; var habitatModules = CountModules(station.InstalledModules, "habitat-ring"); station.PopulationCapacity = 40f + (habitatModules * 220f); if (waterSatisfied) { if (habitatModules > 0 && station.Population < station.PopulationCapacity) { station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds)); } } else if (station.Population > 0f) { var previous = station.Population; station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds)); if (MathF.Floor(previous) > MathF.Floor(station.Population)) { events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Label} lost population due to support shortages.", DateTimeOffset.UtcNow)); } } station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired); } private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station) { if (station.CommanderId is null) { return; } var desiredOrders = new List(); var waterReserve = MathF.Max(30f, station.Population * 3f); var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f; var oreReserve = HasRefineryCapability(station) ? 180f : 0f; var shipPartsReserve = HasStationModules(station, "fabricator-array") && !HasStationModules(station, "component-factory", "ship-factory") && FactionNeedsMoreWarships(world, station.FactionId) ? 90f : 0f; AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f); AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f); AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f); AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f); AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f); AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f); AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f); ReconcileStationMarketOrders(world, station, desiredOrders); } private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection events) { var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); foreach (var laneKey in GetStationProductionLanes(station)) { var recipe = SelectProductionRecipe(world, station, laneKey); if (recipe is null) { station.ProductionLaneTimers[laneKey] = 0f; continue; } var throughput = GetStationProductionThroughput(station, recipe); var produced = 0f; station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput); while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe)) { station.ProductionLaneTimers[laneKey] -= recipe.Duration; foreach (var input in recipe.Inputs) { RemoveInventory(station.Inventory, input.ItemId, input.Amount); } if (recipe.ShipOutputId is not null) { produced += CompleteShipRecipe(world, station, recipe, events); continue; } foreach (var output in recipe.Outputs) { produced += TryAddStationInventory(world, station, output.ItemId, output.Amount); } } if (produced <= 0.01f) { continue; } events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow)); if (faction is not null) { faction.GoodsProduced += produced; } } } private static IEnumerable GetStationProductionLanes(StationRuntime station) { if (CountModules(station.InstalledModules, "refinery-stack") > 0) { yield return "refinery"; } if (CountModules(station.InstalledModules, "fabricator-array") > 0) { yield return "fabrication"; } if (CountModules(station.InstalledModules, "component-factory") > 0) { yield return "components"; } if (CountModules(station.InstalledModules, "ship-factory") > 0) { yield return "shipyard"; } } private static float GetStationProductionTimer(StationRuntime station, string laneKey) => station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f; private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) => world.Recipes.Values .Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal)) .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe)) .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); private static string? GetStationProductionLaneKey(RecipeDefinition recipe) { if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal)) { return "refinery"; } if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal)) { return "fabrication"; } if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal)) { return "components"; } if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal)) { return "shipyard"; } return null; } private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) { var priority = (float)recipe.Priority; var expansionPressure = GetFactionExpansionPressure(world, station.FactionId); var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f; priority += recipe.Id switch { "ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory") ? -140f * MathF.Max(expansionPressure, fleetPressure) : 280f * MathF.Max(expansionPressure, fleetPressure), "hull-fabrication" => 180f * expansionPressure, "equipment-assembly" => 170f * expansionPressure, "gun-assembly" => 160f * expansionPressure, "command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly" => 220f * MathF.Max(expansionPressure, fleetPressure), "frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure), "destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure), "cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure), "ammo-fabrication" => -80f * expansionPressure, "trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly" => -120f * expansionPressure, _ => 0f, }; return priority; } private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) { var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal) || string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal) || string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal); return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); } private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) { if (recipe.ShipOutputId is not null) { if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)) { return false; } if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal) || !FactionNeedsMoreWarships(world, station.FactionId)) { return false; } } if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount)) { return false; } return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount)); } private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) { if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { return false; } var requiredModule = GetStorageRequirement(itemDefinition.CargoKind); if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) { return false; } var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind); if (capacity <= 0.01f) { return false; } var used = station.Inventory .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind) .Sum(entry => entry.Value); return used + amount <= capacity + 0.001f; } private static void AddDemandOrder(ICollection desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase) { var current = GetInventoryAmount(station.Inventory, itemId); if (current >= targetAmount - 0.01f) { return; } var deficit = targetAmount - current; var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount); desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null)); } private static void AddSupplyOrder(ICollection desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase) { var current = GetInventoryAmount(station.Inventory, itemId); if (current <= triggerAmount + 0.01f) { return; } var surplus = current - reserveFloor; if (surplus <= 0.01f) { return; } desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor)); } private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection desiredOrders) { var existingOrders = world.MarketOrders .Where(order => order.StationId == station.Id && order.ConstructionSiteId is null) .ToList(); foreach (var desired in desiredOrders) { var order = existingOrders.FirstOrDefault(candidate => candidate.Kind == desired.Kind && candidate.ItemId == desired.ItemId && candidate.ConstructionSiteId is null); if (order is null) { order = new MarketOrderRuntime { Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}", FactionId = station.FactionId, StationId = station.Id, Kind = desired.Kind, ItemId = desired.ItemId, Amount = desired.Amount, RemainingAmount = desired.Amount, Valuation = desired.Valuation, ReserveThreshold = desired.ReserveThreshold, State = MarketOrderStateKinds.Open, }; world.MarketOrders.Add(order); station.MarketOrderIds.Add(order.Id); existingOrders.Add(order); continue; } order.RemainingAmount = desired.Amount; order.Valuation = desired.Valuation; order.ReserveThreshold = desired.ReserveThreshold; order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open; } foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId))) { order.RemainingAmount = 0f; order.State = MarketOrderStateKinds.Cancelled; } } private static bool HasRefineryCapability(StationRuntime station) => HasStationModules(station, "refinery-stack", "power-core", "bulk-bay"); private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection events) { if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition)) { return 0f; } var spawnPosition = new Vector3(station.Position.X + GetStationRadius(world, station) + 32f, station.Position.Y, station.Position.Z); var ship = new ShipRuntime { Id = $"ship-{world.Ships.Count + 1}", SystemId = station.SystemId, Definition = definition, FactionId = station.FactionId, Position = spawnPosition, TargetPosition = spawnPosition, SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition), DefaultBehavior = CreateSpawnedShipBehavior(definition, station), ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold), Health = definition.MaxHealth, }; world.Ships.Add(ship); if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction) { faction.ShipsBuilt += 1; } events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow)); return 1f; } private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId) { var militaryShipCount = world.Ships.Count(ship => ship.FactionId == factionId && string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal)); var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); var controlledSystems = GetFactionControlledSystemsCount(world, factionId); var expansionDeficit = Math.Max(0, targetSystems - controlledSystems); var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3)); return militaryShipCount < targetWarships; } private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) { return world.Claims .Where(claim => claim.State != ClaimStateKinds.Destroyed) .Select(claim => claim.SystemId) .Distinct(StringComparer.Ordinal) .Count(systemId => FactionControlsSystem(world, factionId, systemId)); } private static float GetFactionExpansionPressure(SimulationWorld world, string factionId) { var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)); var controlledSystems = GetFactionControlledSystemsCount(world, factionId); var deficit = Math.Max(0, targetSystems - controlledSystems); return Math.Clamp(deficit / (float)targetSystems, 0f, 1f); } private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) { var buildableLocations = world.Claims .Where(claim => claim.SystemId == systemId && claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active) .ToList(); if (buildableLocations.Count == 0) { return false; } var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId); return ownedLocations > (buildableLocations.Count / 2f); } private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new() { CurrentSystemId = station.SystemId, SpaceLayer = SpaceLayerKinds.LocalSpace, CurrentNodeId = station.NodeId, CurrentBubbleId = station.BubbleId, LocalPosition = position, SystemPosition = position, MovementRegime = MovementRegimeKinds.LocalFlight, }; private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station) { if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal)) { return new DefaultBehaviorRuntime { Kind = "idle", }; } var patrolRadius = station.Radius + 90f; return new DefaultBehaviorRuntime { Kind = "patrol", PatrolPoints = [ new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z), new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius), new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z), new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius), ], }; } private static float GetStationProductionThroughput(StationRuntime station, RecipeDefinition recipe) { if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal)) { return Math.Max(1, CountModules(station.InstalledModules, "refinery-stack")); } if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal)) { return Math.Max(1, CountModules(station.InstalledModules, "fabricator-array")); } if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal)) { return Math.Max(1, CountModules(station.InstalledModules, "component-factory")); } if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal)) { return Math.Max(1, CountModules(station.InstalledModules, "ship-factory")); } return 1f; } private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); }