500 lines
20 KiB
C#
500 lines
20 KiB
C#
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<SimulationEventRecord> events)
|
|
{
|
|
var factionPopulation = new Dictionary<string, float>(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<SimulationEventRecord> 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<DesiredMarketOrder>();
|
|
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<SimulationEventRecord> 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<string> 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)
|
|
|| !CanLaunchShipFromStation(station))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(shipDefinition.Role, "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<DesiredMarketOrder> 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<DesiredMarketOrder> 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<DesiredMarketOrder> 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<SimulationEventRecord> 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 CanLaunchShipFromStation(StationRuntime station) =>
|
|
HasStationModules(station, "power-core", "ship-factory", "container-bay", "dock-bay-small");
|
|
|
|
private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId)
|
|
{
|
|
var militaryShipCount = world.Ships.Count(ship =>
|
|
ship.FactionId == factionId
|
|
&& string.Equals(ship.Definition.Role, "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.Role, "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);
|
|
}
|