refactor(backend): modularize simulation engine

This commit is contained in:
2026-03-19 17:28:07 -04:00
parent 5c79946d57
commit 8d2a810f6b
12 changed files with 297 additions and 234 deletions

View File

@@ -0,0 +1,191 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.AI;
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class CommanderPlanningService
{
private const float FactionCommanderReplanInterval = 10f;
private const float ShipCommanderReplanInterval = 5f;
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
[
new ExpandTerritoryGoal(),
new EnsureWarFleetGoal(),
new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(),
];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[
new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(),
new SetIdleObjectiveAction(),
];
private static readonly GoapGoal<ShipPlanningState> _shipGoal = new AssignObjectiveGoal();
internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
// Faction commanders run first so their directives are available to ship commanders in the same tick.
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Faction)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateFactionCommander(engine, world, commander);
}
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateShipCommander(engine, world, commander);
}
}
private static void TickCommander(CommanderRuntime commander, float deltaSeconds)
{
if (commander.ReplanTimer > 0f)
{
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
}
}
private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
var state = BuildFactionPlanningState(world, commander.FactionId);
var actions = BuildFactionActions(world);
// Clear stale directives — actions will re-assert what is still needed.
commander.ActiveDirectives.Clear();
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
.Where(x => x.priority > 0f)
.OrderByDescending(x => x.priority)
.ToList();
commander.LastPlanningState = state;
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
foreach (var (goal, _) in rankedGoals.Take(3))
{
var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(engine, world, commander);
}
}
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = ShipCommanderReplanInterval;
commander.NeedsReplan = false;
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null)
{
return;
}
var state = BuildShipPlanningState(world, ship, commander);
var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions);
if (plan?.CurrentAction is { } action)
{
commander.ActiveGoalName = _shipGoal.Name;
commander.ActiveActionName = action.Name;
action.Execute(engine, world, commander);
}
}
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
{
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
return new FactionPlanningState
{
MilitaryShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
MinerShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)),
TransportShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)),
ConstructorShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
};
}
private static ShipPlanningState BuildShipPlanningState(
SimulationWorld world,
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = world.Commanders.FirstOrDefault(c =>
c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
var actions = new List<GoapAction<FactionPlanningState>>();
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
actions.Add(new ExpandToSystemAction());
return actions;
}
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
}

View File

@@ -0,0 +1,334 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class InfrastructureSimulationService
{
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)
{
// Expand storage before it becomes a bottleneck
const float StorageExpansionThreshold = 0.85f;
var storageExpansionCandidates = new[]
{
("solid", "module_arg_stor_solid_m_01"),
("liquid", "module_arg_stor_liquid_m_01"),
("container", "module_arg_stor_container_m_01"),
};
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
{
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 >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
var priorities = StationSimulationService.GetFactionExpansionPressure(world, station.FactionId) > 0f
? new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_arg_dock_m_01_lowtech", 2),
("module_gen_prod_energycells_01", 2),
}
: new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
};
foreach (var (moduleId, targetCount) in priorities)
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
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,
});
}
}
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));
}
}

View File

