using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Stations.Simulation; internal sealed class InfrastructureSimulationService { private const float CommodityTargetLevelSeconds = 240f; private const float EnergyTargetLevelSeconds = 240f; internal void UpdateClaims(SimulationWorld world, ICollection events) { foreach (var claim in world.Claims) { if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f) { if (claim.State != ClaimStateKinds.Destroyed) { claim.State = ClaimStateKinds.Destroyed; events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc)); } foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id)) { site.State = ConstructionSiteStateKinds.Destroyed; } continue; } if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc) { claim.State = ClaimStateKinds.Active; events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc)); } } } internal void UpdateConstructionSites(SimulationWorld world, ICollection events) { foreach (var site in world.ConstructionSites) { if (site.State == ConstructionSiteStateKinds.Destroyed) { continue; } var claim = site.ClaimId is null ? null : world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId); if (claim?.State == ClaimStateKinds.Destroyed) { site.State = ConstructionSiteStateKinds.Destroyed; continue; } if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned) { site.State = ConstructionSiteStateKinds.Active; events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc)); } foreach (var orderId in site.MarketOrderIds) { var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required)) { continue; } var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId)); order.RemainingAmount = remaining; order.State = remaining <= 0.01f ? MarketOrderStateKinds.Filled : remaining < order.Amount ? MarketOrderStateKinds.PartiallyFilled : MarketOrderStateKinds.Open; } } } internal static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) { if (station.ActiveConstruction is not null) { return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) && string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal); } if (!CanStartModuleConstruction(station, recipe)) { return false; } foreach (var input in recipe.Inputs) { RemoveInventory(station.Inventory, input.ItemId, input.Amount); } station.ActiveConstruction = new ModuleConstructionRuntime { ModuleId = recipe.ModuleId, RequiredSeconds = recipe.Duration, AssignedConstructorShipId = shipId, }; return true; } internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) { 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"), }) { var capacity = GetStationStorageCapacity(station, storageClass); if (capacity <= 0.01f) { continue; } var used = station.Inventory .Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass) .Sum(entry => entry.Value); if (used / capacity >= 0.65f) { yield return moduleId; } } } 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 + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(objectiveCommodityId)) + (marginalOutputRate * 900f) + constructionImpact; if (currentCount == 0) { score += 80f; } if (commodity.LevelSeconds < GetTargetLevelSeconds(objectiveCommodityId)) { score += MathF.Max(0f, GetTargetLevelSeconds(objectiveCommodityId) - commodity.LevelSeconds) * 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 + CommodityOperationalSignal.ComputeNeedScore(energy, EnergyTargetLevelSeconds) * 0.5f + constructionImpact + readinessUnlock; if (currentCount == 0) { score += 70f; } if (energy.LevelSeconds < EnergyTargetLevelSeconds) { score += MathF.Max(0f, EnergyTargetLevelSeconds - energy.LevelSeconds) * 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); feasibility *= CommodityOperationalSignal.ComputeFeasibilityFactor( inputCommodity, GetTargetLevelSeconds(input.ItemId)); } } 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 && commodity.Level is not CommodityLevelKind.Critical) { 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); private static float GetTargetLevelSeconds(string commodityId) => string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds : string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f : CommodityTargetLevelSeconds; internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site) { var nextModuleId = GetNextStationModuleToBuild(station, world); foreach (var orderId in site.MarketOrderIds) { var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId); if (order is not null) { order.State = MarketOrderStateKinds.Cancelled; order.RemainingAmount = 0f; world.MarketOrders.Remove(order); } station.MarketOrderIds.Remove(orderId); } site.MarketOrderIds.Clear(); site.Inventory.Clear(); site.DeliveredItems.Clear(); site.RequiredItems.Clear(); site.AssignedConstructorShipIds.Clear(); site.Progress = 0f; if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe)) { site.State = ConstructionSiteStateKinds.Completed; site.BlueprintId = null; return; } site.BlueprintId = nextModuleId; site.State = ConstructionSiteStateKinds.Active; foreach (var input in recipe.Inputs) { site.RequiredItems[input.ItemId] = input.Amount; site.DeliveredItems[input.ItemId] = 0f; var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}"; site.MarketOrderIds.Add(orderId); station.MarketOrderIds.Add(orderId); world.MarketOrders.Add(new MarketOrderRuntime { Id = orderId, FactionId = station.FactionId, StationId = station.Id, ConstructionSiteId = site.Id, Kind = MarketOrderKinds.Buy, ItemId = input.ItemId, Amount = input.Amount, RemainingAmount = input.Amount, Valuation = 1f, State = MarketOrderStateKinds.Open, }); } } 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; internal static int? ReserveDockingPad(StationRuntime station, string shipId) { if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing && !string.IsNullOrEmpty(existing.Value)) { return existing.Key; } var padCount = GetDockingPadCount(station); for (var padIndex = 0; padIndex < padCount; padIndex += 1) { if (station.DockingPadAssignments.ContainsKey(padIndex)) { continue; } station.DockingPadAssignments[padIndex] = shipId; return padIndex; } return null; } internal static void ReleaseDockingPad(StationRuntime station, string shipId) { var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)); if (!string.IsNullOrEmpty(assignment.Value)) { station.DockingPadAssignments.Remove(assignment.Key); } } internal static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex) { var padCount = Math.Max(1, GetDockingPadCount(station)); var angle = ((MathF.PI * 2f) / padCount) * padIndex; var radius = station.Radius + 18f; return new Vector3( station.Position.X + (MathF.Cos(angle) * radius), station.Position.Y, station.Position.Z + (MathF.Sin(angle) * radius)); } internal static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId) { var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); var angle = (hash % 360) * (MathF.PI / 180f); var radius = station.Radius + 24f; return new Vector3( station.Position.X + (MathF.Cos(angle) * radius), station.Position.Y, station.Position.Z + (MathF.Sin(angle) * radius)); } internal static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance) { if (padIndex is null) { return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); } var pad = GetDockingPadPosition(station, padIndex.Value); var dx = pad.X - station.Position.X; var dz = pad.Z - station.Position.Z; var length = MathF.Sqrt((dx * dx) + (dz * dz)); if (length <= 0.001f) { return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z); } var scale = distance / length; return new Vector3( pad.X + (dx * scale), station.Position.Y, pad.Z + (dz * scale)); } internal static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) => ship.AssignedDockingPadIndex is int padIndex ? GetDockingPadPosition(station, padIndex) : station.Position; internal static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId) { var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); var angle = (hash % 360) * (MathF.PI / 180f); var radius = station.Radius + 78f; return new Vector3( station.Position.X + (MathF.Cos(angle) * radius), station.Position.Y, station.Position.Z + (MathF.Sin(angle) * radius)); } internal static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius) { var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); var angle = (hash % 360) * (MathF.PI / 180f); return new Vector3( nodePosition.X + (MathF.Cos(angle) * radius), nodePosition.Y, nodePosition.Z + (MathF.Sin(angle) * radius)); } }