Files
space-game/apps/backend/Stations/Simulation/InfrastructureSimulationService.cs
Jonathan Bourdon 766fef1c8f chore: add .editorconfig and consistent formatting for backend projects
Adds an `.editorconfig` file with C# and project-specific conventions. Applies consistent indentation and formatting across backend handlers, runtime models, and AI services.
2026-03-24 02:55:15 -04:00

910 lines
35 KiB
C#

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<SimulationEventRecord> 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<SimulationEventRecord> 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<ModuleExpansionCandidate> GetModuleExpansionCandidates(
SimulationWorld world,
StationRuntime station,
FactionEconomySnapshot economy)
{
var role = StationSimulationService.DetermineStationRole(station);
var candidates = new Dictionary<string, float>(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<string> 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<string> 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",
"graphene" => "graphene",
"siliconwafers" => "siliconwafers",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"quantumtubes" => "quantumtubes",
"antimattercells" => "antimattercells",
"superfluidcoolant" => "superfluidcoolant",
_ => 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<string, float> 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<string, float> 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<string, float> 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<string, float> 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<string, float> GetOutstandingConstructionDemand(SimulationWorld world, string factionId)
{
var demand = new Dictionary<string, float>(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 :
string.Equals(commodityId, "graphene", StringComparison.Ordinal) ? 240f :
string.Equals(commodityId, "siliconwafers", StringComparison.Ordinal) ? 240f :
string.Equals(commodityId, "quantumtubes", StringComparison.Ordinal) ? 240f :
string.Equals(commodityId, "antimattercells", StringComparison.Ordinal) ? 240f :
string.Equals(commodityId, "superfluidcoolant", StringComparison.Ordinal) ? 240f :
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));
}
}