@@ -0,0 +1,295 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class OrbitalStateUpdater
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
internal OrbitalStateUpdater(OrbitalSimulationOptions orbitalSimulation)
{
_orbitalSimulation = orbitalSimulation;
}
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
{
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
var meanAnomaly = DegreesToRadians(planet.OrbitPhaseAtEpoch) + (timeSeconds * planet.OrbitSpeed);
var eccentricAnomaly = meanAnomaly
+ (eccentricity * MathF.Sin(meanAnomaly))
+ (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly));
var semiMajorAxis = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var semiMinorAxis = semiMajorAxis * MathF.Sqrt(MathF.Max(1f - (eccentricity * eccentricity), 0.05f));
var local = new Vector3(
semiMajorAxis * (MathF.Cos(eccentricAnomaly) - eccentricity),
0f,
semiMinorAxis * MathF.Sin(eccentricAnomaly));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitArgumentOfPeriapsis));
local = RotateAroundX(local, DegreesToRadians(planet.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitLongitudeOfAscendingNode));
return local;
}
private static Vector3 ComputeMoonOffset(MoonDefinition moon, float timeSeconds)
{
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch) + (timeSeconds * moon.OrbitSpeed);
var local = new Vector3(
MathF.Cos(angle) * moon.OrbitRadius,
0f,
MathF.Sin(angle) * moon.OrbitRadius);
local = RotateAroundX(local, DegreesToRadians(moon.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(moon.OrbitLongitudeOfAscendingNode));
return local;
}
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
{
var baseSpeed = 0.24f;
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180000f, 0.45f));
}
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
{
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
var orbit = new Vector3(
MathF.Cos(angle) * node.OrbitRadius,
0f,
MathF.Sin(angle) * node.OrbitRadius);
return RotateAroundX(orbit, node.OrbitInclination);
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
yield return new LagrangePointPlacement(
"L4",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
{
var planetMassProxy = EstimatePlanetMassRatio(planet);
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
return MathF.Max(minimumOffset, hillLikeOffset);
}
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
var densityFactor = planet.PlanetType switch
{
"gas-giant" => 0.24f,
"ice-giant" => 0.18f,
"oceanic" => 0.95f,
"ice" => 0.7f,
_ => 1f,
};
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
return earthMasses / 332_946f;
}
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
{
var length = MathF.Sqrt(value.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return value.Divide(length);
}
private static Vector3 RotateAroundX(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
value.X,
(value.Y * cos) - (value.Z * sin),
(value.Y * sin) + (value.Z * cos));
}
private static Vector3 RotateAroundY(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
(value.X * cos) + (value.Z * sin),
value.Y,
(-value.X * sin) + (value.Z * cos));
}
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
private static Vector3 Scale(Vector3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
private static float HashUnit(string input)
{
unchecked
{
var hash = 2166136261u;
foreach (var character in input)
{
hash ^= character;
hash *= 16777619u;
}
return (hash & 0x00FFFFFF) / (float)0x01000000;
}
}
internal void Update(SimulationWorld world)
{
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
var celestialsById = world.Celestials.ToDictionary(c => c.Id, StringComparer.Ordinal);
foreach (var system in world.Systems)
{
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
{
var star = system.Definition.Stars[starIndex];
var starNodeId = $"node-{system.Definition.Id}-star-{starIndex + 1}";
if (!celestialsById.TryGetValue(starNodeId, out var starNode))
{
continue;
}
if (star.OrbitRadius <= 0f)
{
starNode.Position = Vector3.Zero;
}
else
{
var angle = DegreesToRadians(star.OrbitPhaseAtEpoch) + (worldTimeSeconds * star.OrbitSpeed);
starNode.Position = new Vector3(MathF.Cos(angle) * star.OrbitRadius, 0f, MathF.Sin(angle) * star.OrbitRadius);
}
}
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
if (!celestialsById.TryGetValue(planetNodeId, out var planetNode))
{
continue;
}
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
planetNode.Position = planetPosition;
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet))
{
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
if (celestialsById.TryGetValue(lagrangeId, out var lagrangeNode))
{
lagrangeNode.Position = lagrange.Position;
}
}
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
{
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
if (!celestialsById.TryGetValue(moonId, out var moonNode))
{
continue;
}
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet.Moons[moonIndex], worldTimeSeconds));
}
}
}
foreach (var station in world.Stations)
{
if (station.CelestialId is null || !celestialsById.TryGetValue(station.CelestialId, out var anchorCelestial))
{
continue;
}
station.Position = anchorCelestial.Position;
}
foreach (var node in world.Nodes)
{
if (node.CelestialId is null || !celestialsById.TryGetValue(node.CelestialId, out var anchorCelestial))
{
continue;
}
node.Position = Add(anchorCelestial.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
}
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
continue;
}
var dockedPosition = GetShipDockedPosition(ship, station);
ship.Position = dockedPosition;
ship.TargetPosition = dockedPosition;
}
}
internal void SyncSpatialState(SimulationWorld world)
{
foreach (var ship in world.Ships)
{
ship.SpatialState.CurrentSystemId = ship.SystemId;
ship.SpatialState.LocalPosition = ship.Position;
ship.SpatialState.SystemPosition = ship.Position;
if (ship.SpatialState.Transit is not null)
{
ship.SpatialState.CurrentCelestialId = null;
continue;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
var nearestCelestial = world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id;
if (ship.DockedStationId is null)
{
continue;
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station?.CelestialId is not null)
{
ship.SpatialState.CurrentCelestialId = station.CelestialId;
}
}
}
private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position);
}

View File

