refactor(backend): modularize simulation engine
This commit is contained in:
191
apps/backend/Simulation/Systems/CommanderPlanningService.cs
Normal file
191
apps/backend/Simulation/Systems/CommanderPlanningService.cs
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
295
apps/backend/Simulation/Systems/OrbitalStateUpdater.cs
Normal file
295
apps/backend/Simulation/Systems/OrbitalStateUpdater.cs
Normal 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);
|
||||
}
|
||||
570
apps/backend/Simulation/Systems/ShipControlService.cs
Normal file
570
apps/backend/Simulation/Systems/ShipControlService.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
313
apps/backend/Simulation/Systems/ShipTaskExecutionService.cs
Normal file
313
apps/backend/Simulation/Systems/ShipTaskExecutionService.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
816
apps/backend/Simulation/Systems/SimulationProjectionService.cs
Normal file
816
apps/backend/Simulation/Systems/SimulationProjectionService.cs
Normal 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);
|
||||
}
|
||||
130
apps/backend/Simulation/Systems/StationLifecycleService.cs
Normal file
130
apps/backend/Simulation/Systems/StationLifecycleService.cs
Normal 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),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
354
apps/backend/Simulation/Systems/StationSimulationService.cs
Normal file
354
apps/backend/Simulation/Systems/StationSimulationService.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user