using SpaceGame.Simulation.Api.Contracts; using SpaceGame.Simulation.Api.Data; namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class SimulationEngine { private const float StationEnergyCellToEnergyRatio = 1f; private static bool HasShipModules(ShipDefinition definition, params string[] modules) => modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); private static bool CanTransportWorkers(ShipRuntime ship) => CountModules(ship.Definition.Modules, "habitat-ring") > 0; private static float GetWorkerTransportCapacity(ShipRuntime ship) => CountModules(ship.Definition.Modules, "habitat-ring") * 120f; private static void UpdateStationPower(SimulationWorld world, float deltaSeconds, ICollection events) { foreach (var station in world.Stations) { var previousEnergy = station.EnergyStored; GenerateStationEnergy(station, world, deltaSeconds); if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } } } private static void UpdateShipPower(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ICollection events) { var previousEnergy = ship.EnergyStored; GenerateShipEnergy(ship, world, deltaSeconds); if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f) { events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow)); } } private static void GenerateStationEnergy(StationRuntime station, SimulationWorld world, float deltaSeconds) { var powerCores = CountModules(station.InstalledModules, "power-core"); var tanks = CountModules(station.InstalledModules, "liquid-tank"); if (powerCores <= 0 || tanks <= 0) { station.EnergyStored = 0f; station.Inventory.Remove("fuel"); return; } var energyCapacity = powerCores * StationEnergyPerPowerCore; var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); if (desiredEnergy <= 0.01f) { station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); return; } var solarGenerated = MathF.Min(desiredEnergy, GetStationSolarGeneration(station, world) * deltaSeconds); if (solarGenerated > 0.01f) { station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + solarGenerated); desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); } if (desiredEnergy > 0.01f && fuelStored <= 0.01f) { var energyCells = GetInventoryAmount(station.Inventory, "energy-cell"); if (energyCells > 0.01f) { var consumedCells = MathF.Min(energyCells, desiredEnergy / StationEnergyCellToEnergyRatio); RemoveInventory(station.Inventory, "energy-cell", consumedCells); station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + (consumedCells * StationEnergyCellToEnergyRatio)); desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); } } if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank); return; } var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds); var requiredFuel = generated / StationFuelToEnergyRatio; var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * StationFuelToEnergyRatio; RemoveInventory(station.Inventory, "fuel", consumedFuel); station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); } private static float GetStationFuelCapacity(StationRuntime station) => CountModules(station.InstalledModules, "liquid-tank") * StationFuelPerTank; private static float GetStationEnergyCapacity(StationRuntime station) => CountModules(station.InstalledModules, "power-core") * StationEnergyPerPowerCore; private static float GetStationSolarGeneration(StationRuntime station, SimulationWorld world) => world.Balance.Energy.StationSolarCharge * (1f + CountModules(station.InstalledModules, "solar-array")); private static float GetStationStorageCapacity(StationRuntime station, string storageClass) { var baseCapacity = station.Definition.Storage.TryGetValue(storageClass, out var capacity) ? capacity : 0f; var extraBulkBays = Math.Max(0, CountModules(station.InstalledModules, "bulk-bay") - CountModules(station.Definition.Modules, "bulk-bay")); var extraLiquidTanks = Math.Max(0, CountModules(station.InstalledModules, "liquid-tank") - CountModules(station.Definition.Modules, "liquid-tank")); var extraGasTanks = Math.Max(0, CountModules(station.InstalledModules, "gas-tank") - CountModules(station.Definition.Modules, "gas-tank")); var extraContainerBays = Math.Max(0, CountModules(station.InstalledModules, "container-bay") - CountModules(station.Definition.Modules, "container-bay")); var moduleBonus = storageClass switch { "bulk-solid" => extraBulkBays * 1000f, "bulk-liquid" => extraLiquidTanks * 500f, "bulk-gas" => extraGasTanks * 500f, "container" => extraContainerBays * 800f, _ => 0f, }; return baseCapacity + moduleBonus; } private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var reactors = CountModules(ship.Definition.Modules, "reactor-core"); var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank"); if (reactors <= 0 || capacitors <= 0) { ship.EnergyStored = 0f; ship.Inventory.Remove("fuel"); return; } var energyCapacity = capacitors * CapacitorEnergyPerModule; var fuelCapacity = reactors * ShipFuelPerReactor; var fuelStored = GetInventoryAmount(ship.Inventory, "fuel"); var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored); if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) { ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity); ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity); return; } var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds); var requiredFuel = generated / ShipFuelToEnergyRatio; var consumedFuel = MathF.Min(requiredFuel, fuelStored); var actualGenerated = consumedFuel * ShipFuelToEnergyRatio; RemoveInventory(ship.Inventory, "fuel", consumedFuel); ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated); } private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount) { if (ship.EnergyStored + 0.0001f < amount) { return false; } ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount); return true; } private static bool TryConsumeStationEnergy(StationRuntime station, float amount) { if (station.EnergyStored + 0.0001f < amount) { return false; } station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount); return true; } private static int CountModules(IEnumerable modules, string moduleId) => modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal)); private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => inventory.TryGetValue(itemId, out var amount) ? amount : 0f; private static void AddInventory(IDictionary inventory, string itemId, float amount) { if (amount <= 0f) { return; } inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; } private static float RemoveInventory(IDictionary inventory, string itemId, float amount) { var current = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId); var removed = MathF.Min(current, amount); var remaining = current - removed; if (remaining <= 0.001f) { inventory.Remove(itemId); } else { inventory[itemId] = remaining; } return removed; } private static bool HasStationModules(StationRuntime station, params string[] modules) => modules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) => node.ItemId switch { "ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"), "gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"), _ => false, }; private static bool CanBuildClaimBeacon(ShipRuntime ship) => string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal); private static float GetShipFuelCapacity(ShipRuntime ship) => CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; private static float GetShipAvailableEnergyBudget(ShipRuntime ship) => ship.EnergyStored + (GetInventoryAmount(ship.Inventory, "fuel") * ShipFuelToEnergyRatio); private static float GetShipFuelReserve(ShipRuntime ship, float plannedFuel) { var capacity = GetShipFuelCapacity(ship); var reserveRatio = ship.Definition.CargoItemId == "gas" ? 0.4f : 0.3f; var reserve = MathF.Max(16f, MathF.Max(capacity * 0.18f, plannedFuel * reserveRatio)); return MathF.Min(capacity, reserve); } private static float EstimateFuelForEnergyDemand(ShipRuntime ship, float energyDemand) => MathF.Max(0f, energyDemand - ship.EnergyStored) / ShipFuelToEnergyRatio; private static float EstimateTimedEnergyUse(SimulationWorld world, float durationSeconds, float drainPerSecond) => MathF.Max(0f, durationSeconds) * drainPerSecond; private static float EstimateTravelEnergy( ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, string fromSystemId, Vector3 toPosition, string toSystemId) { if (!string.Equals(fromSystemId, toSystemId, StringComparison.Ordinal)) { var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId); var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition; var originSystemPosition = ResolveSystemGalaxyPosition(world, fromSystemId); var destinationSystemPosition = ResolveSystemGalaxyPosition(world, toSystemId); var ftlDistance = originSystemPosition.DistanceTo(destinationSystemPosition); var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f); return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain) + EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain) + EstimateInSystemTravelEnergy(ship, world, destinationEntryPosition, toPosition); } return EstimateInSystemTravelEnergy(ship, world, fromPosition, toPosition); } private static float EstimateInSystemTravelEnergy(ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, Vector3 toPosition) { var distance = fromPosition.DistanceTo(toPosition); if (distance <= world.Balance.ArrivalThreshold) { return 0f; } if (distance <= WarpEngageDistanceKilometers) { var localDuration = distance / MathF.Max(GetLocalTravelSpeed(ship), 0.01f); return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain); } var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); var warpDuration = distance / MathF.Max(GetWarpTravelSpeed(ship), 0.01f); return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain) + EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain); } private static float EstimateDockingEnergy(SimulationWorld world) => EstimateTimedEnergyUse(world, world.Balance.DockingDuration, world.Balance.Energy.MoveDrain) + EstimateTimedEnergyUse(world, 6f, world.Balance.Energy.IdleDrain); private static float EstimateUndockingEnergy(SimulationWorld world) => EstimateTimedEnergyUse(world, world.Balance.UndockingDuration, world.Balance.Energy.MoveDrain) + EstimateTimedEnergyUse(world, 4f, world.Balance.Energy.IdleDrain); private static float EstimateExtractionEnergy(ShipRuntime ship, SimulationWorld world) { var remainingCargo = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)); if (remainingCargo <= 0.01f) { return 0f; } var cycles = MathF.Ceiling(remainingCargo / MathF.Max(world.Balance.MiningRate, 0.01f)); return EstimateTimedEnergyUse(world, cycles * world.Balance.MiningCycleSeconds, world.Balance.Energy.MoveDrain) + EstimateTimedEnergyUse(world, cycles * 1.5f, world.Balance.Energy.IdleDrain); } private static float EstimateConstructionEnergy(ShipRuntime ship, SimulationWorld world, StationRuntime station) { var holdPosition = GetConstructionHoldPosition(station, ship.Id); var travelEnergy = EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, holdPosition, station.SystemId); var site = GetConstructionSiteForStation(world, station.Id); if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site)) { if (world.ModuleRecipes.TryGetValue(site.BlueprintId ?? string.Empty, out var siteRecipe)) { return travelEnergy + EstimateTimedEnergyUse(world, siteRecipe.Duration, world.Balance.Energy.IdleDrain); } return travelEnergy; } var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world); if (moduleId is not null && world.ModuleRecipes.TryGetValue(moduleId, out var recipe) && CanStartModuleConstruction(station, recipe)) { return travelEnergy + EstimateTimedEnergyUse(world, recipe.Duration, world.Balance.Energy.IdleDrain); } return travelEnergy; } private static float EstimateResourceHarvestEnergy(ShipRuntime ship, SimulationWorld world) { var cargoItemId = ship.Definition.CargoItemId; if (cargoItemId is null) { return 0f; } var requiredModule = cargoItemId == "gas" ? "gas-extractor" : "mining-turret"; var behavior = ship.DefaultBehavior; var refinery = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId); var node = behavior.NodeId is null ? world.Nodes .Where(candidate => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == cargoItemId && candidate.OreRemaining > 0.01f) .OrderByDescending(candidate => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f); if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) { return 0f; } var currentPosition = ship.Position; var currentSystemId = ship.SystemId; var energy = 0f; var cargoAmount = GetShipCargoAmount(ship); if (ship.DockedStationId == refinery.Id) { currentPosition = GetUndockTargetPosition(refinery, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); currentSystemId = refinery.SystemId; energy += EstimateUndockingEnergy(world); } if (cargoAmount > 0.01f) { energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId); return energy + EstimateDockingEnergy(world); } var holdPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f); energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, holdPosition, node.SystemId); energy += EstimateExtractionEnergy(ship, world); energy += EstimateTravelEnergy(ship, world, holdPosition, node.SystemId, refinery.Position, refinery.SystemId); energy += EstimateDockingEnergy(world); return energy; } private static float EstimateResourceReturnEnergy(ShipRuntime ship, SimulationWorld world) { var cargoItemId = ship.Definition.CargoItemId; if (cargoItemId is null) { return 0f; } var refinery = SelectBestBuyStation(world, ship, cargoItemId, ship.DefaultBehavior.StationId); if (refinery is null) { return 0f; } var currentPosition = ship.Position; var currentSystemId = ship.SystemId; return EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId) + EstimateDockingEnergy(world); } private static float EstimateTransportEnergy(ShipRuntime ship, SimulationWorld world) { var cargoItemId = ship.Definition.CargoItemId; if (cargoItemId is null) { return 0f; } var behavior = ship.DefaultBehavior; var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId); var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId); if (source is null && destination is null) { return 0f; } var cargoAmount = GetShipCargoAmount(ship); var currentPosition = ship.Position; var currentSystemId = ship.SystemId; if (ship.DockedStationId is not null) { var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); if (dockedStation is not null) { currentPosition = GetUndockTargetPosition(dockedStation, ship.AssignedDockingPadIndex, world.Balance.UndockDistance); currentSystemId = dockedStation.SystemId; } } var targetStation = cargoAmount > 0.01f ? destination : source; if (targetStation is null) { return ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f; } var energy = ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f; energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, targetStation.Position, targetStation.SystemId); return energy + EstimateDockingEnergy(world); } private static float EstimateShipMissionEnergyDemand(ShipRuntime ship, SimulationWorld world) => ship.DefaultBehavior.Kind switch { "auto-mine" or "auto-harvest-gas" => EstimateResourceHarvestEnergy(ship, world), "auto-supply-energy" => EstimateTransportEnergy(ship, world), "construct-station" when ship.DefaultBehavior.StationId is not null => world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) is { } station ? EstimateConstructionEnergy(ship, world, station) : 0f, _ when ship.ControllerTask.TargetPosition is { } targetPosition && ship.ControllerTask.TargetSystemId is { } targetSystemId => EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, targetPosition, targetSystemId), _ => 0f, }; private static float GetShipRefuelTarget(ShipRuntime ship, SimulationWorld world) { var capacity = GetShipFuelCapacity(ship); var missionFuel = EstimateFuelForEnergyDemand(ship, EstimateShipMissionEnergyDemand(ship, world)); var reserveFuel = GetShipFuelReserve(ship, missionFuel); return MathF.Min(capacity, missionFuel + reserveFuel); } internal static bool NeedsRefuel(ShipRuntime ship, SimulationWorld world) => GetInventoryAmount(ship.Inventory, "fuel") + 0.01f < GetShipRefuelTarget(ship, world); internal static bool NeedsEmergencyReturn(ShipRuntime ship, SimulationWorld world) { if (ship.DefaultBehavior.Kind is not "auto-mine" and not "auto-harvest-gas") { return false; } var returnEnergy = EstimateResourceReturnEnergy(ship, world); var reserveFuel = GetShipFuelReserve(ship, EstimateFuelForEnergyDemand(ship, returnEnergy)); var requiredBudget = returnEnergy + (reserveFuel * ShipFuelToEnergyRatio); return GetShipAvailableEnergyBudget(ship) + 0.01f < requiredBudget; } private static float ComputeWorkforceRatio(float population, float workforceRequired) { if (workforceRequired <= 0.01f) { return 1f; } var staffedRatio = MathF.Min(1f, population / workforceRequired); return 0.1f + (0.9f * staffedRatio); } private static string? GetStorageRequirement(string storageClass) => storageClass switch { "bulk-solid" => "bulk-bay", "bulk-liquid" => "liquid-tank", "bulk-gas" => "gas-tank", _ => null, }; private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount) { if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition)) { return 0f; } var storageClass = itemDefinition.Storage; var requiredModule = GetStorageRequirement(storageClass); if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal)) { return 0f; } var capacity = GetStationStorageCapacity(station, storageClass); if (capacity <= 0.01f) { return 0f; } var used = station.Inventory .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass) .Sum(entry => entry.Value); var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used)); if (accepted <= 0.01f) { return 0f; } AddInventory(station.Inventory, itemId, accepted); return accepted; } private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) => recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount); private static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) => world.ConstructionSites.FirstOrDefault(site => string.Equals(site.StationId, stationId, StringComparison.Ordinal) && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId) { if (site.StationId is not null && world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station) { return GetInventoryAmount(station.Inventory, itemId); } return GetInventoryAmount(site.DeliveredItems, itemId); } private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) => site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value); }