@@ -0,0 +1,570 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.AI;
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class ShipControlService
{
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship);
private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander)
{
if (commander.ActiveBehavior is not null)
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
}
if (commander.ActiveOrder is null)
{
ship.Order = null;
}
else
{
ship.Order = new ShipOrderRuntime
{
Kind = commander.ActiveOrder.Kind,
Status = commander.ActiveOrder.Status,
DestinationSystemId = commander.ActiveOrder.DestinationSystemId,
DestinationPosition = commander.ActiveOrder.DestinationPosition,
};
}
if (commander.ActiveTask is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
TargetNodeId = commander.ActiveTask.TargetNodeId,
TargetPosition = commander.ActiveTask.TargetPosition,
TargetSystemId = commander.ActiveTask.TargetSystemId,
Threshold = commander.ActiveTask.Threshold,
};
}
}
private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander)
{
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex;
commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId;
if (ship.Order is null)
{
commander.ActiveOrder = null;
}
else
{
commander.ActiveOrder ??= new CommanderOrderRuntime
{
Kind = ship.Order.Kind,
DestinationSystemId = ship.Order.DestinationSystemId,
DestinationPosition = ship.Order.DestinationPosition,
};
commander.ActiveOrder.Status = ship.Order.Status;
commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition;
commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId;
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
{
SyncCommanderToShip(ship, commander);
}
if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued)
{
ship.Order.Status = OrderStatus.Accepted;
if (commander?.ActiveOrder is not null)
{
commander.ActiveOrder.Status = ship.Order.Status;
}
}
if (commander is not null)
{
SyncShipToCommander(ship, commander);
}
}
internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
TargetNodeId = ship.SpatialState.DestinationNodeId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
SyncCommanderTask(commander, ship.ControllerTask);
return;
}
_shipBehaviorStateMachine.Plan(engine, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
candidate.ItemId == resourceItemId &&
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 || !HasShipCapabilities(ship.Definition, requiredModule))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.NodeId ??= node.Id;
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
&& behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (ship.DockedStationId == refinery.Id)
{
if (GetShipCargoAmount(ship) > 0.01f)
{
behavior.Phase = "unload";
}
else if (behavior.Phase is "dock" or "unload")
{
behavior.Phase = "undock";
}
}
else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = extractionPosition,
Threshold = 5f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null)
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
phase switch
{
"dock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"undock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
Threshold = 8f,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (ship.DockedStationId is not null)
{
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
dockedStation.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(dockedStation, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
var isAtConstructionHold = ship.SystemId == station.SystemId
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
if (isAtConstructionHold)
{
if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
{
behavior.Phase = "deliver-to-site";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{
behavior.Phase = "build-site";
}
else if (site is not null)
{
behavior.Phase = "wait-for-materials";
}
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
{
behavior.Phase = "construct-module";
}
else
{
behavior.Phase = "wait-for-materials";
}
}
else if (behavior.Phase != "travel-to-station")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
behavior.Phase = "travel-to-station";
break;
}
}
internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not null)
{
commander.ActiveOrder = null;
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Completed,
TargetSystemId = ship.SystemId,
Threshold = 0f,
};
}
return;
}
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
if (commander.ActiveTask is not null)
{
commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed;
}
}
}
internal void TrackHistory(ShipRuntime ship, string controllerEvent)
{
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
var target = ship.ControllerTask.TargetEntityId
?? ship.ControllerTask.TargetSystemId
?? "none";
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
internal void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
internal static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = ControllerTaskKind.Idle,
Threshold = threshold,
};
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
{
"travel" => ControllerTaskKind.Travel,
"extract" => ControllerTaskKind.Extract,
"dock" => ControllerTaskKind.Dock,
"load" => ControllerTaskKind.Load,
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
_ => ControllerTaskKind.Idle,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{
if (commander is null)
{
return;
}
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}
}

View File

@@ -0,0 +1,458 @@
using SpaceGame.Api.Simulation.Model;
using SpaceGame.Api.Simulation.Support;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed partial class ShipTaskExecutionService
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < requiredSeconds)
{
return false;
}
ship.ActionTimer = 0f;
return true;
}
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
{
if (ship.TrackedActionKey == actionKey)
{
return;
}
ship.TrackedActionKey = actionKey;
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
}
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node, world))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return "cargo-full";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.ActionTimer = 0f;
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
ship.ActionTimer = 0f;
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
}
AddInventory(ship.Inventory, node.ItemId, mined);
node.OreRemaining -= mined;
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f)
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
return "none";
}
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
var distance = ship.Position.DistanceTo(padPosition);
if (distance > 4f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
foreach (var (itemId, amount) in ship.Inventory.ToList())
{
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
{
faction.OreMined += accepted;
faction.Credits += accepted * 0.4f;
}
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Loading;
var itemId = ship.ControllerTask.ItemId;
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = itemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, itemId));
if (itemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, itemId, moved);
AddInventory(ship.Inventory, itemId, moved);
}
return itemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, itemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null || ship.DefaultBehavior.ModuleId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{
return "none";
}
AddStationModule(world, station, station.ActiveConstruction.ModuleId);
station.ActiveConstruction = null;
return "module-constructed";
}
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.DeliveringConstruction;
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
if (site.StationId is not null)
{
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
{
return "none";
}
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
return "site-constructed";
}
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
ship.DockedStationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
ship.DockedStationId is not null
? GetShipDockedPosition(ship, station)
: GetConstructionHoldPosition(station, ship.Id);
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var undockTarget = station is null
? task.TargetPosition.Value
: GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
{
ship.Position = GetShipDockedPosition(ship, station);
}
return "none";
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > task.Threshold)
{
return "none";
}
if (station is not null)
{
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return "undocked";
}
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
}

View File

