Files
space-game/apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs
Jonathan Bourdon 5df5111463 feat: migrate simulation to physically-based unit system
Replace arbitrary game units with real-world measurements throughout
the simulation and viewer: planet orbits in AU, sizes in km, galaxy
positions in light-years. Add SimulationUnits helpers for conversions,
separate WarpSpeed from FtlSpeed for ships, fix FTL transit progress
to use galaxy-space distances, overhaul Lagrange point placement with
Hill sphere approximation, and update the viewer to scale and format
all distances correctly. Ships in FTL transit now render in galaxy view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 14:21:20 -04:00

649 lines
25 KiB
C#

namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
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)
{
var cargoItemId = ship.Definition.CargoItemId;
return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId);
}
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))
{
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;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
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";
}
if (ship.Definition.CargoItemId is not null)
{
AddInventory(ship.Inventory, ship.Definition.CargoItemId, 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 && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
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;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
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";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
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 cargoItemId = ship.Definition.CargoItemId;
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
if (cargoItemId is not null)
{
var accepted = TryAddStationInventory(world, station, cargoItemId, moved);
RemoveInventory(ship.Inventory, cargoItemId, accepted);
moved = accepted;
}
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
if (faction is not null && cargoItemId == "ore")
{
faction.OreMined += moved;
faction.Credits += moved * 0.4f;
}
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 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";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Loading;
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var cargoItemId = ship.Definition.CargoItemId;
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = cargoItemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, cargoItemId));
if (cargoItemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, cargoItemId, moved);
AddInventory(ship.Inventory, cargoItemId, moved);
}
return cargoItemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, cargoItemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateRefuel(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 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 (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Refueling;
var refuelTarget = GetShipRefuelTarget(ship, world);
BeginTrackedAction(ship, "refueling", MathF.Max(0f, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel")));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel"));
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
if (moved > 0.01f)
{
RemoveInventory(station.Inventory, "fuel", moved);
AddInventory(ship.Inventory, "fuel", moved);
}
return !NeedsRefuel(ship, world) ? "refueled" : "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 (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
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";
}
station.InstalledModules.Add(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";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
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";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
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";
}
station.InstalledModules.Add(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 UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = ShipState.Blocked;
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || station.Population <= 0.01f)
{
ship.State = ShipState.Idle;
return "none";
}
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
var totalTransfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f)
{
return "none";
}
station.Population = MathF.Max(0f, station.Population - transfer);
ship.WorkerPopulation += transfer;
ship.State = ShipState.Loading;
BeginTrackedAction(ship, "loading", totalTransfer);
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
}
private string UpdateUnloadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = ShipState.Blocked;
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || ship.WorkerPopulation <= 0.01f)
{
ship.State = ShipState.Idle;
return "none";
}
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
var totalTransfer = transfer;
transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f)
{
return "none";
}
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
ship.State = ShipState.Unloading;
BeginTrackedAction(ship, "unloading", totalTransfer);
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
}
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;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
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";
}
private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
}