@@ -0,0 +1,313 @@
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed partial class ShipTaskExecutionService
{
private const float WarpEngageDistanceKilometers = 250_000f;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
{
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds),
};
}
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
// Resolve live position each frame — entities like stations orbit celestials and move every tick
var targetPosition = ResolveCurrentTargetPosition(world, task);
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
var distance = ship.Position.DistanceTo(targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
{
if (!HasShipCapabilities(ship.Definition, "ftl"))
{
ship.State = ShipState.Idle;
return "none";
}
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, task.TargetSystemId);
var destinationEntryPosition = destinationEntryCelestial?.Position ?? Vector3.Zero;
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryCelestial);
}
var currentCelestial = ResolveCurrentCelestial(world, ship);
if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
{
if (!HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
if (targetCelestial is not null
&& distance > WarpEngageDistanceKilometers
&& HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is not null)
{
return station.Position;
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial.Position;
}
}
return task.TargetPosition!.Value;
}
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial;
}
}
return world.Celestials
.Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
CelestialRuntime? targetCelestial,
float threshold)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
if (distance <= threshold)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = ship.Position;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
ship.ActionTimer = 0f;
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = targetCelestial.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping)
{
if (ship.State != ShipState.SpoolingWarp)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = ShipState.Warping;
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetCelestial.SystemId, targetPosition, targetCelestial)
: "none";
}
private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
var destinationNodeId = targetCelestial?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != ShipState.Ftl)
{
if (ship.State != ShipState.SpoolingFtl)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = ShipState.Ftl;
}
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance));
return transit.Progress >= 0.999f
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetCelestial)
: "none";
}
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "none";
}
}

View File

@@ -0,0 +1,816 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Systems.StationSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class SimulationProjectionService
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
internal SimulationProjectionService(OrbitalSimulationOptions orbitalSimulation)
{
_orbitalSimulation = orbitalSimulation;
}
internal WorldDelta BuildDelta(SimulationWorld world, long sequence, IReadOnlyList<SimulationEventRecord> events) =>
new(
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
false,
events,
BuildCelestialDeltas(world),
BuildNodeDeltas(world),
BuildStationDeltas(world),
BuildClaimDeltas(world),
BuildConstructionSiteDeltas(world),
BuildMarketOrderDeltas(world),
BuildPolicyDeltas(world),
BuildShipDeltas(world),
BuildFactionDeltas(world));
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
{
PrimeDeltaBaseline(world);
return new WorldSnapshot(
world.Label,
world.Seed,
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
world.Systems.Select(system => new SystemSnapshot(
system.Definition.Id,
system.Definition.Label,
ToDto(system.Position),
system.Definition.Stars.Select(star => new StarSnapshot(
star.Kind,
star.Color,
star.Glow,
star.Size,
star.OrbitRadius,
star.OrbitSpeed,
star.OrbitPhaseAtEpoch)).ToList(),
system.Definition.Planets.Select(planet => new PlanetSnapshot(
planet.Label,
planet.PlanetType,
planet.Shape,
planet.Moons.Select(moon => new MoonSnapshot(
moon.Label,
moon.Size,
moon.Color,
moon.OrbitRadius,
moon.OrbitSpeed,
moon.OrbitPhaseAtEpoch,
moon.OrbitInclination,
moon.OrbitLongitudeOfAscendingNode)).ToList(),
planet.OrbitRadius,
planet.OrbitSpeed,
planet.OrbitEccentricity,
planet.OrbitInclination,
planet.OrbitLongitudeOfAscendingNode,
planet.OrbitArgumentOfPeriapsis,
planet.OrbitPhaseAtEpoch,
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
world.Celestials.Select(ToCelestialDelta).Select(c => new CelestialSnapshot(
c.Id,
c.SystemId,
c.Kind,
c.OrbitalAnchor,
c.LocalSpaceRadius,
c.ParentNodeId,
c.OccupyingStructureId,
c.OrbitReferenceId)).ToList(),
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
node.Id,
node.SystemId,
node.LocalPosition,
node.CelestialId,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
station.Id,
station.Label,
station.Category,
station.SystemId,
station.LocalPosition,
station.CelestialId,
station.Color,
station.DockedShips,
station.DockedShipIds,
station.DockingPads,
station.CurrentProcesses,
station.Inventory,
station.FactionId,
station.CommanderId,
station.PolicySetId,
station.Population,
station.PopulationCapacity,
station.WorkforceRequired,
station.WorkforceEffectiveRatio,
station.StorageUsage,
station.InstalledModules,
station.MarketOrderIds)).ToList(),
world.Claims.Select(ToClaimDelta).Select(claim => new ClaimSnapshot(
claim.Id,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
claim.ActivatesAtUtc)).ToList(),
world.ConstructionSites.Select(site => ToConstructionSiteDelta(world, site)).Select(site => new ConstructionSiteSnapshot(
site.Id,
site.FactionId,
site.SystemId,
site.CelestialId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
site.ClaimId,
site.StationId,
site.State,
site.Progress,
site.Inventory,
site.RequiredItems,
site.DeliveredItems,
site.AssignedConstructorShipIds,
site.MarketOrderIds)).ToList(),
world.MarketOrders.Select(ToMarketOrderDelta).Select(order => new MarketOrderSnapshot(
order.Id,
order.FactionId,
order.StationId,
order.ConstructionSiteId,
order.Kind,
order.ItemId,
order.Amount,
order.RemainingAmount,
order.Valuation,
order.ReserveThreshold,
order.PolicySetId,
order.State)).ToList(),
world.Policies.Select(ToPolicySetDelta).Select(policy => new PolicySetSnapshot(
policy.Id,
policy.OwnerKind,
policy.OwnerId,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy)).ToList(),
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
ship.Id,
ship.Label,
ship.Kind,
ship.Class,
ship.SystemId,
ship.LocalPosition,
ship.LocalVelocity,
ship.TargetLocalPosition,
ship.State,
ship.OrderKind,
ship.DefaultBehaviorKind,
ship.BehaviorPhase,
ship.ControllerTaskKind,
ship.CommanderObjective,
ship.CelestialId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.CargoCapacity,
ship.TravelSpeed,
ship.TravelSpeedUnit,
ship.Inventory,
ship.FactionId,
ship.Health,
ship.History,
ship.CurrentAction,
ship.SpatialState)).ToList(),
world.Factions.Select(faction => ToFactionDelta(faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.PopulationTotal,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId,
faction.GoapState,
faction.GoapPriorities)).ToList());
}
public void PrimeDeltaBaseline(SimulationWorld world)
{
foreach (var node in world.Nodes)
{
node.LastDeltaSignature = BuildNodeSignature(node);
}
foreach (var celestial in world.Celestials)
{
celestial.LastDeltaSignature = BuildCelestialSignature(celestial);
}
foreach (var station in world.Stations)
{
station.LastDeltaSignature = BuildStationSignature(world, station);
}
foreach (var claim in world.Claims)
{
claim.LastDeltaSignature = BuildClaimSignature(claim);
}
foreach (var site in world.ConstructionSites)
{
site.LastDeltaSignature = BuildConstructionSiteSignature(site);
}
foreach (var order in world.MarketOrders)
{
order.LastDeltaSignature = BuildMarketOrderSignature(order);
}
foreach (var policy in world.Policies)
{
policy.LastDeltaSignature = BuildPolicySignature(policy);
}
foreach (var ship in world.Ships)
{
ship.LastDeltaSignature = BuildShipSignature(world, ship);
}
foreach (var faction in world.Factions)
{
faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id));
}
}
private static IReadOnlyList<ResourceNodeDelta> BuildNodeDeltas(SimulationWorld world)
{
var deltas = new List<ResourceNodeDelta>();
foreach (var node in world.Nodes)
{
var signature = BuildNodeSignature(node);
if (signature == node.LastDeltaSignature)
{
continue;
}
node.LastDeltaSignature = signature;
deltas.Add(ToNodeDelta(node));
}
return deltas;
}
private static IReadOnlyList<CelestialDelta> BuildCelestialDeltas(SimulationWorld world)
{
var deltas = new List<CelestialDelta>();
foreach (var celestial in world.Celestials)
{
var signature = BuildCelestialSignature(celestial);
if (signature == celestial.LastDeltaSignature)
{
continue;
}
celestial.LastDeltaSignature = signature;
deltas.Add(ToCelestialDelta(celestial));
}
return deltas;
}
private static IReadOnlyList<StationDelta> BuildStationDeltas(SimulationWorld world)
{
var deltas = new List<StationDelta>();
foreach (var station in world.Stations)
{
var signature = BuildStationSignature(world, station);
if (signature == station.LastDeltaSignature)
{
continue;
}
station.LastDeltaSignature = signature;
deltas.Add(ToStationDelta(world, station));
}
return deltas;
}
private static IReadOnlyList<ClaimDelta> BuildClaimDeltas(SimulationWorld world)
{
var deltas = new List<ClaimDelta>();
foreach (var claim in world.Claims)
{
var signature = BuildClaimSignature(claim);
if (signature == claim.LastDeltaSignature)
{
continue;
}
claim.LastDeltaSignature = signature;
deltas.Add(ToClaimDelta(claim));
}
return deltas;
}
private static IReadOnlyList<ConstructionSiteDelta> BuildConstructionSiteDeltas(SimulationWorld world)
{
var deltas = new List<ConstructionSiteDelta>();
foreach (var site in world.ConstructionSites)
{
var signature = BuildConstructionSiteSignature(site);
if (signature == site.LastDeltaSignature)
{
continue;
}
site.LastDeltaSignature = signature;
deltas.Add(ToConstructionSiteDelta(world, site));
}
return deltas;
}
private static IReadOnlyList<MarketOrderDelta> BuildMarketOrderDeltas(SimulationWorld world)
{
var deltas = new List<MarketOrderDelta>();
foreach (var order in world.MarketOrders)
{
var signature = BuildMarketOrderSignature(order);
if (signature == order.LastDeltaSignature)
{
continue;
}
order.LastDeltaSignature = signature;
deltas.Add(ToMarketOrderDelta(order));
}
return deltas;
}
private static IReadOnlyList<PolicySetDelta> BuildPolicyDeltas(SimulationWorld world)
{
var deltas = new List<PolicySetDelta>();
foreach (var policy in world.Policies)
{
var signature = BuildPolicySignature(policy);
if (signature == policy.LastDeltaSignature)
{
continue;
}
policy.LastDeltaSignature = signature;
deltas.Add(ToPolicySetDelta(policy));
}
return deltas;
}
private IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
{
var deltas = new List<ShipDelta>();
foreach (var ship in world.Ships)
{
var signature = BuildShipSignature(world, ship);
if (signature == ship.LastDeltaSignature)
{
continue;
}
ship.LastDeltaSignature = signature;
deltas.Add(ToShipDelta(world, ship));
}
return deltas;
}
private static IReadOnlyList<FactionDelta> BuildFactionDeltas(SimulationWorld world)
{
var deltas = new List<FactionDelta>();
foreach (var faction in world.Factions)
{
var commander = FindFactionCommander(world, faction.Id);
var signature = BuildFactionSignature(faction, commander);
if (signature == faction.LastDeltaSignature)
{
continue;
}
faction.LastDeltaSignature = signature;
deltas.Add(ToFactionDelta(faction, commander));
}
return deltas;
}
private static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.CelestialId}|{node.OreRemaining:0.###}";
private static string BuildCelestialSignature(CelestialRuntime celestial) =>
$"{celestial.SystemId}|{celestial.Kind.ToContractValue()}|{celestial.Position.X:0.###}|{celestial.Position.Y:0.###}|{celestial.Position.Z:0.###}|{celestial.LocalSpaceRadius:0.###}|{celestial.ParentNodeId}|{celestial.OccupyingStructureId}|{celestial.OrbitReferenceId}";
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
{
var processes = ToStationActionProgressSnapshots(world, station);
return string.Join("|",
station.SystemId,
station.CelestialId ?? "none",
station.CommanderId ?? "none",
station.PolicySetId ?? "none",
BuildInventorySignature(station.Inventory),
string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")),
string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)),
station.DockingPadAssignments.Count.ToString(),
station.Population.ToString("0.###"),
station.PopulationCapacity.ToString("0.###"),
station.WorkforceRequired.ToString("0.###"),
station.WorkforceEffectiveRatio.ToString("0.###"),
string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)),
string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)),
station.ActiveConstruction?.ModuleId ?? "none",
station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0",
string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}")));
}
private static string BuildClaimSignature(ClaimRuntime claim) =>
$"{claim.FactionId}|{claim.SystemId}|{claim.CelestialId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
$"{site.FactionId}|{site.SystemId}|{site.CelestialId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
private static string BuildPolicySignature(PolicySetRuntime policy) =>
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) =>
string.Join("|",
ship.SystemId,
ship.Position.X.ToString("0.###"),
ship.Position.Y.ToString("0.###"),
ship.Position.Z.ToString("0.###"),
ship.Velocity.X.ToString("0.###"),
ship.Velocity.Y.ToString("0.###"),
ship.Velocity.Z.ToString("0.###"),
ship.TargetPosition.X.ToString("0.###"),
ship.TargetPosition.Y.ToString("0.###"),
ship.TargetPosition.Z.ToString("0.###"),
ship.State.ToContractValue(),
ship.Order?.Kind ?? "none",
ship.DefaultBehavior.Kind,
ship.DefaultBehavior.Phase ?? "none",
ship.ControllerTask.Kind.ToContractValue(),
ship.SpatialState.CurrentCelestialId ?? "none",
ship.DockedStationId ?? "none",
ship.CommanderId ?? "none",
ship.PolicySetId ?? "none",
ship.SpatialState.SpaceLayer,
ship.SpatialState.CurrentCelestialId ?? "none",
ship.SpatialState.MovementRegime,
ship.SpatialState.DestinationNodeId ?? "none",
ship.SpatialState.Transit?.Regime ?? "none",
ship.SpatialState.Transit?.OriginNodeId ?? "none",
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"),
ship.TrackedActionKey ?? "none",
ship.TrackedActionTotal.ToString("0.###"),
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
? ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site).ToString("0.###")
: "0",
ship.Health.ToString("0.###"),
ship.ActionTimer.ToString("0.###"));
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",",
inventory
.Where(entry => entry.Value > 0.001f)
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.Select(entry => $"{entry.Key}:{entry.Value:0.###}"));
private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander)
{
var goapSig = commander?.LastGoalPriorities is { } prios
? string.Join(",", prios.Select(p => $"{p.Name}:{p.Priority:0.##}"))
: string.Empty;
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{goapSig}";
}
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id,
node.SystemId,
ToDto(node.Position),
node.CelestialId,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId);
private static CelestialDelta ToCelestialDelta(CelestialRuntime celestial) => new(
celestial.Id,
celestial.SystemId,
celestial.Kind.ToContractValue(),
ToDto(celestial.Position),
celestial.LocalSpaceRadius,
celestial.ParentNodeId,
celestial.OccupyingStructureId,
celestial.OrbitReferenceId);
private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new(
station.Id,
station.Label,
station.Category,
station.SystemId,
ToDto(station.Position),
station.CelestialId,
station.Color,
station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
GetDockingPadCount(station),
ToStationActionProgressSnapshots(world, station),
ToInventoryEntries(station.Inventory),
station.FactionId,
station.CommanderId,
station.PolicySetId,
station.Population,
station.PopulationCapacity,
station.WorkforceRequired,
station.WorkforceEffectiveRatio,
ToStationStorageUsageSnapshots(world, station),
station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(),
station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList());
private static IReadOnlyList<StationActionProgressSnapshot> ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) =>
GetStationProductionLanes(world, station)
.Select(laneKey =>
{
var recipe = SelectProductionRecipe(world, station, laneKey);
var timer = GetStationProductionTimer(station, laneKey);
var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f);
var progress = Math.Clamp(timer / duration, 0f, 1f);
return recipe is null || timer <= 0.01f
? null
: new StationActionProgressSnapshot(
laneKey,
recipe.Label,
progress,
duration * (1f - progress),
duration,
recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(),
recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList());
})
.Where(snapshot => snapshot is not null)
.Cast<StationActionProgressSnapshot>()
.ToList();
private static IReadOnlyList<StationStorageUsageSnapshot> ToStationStorageUsageSnapshots(SimulationWorld world, StationRuntime station)
{
string[] storageClasses = ["solid", "liquid", "container", "manufactured"];
return storageClasses
.Select(storageClass => new StationStorageUsageSnapshot(
storageClass,
station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass)
.Sum(entry => entry.Value),
GetStationStorageCapacity(station, storageClass)))
.Where(snapshot => snapshot.Capacity > 0.01f)
.ToList();
}
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
claim.Id,
claim.FactionId,
claim.SystemId,
claim.CelestialId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
claim.ActivatesAtUtc);
private static ConstructionSiteDelta ToConstructionSiteDelta(SimulationWorld world, ConstructionSiteRuntime site) => new(
site.Id,
site.FactionId,
site.SystemId,
site.CelestialId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
site.ClaimId,
site.StationId,
site.State,
GetConstructionSiteProgress(world, site),
ToInventoryEntries(site.Inventory),
ToInventoryEntries(site.RequiredItems),
ToInventoryEntries(site.DeliveredItems),
site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
private static float GetConstructionSiteProgress(SimulationWorld world, ConstructionSiteRuntime site)
{
if (site.BlueprintId is not null
&& world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)
&& recipe.Duration > 0.01f)
{
return Math.Clamp(site.Progress / recipe.Duration, 0f, 1f);
}
return Math.Clamp(site.Progress, 0f, 1f);
}
private static MarketOrderDelta ToMarketOrderDelta(MarketOrderRuntime order) => new(
order.Id,
order.FactionId,
order.StationId,
order.ConstructionSiteId,
order.Kind,
order.ItemId,
order.Amount,
order.RemainingAmount,
order.Valuation,
order.ReserveThreshold,
order.PolicySetId,
order.State);
private static PolicySetDelta ToPolicySetDelta(PolicySetRuntime policy) => new(
policy.Id,
policy.OwnerKind,
policy.OwnerId,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy);
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship)
{
var commander = ship.CommanderId is null ? null
: world.Commanders.FirstOrDefault(c => c.Id == ship.CommanderId && c.Kind == CommanderKind.Ship);
return new ShipDelta(
ship.Id,
ship.Definition.Label,
ship.Definition.Kind,
ship.Definition.Class,
ship.SystemId,
ToDto(ship.Position),
ToDto(ship.Velocity),
ToDto(ship.TargetPosition),
ship.State.ToContractValue(),
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.DefaultBehavior.Phase,
ship.ControllerTask.Kind.ToContractValue(),
commander?.ActiveActionName,
ship.SpatialState.CurrentCelestialId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.Definition.CargoCapacity,
ToShipTravelSpeed(ship).Speed,
ToShipTravelSpeed(ship).Unit,
ToInventoryEntries(ship.Inventory),
ship.FactionId,
ship.Health,
ship.History.ToList(),
ToShipActionProgressSnapshot(world, ship),
ToShipSpatialStateSnapshot(ship.SpatialState));
}
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
{
var progress = ship.State switch
{
ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)),
ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)),
ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)),
ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)),
ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)),
ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)),
ShipState.Loading or ShipState.Unloading => null,
ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null
? null
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
? null
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, ShipTaskExecutionService.GetRemainingConstructionDelivery(world, site)),
_ => null,
};
return progress;
}
private static (float Speed, string Unit) ToShipTravelSpeed(ShipRuntime ship)
{
return ship.SpatialState.MovementRegime switch
{
MovementRegimeKinds.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
MovementRegimeKinds.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
};
}
private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) =>
new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f));
private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount)
{
if (totalAmount <= 0.01f)
{
return null;
}
var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f);
return new ShipActionProgressSnapshot(label, progress);
}
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
inventory
.Where(entry => entry.Value > 0.001f)
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.Select(entry => new InventoryEntry(entry.Key, entry.Value))
.ToList();
private static FactionDelta ToFactionDelta(FactionRuntime faction, CommanderRuntime? commander)
{
FactionGoapStateSnapshot? goapState = null;
IReadOnlyList<FactionGoapPrioritySnapshot>? goapPriorities = null;
if (commander?.LastPlanningState is { } ps)
{
goapState = new FactionGoapStateSnapshot(
ps.MilitaryShipCount,
ps.MinerShipCount,
ps.TransportShipCount,
ps.ConstructorShipCount,
ps.ControlledSystemCount,
ps.TargetSystemCount,
ps.HasShipFactory,
ps.OreStockpile,
ps.RefinedMetalsStockpile);
}
if (commander?.LastGoalPriorities is { } prios)
{
goapPriorities = prios.Select(p => new FactionGoapPrioritySnapshot(p.Name, p.Priority)).ToList();
}
return new FactionDelta(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.PopulationTotal,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId,
goapState,
goapPriorities);
}
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
state.SpaceLayer,
state.CurrentSystemId,
state.CurrentCelestialId,
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
state.MovementRegime,
state.DestinationNodeId,
state.Transit is null ? null : new ShipTransitSnapshot(
state.Transit.Regime,
state.Transit.OriginNodeId,
state.Transit.DestinationNodeId,
state.Transit.StartedAtUtc,
state.Transit.ArrivalDueAtUtc,
state.Transit.Progress));
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
}

View File

@@ -0,0 +1,130 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.ShipControlService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class StationLifecycleService
{
private const float WaterConsumptionPerWorkerPerSecond = 0.004f;
private const float PopulationGrowthPerSecond = 0.012f;
private const float PopulationAttritionPerSecond = 0.018f;
private readonly StationSimulationService _stationSimulation;
internal StationLifecycleService(StationSimulationService stationSimulation)
{
_stationSimulation = stationSimulation;
}
internal 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);
_stationSimulation.ReviewStationMarketOrders(world, station);
_stationSimulation.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, "module_arg_hab_m_01");
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);
}
internal static 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 ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
{
CurrentSystemId = station.SystemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentCelestialId = station.CelestialId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
{
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime { Kind = "idle" };
}
var patrolRadius = station.Radius + 90f;
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints =
[
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius),
new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius),
],
};
}
}

View File

@@ -0,0 +1,354 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.CommanderPlanningService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class StationSimulationService
{
internal const int StrategicControlTargetSystems = 5;
internal 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, "module_gen_prod_hullparts_01") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
? 90f
: 0f;
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "hullparts", 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, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
ReconcileStationMarketOrders(world, station, desiredOrders);
}
internal 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(world, station))
{
var recipe = SelectProductionRecipe(world, station, laneKey);
if (recipe is null)
{
station.ProductionLaneTimers[laneKey] = 0f;
continue;
}
var throughput = GetStationProductionThroughput(world, 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 += StationLifecycleService.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;
}
}
}
internal static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
{
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
{
continue;
}
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
{
continue;
}
yield return moduleId;
}
}
internal static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
internal static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
world.Recipes.Values
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
recipe.RequiredModules.FirstOrDefault(moduleId =>
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var laneModuleId = GetStationProductionLaneKey(world, recipe);
if (laneModuleId is null)
{
return 1f;
}
return Math.Max(1, CountModules(station.InstalledModules, laneModuleId));
}
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var priority = (float)recipe.Priority;
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure);
return priority;
}
private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
{
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
if (outputItemIds.Contains("hullparts"))
{
return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure);
}
if (outputItemIds.Contains("refinedmetals"))
{
return 180f * expansionPressure;
}
if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"]))
{
return 170f * expansionPressure;
}
if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"]))
{
return 160f * expansionPressure;
}
return recipe.Id switch
{
"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,
};
}
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal);
return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
}
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
if (recipe.ShipOutputId is not null)
{
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
{
return false;
}
if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)
|| !FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships"))
{
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 bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
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;
}
}
internal 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);
}
internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
{
return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id));
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var totalLagrangePoints = world.Celestials.Count(node =>
node.SystemId == systemId &&
node.Kind == SpatialNodeKind.LagrangePoint);
if (totalLagrangePoints == 0)
{
return false;
}
var ownedLocations = world.Claims.Count(claim =>
claim.SystemId == systemId &&
claim.FactionId == factionId &&
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active);
return ownedLocations > (totalLagrangePoints / 2f);
}
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
}