Files
space-game/apps/backend/Simulation/SimulationEngine.cs

1028 lines
33 KiB
C#

using SpaceGame.Simulation.Api.Data;
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationEngine
{
private const float ShipFuelToEnergyRatio = 12f;
private const float StationFuelToEnergyRatio = 18f;
private const float CapacitorEnergyPerModule = 120f;
private const float StationEnergyPerPowerCore = 480f;
private const float ShipFuelPerReactor = 100f;
private const float StationFuelPerTank = 500f;
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{
var events = new List<SimulationEventRecord>();
UpdateStationPower(world, deltaSeconds, events);
UpdateStations(world, deltaSeconds, events);
foreach (var ship in world.Ships)
{
var previousPosition = ship.Position;
var previousState = ship.State;
var previousBehavior = ship.DefaultBehavior.Kind;
var previousTask = ship.ControllerTask.Kind;
UpdateShipPower(ship, world, deltaSeconds, events);
RefreshControlLayers(ship);
PlanControllerTask(ship, world);
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
TrackHistory(ship);
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
}
world.GeneratedAtUtc = DateTimeOffset.UtcNow;
return new WorldDelta(
sequence,
world.TickIntervalMs,
world.GeneratedAtUtc,
false,
events,
BuildNodeDeltas(world),
BuildStationDeltas(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.GeneratedAtUtc,
world.Systems.Select((system) => new SystemSnapshot(
system.Definition.Id,
system.Definition.Label,
ToDto(system.Position),
system.Definition.StarKind,
system.Definition.StarCount,
system.Definition.StarColor,
system.Definition.StarSize,
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
planet.Label,
planet.PlanetType,
planet.Shape,
planet.MoonCount,
planet.OrbitRadius,
planet.OrbitSpeed,
planet.OrbitEccentricity,
planet.OrbitInclination,
planet.OrbitLongitudeOfAscendingNode,
planet.OrbitArgumentOfPeriapsis,
planet.OrbitPhaseAtEpoch,
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot(
node.Id,
node.SystemId,
node.LocalPosition,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot(
station.Id,
station.Label,
station.Category,
station.SystemId,
station.LocalPosition,
station.Color,
station.DockedShips,
station.EnergyStored,
station.Inventory,
station.FactionId)).ToList(),
world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
ship.Id,
ship.Label,
ship.Role,
ship.ShipClass,
ship.SystemId,
ship.LocalPosition,
ship.LocalVelocity,
ship.TargetLocalPosition,
ship.State,
ship.OrderKind,
ship.DefaultBehaviorKind,
ship.ControllerTaskKind,
ship.CargoCapacity,
ship.EnergyStored,
ship.Inventory,
ship.FactionId,
ship.Health,
ship.History)).ToList(),
world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost)).ToList());
}
public void PrimeDeltaBaseline(SimulationWorld world)
{
foreach (var node in world.Nodes)
{
node.LastDeltaSignature = BuildNodeSignature(node);
}
foreach (var station in world.Stations)
{
station.LastDeltaSignature = BuildStationSignature(station);
}
foreach (var ship in world.Ships)
{
ship.LastDeltaSignature = BuildShipSignature(ship);
}
foreach (var faction in world.Factions)
{
faction.LastDeltaSignature = BuildFactionSignature(faction);
}
}
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<StationDelta> BuildStationDeltas(SimulationWorld world)
{
var deltas = new List<StationDelta>();
foreach (var station in world.Stations)
{
var signature = BuildStationSignature(station);
if (signature == station.LastDeltaSignature)
{
continue;
}
station.LastDeltaSignature = signature;
deltas.Add(ToStationDelta(station));
}
return deltas;
}
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
{
var deltas = new List<ShipDelta>();
foreach (var ship in world.Ships)
{
var signature = BuildShipSignature(ship);
if (signature == ship.LastDeltaSignature)
{
continue;
}
ship.LastDeltaSignature = signature;
deltas.Add(ToShipDelta(ship));
}
return deltas;
}
private static IReadOnlyList<FactionDelta> BuildFactionDeltas(SimulationWorld world)
{
var deltas = new List<FactionDelta>();
foreach (var faction in world.Factions)
{
var signature = BuildFactionSignature(faction);
if (signature == faction.LastDeltaSignature)
{
continue;
}
faction.LastDeltaSignature = signature;
deltas.Add(ToFactionDelta(faction));
}
return deltas;
}
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
$"{node.SystemId}|{node.OreRemaining:0.###}";
private static string BuildStationSignature(StationRuntime station) =>
$"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}";
private static string BuildShipSignature(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,
ship.Order?.Kind ?? "none",
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
GetShipCargoAmount(ship).ToString("0.###"),
GetInventoryAmount(ship.Inventory, "gas").ToString("0.###"),
ship.EnergyStored.ToString("0.###"),
ship.Health.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) =>
$"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}";
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id,
node.SystemId,
ToDto(node.Position),
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId);
private static StationDelta ToStationDelta(StationRuntime station) => new(
station.Id,
station.Definition.Label,
station.Definition.Category,
station.SystemId,
ToDto(station.Position),
station.Definition.Color,
station.DockedShipIds.Count,
station.EnergyStored,
ToInventoryEntries(station.Inventory),
station.FactionId);
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
ship.Id,
ship.Definition.Label,
ship.Definition.Role,
ship.Definition.ShipClass,
ship.SystemId,
ToDto(ship.Position),
ToDto(ship.Velocity),
ToDto(ship.TargetPosition),
ship.State,
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.Definition.CargoCapacity,
ship.EnergyStored,
ToInventoryEntries(ship.Inventory),
ship.FactionId,
ship.Health,
ship.History.ToList());
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) => new(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost);
private static void EmitShipStateEvents(
ShipRuntime ship,
string previousState,
string previousBehavior,
string 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} -> {ship.State}", 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} -> {ship.ControllerTask.Kind}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
foreach (var station in world.Stations)
{
if (!HasRefineryCapability(station.Definition) || GetInventoryAmount(station.Inventory, "ore") < 60f)
{
continue;
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
station.ProcessTimer = 0f;
continue;
}
station.ProcessTimer += deltaSeconds;
if (station.ProcessTimer < 8f)
{
continue;
}
station.ProcessTimer = 0f;
RemoveInventory(station.Inventory, "ore", 60f);
AddInventory(station.Inventory, "refined-metals", 60f);
events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow));
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
if (faction is not null)
{
faction.GoodsProduced += 60f;
faction.Credits += 18f;
}
}
}
private static bool HasRefineryCapability(ConstructibleDefinition definition) =>
definition.Modules.Contains("refinery-stack", StringComparer.Ordinal)
&& definition.Modules.Contains("power-core", StringComparer.Ordinal)
&& definition.Modules.Contains("liquid-tank", StringComparer.Ordinal)
&& definition.Modules.Contains("gas-tank", StringComparer.Ordinal)
&& definition.Storage.ContainsKey("bulk-solid")
&& definition.Storage.ContainsKey("manufactured");
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
private static void UpdateStationPower(
SimulationWorld world,
float deltaSeconds,
ICollection<SimulationEventRecord> events)
{
foreach (var station in world.Stations)
{
var previousEnergy = station.EnergyStored;
GenerateStationEnergy(station, deltaSeconds);
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "gas") <= 0.01f)
{
events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
}
}
}
private static void UpdateShipPower(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
ICollection<SimulationEventRecord> events)
{
var previousEnergy = ship.EnergyStored;
GenerateShipEnergy(ship, world, deltaSeconds);
if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "gas") <= 0.01f)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
}
}
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
{
var powerCores = CountModules(station.Definition.Modules, "power-core");
var tanks = CountModules(station.Definition.Modules, "gas-tank");
if (powerCores <= 0 || tanks <= 0)
{
station.EnergyStored = 0f;
station.Inventory.Remove("gas");
return;
}
var energyCapacity = powerCores * StationEnergyPerPowerCore;
var fuelStored = GetInventoryAmount(station.Inventory, "gas");
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
{
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
station.Inventory["gas"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
return;
}
var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds);
var requiredFuel = generated / StationFuelToEnergyRatio;
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
var actualGenerated = consumedFuel * StationFuelToEnergyRatio;
RemoveInventory(station.Inventory, "gas", consumedFuel);
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
}
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank");
if (reactors <= 0 || capacitors <= 0)
{
ship.EnergyStored = 0f;
ship.Inventory.Remove("gas");
return;
}
var energyCapacity = capacitors * CapacitorEnergyPerModule;
var fuelCapacity = reactors * ShipFuelPerReactor;
var fuelStored = GetInventoryAmount(ship.Inventory, "gas");
var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored);
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
{
ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity);
ship.Inventory["gas"] = MathF.Min(fuelStored, fuelCapacity);
return;
}
var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds);
var requiredFuel = generated / ShipFuelToEnergyRatio;
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
var actualGenerated = consumedFuel * ShipFuelToEnergyRatio;
RemoveInventory(ship.Inventory, "gas", consumedFuel);
ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated);
}
private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount)
{
if (ship.EnergyStored + 0.0001f < amount)
{
return false;
}
ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount);
return true;
}
private static bool TryConsumeStationEnergy(StationRuntime station, float amount)
{
if (station.EnergyStored + 0.0001f < amount)
{
return false;
}
station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount);
return true;
}
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal));
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
private static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
if (amount <= 0f)
{
return;
}
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
}
private static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
var removed = MathF.Min(current, amount);
var remaining = current - removed;
if (remaining <= 0.001f)
{
inventory.Remove(itemId);
}
else
{
inventory[itemId] = remaining;
}
return removed;
}
private static float GetShipCargoAmount(ShipRuntime ship)
{
var cargoItemId = ship.Definition.CargoItemId;
return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId);
}
private void RefreshControlLayers(ShipRuntime ship)
{
if (ship.Order is not null && ship.Order.Status == "queued")
{
ship.Order.Status = "accepted";
}
}
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
{
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = null,
TargetSystemId = ship.Order.DestinationSystemId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
return;
}
if (ship.DefaultBehavior.Kind == "auto-mine")
{
PlanAutoMine(ship, world);
return;
}
if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
};
return;
}
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Threshold = world.Balance.ArrivalThreshold,
};
}
private void PlanAutoMine(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId);
var node = behavior.NodeId is null
? world.Nodes
.Where((candidate) => candidate.SystemId == behavior.AreaSystemId)
.OrderByDescending((candidate) => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
if (refinery is null || node is null)
{
behavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
return;
}
behavior.NodeId ??= node.Id;
switch (behavior.Phase)
{
case "extract":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "extract",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 14f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "unload",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "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 = "travel",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
switch (task.Kind)
{
case "idle":
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
case "travel":
return UpdateTravel(ship, world, deltaSeconds);
case "extract":
return UpdateExtract(ship, world, deltaSeconds);
case "dock":
return UpdateDock(ship, world, deltaSeconds);
case "unload":
return UpdateUnload(ship, world, deltaSeconds);
case "undock":
return UpdateUndock(ship, world, deltaSeconds);
default:
ship.State = "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 = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance <= task.Threshold)
{
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.Position = task.TargetPosition.Value;
ship.TargetPosition = ship.Position;
ship.SystemId = task.TargetSystemId;
ship.State = "arriving";
return "arrived";
}
var speed = ship.Definition.Speed;
var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds;
if (ship.SystemId != task.TargetSystemId)
{
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
speed = ship.Definition.FtlSpeed;
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
}
else if (distance > 200f)
{
ship.State = distance > 500f ? "warping" : "spooling-warp";
speed = ship.Definition.Speed * 4.5f;
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
}
else
{
ship.State = "approaching";
speed = ship.Definition.Speed;
}
if (!TryConsumeShipEnergy(ship, energyCost))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
return "none";
}
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (!HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"))
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining-approach";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining";
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < 1f)
{
return "none";
}
ship.ActionTimer = 0f;
var cargoAmount = GetShipCargoAmount(ship);
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
mined = MathF.Min(mined, node.OreRemaining);
if (ship.Definition.CargoItemId is not null)
{
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
}
node.OreRemaining -= mined;
if (node.OreRemaining <= 0f)
{
node.OreRemaining = node.MaxOre;
}
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 = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking-approach";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking";
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < world.Balance.DockingDuration)
{
return "none";
}
ship.ActionTimer = 0f;
ship.State = "docked";
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = station.Position;
ship.TargetPosition = station.Position;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "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.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = station.Position;
ship.State = "transferring";
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)
{
RemoveInventory(ship.Inventory, cargoItemId, moved);
AddInventory(station.Inventory, cargoItemId, moved);
}
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
if (faction is not null)
{
faction.OreMined += moved;
faction.Credits += moved * 0.4f;
}
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "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 = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = task.TargetPosition.Value;
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
station?.DockedShipIds.Remove(ship.Id);
ship.DockedStationId = null;
ship.State = "undocking";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none";
}
private void AdvanceControlState(ShipRuntime ship, string controllerEvent)
{
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = "idle";
return;
}
if (ship.DefaultBehavior.Kind == "auto-mine")
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-node", "arrived"):
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
break;
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
}
}
if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
}
}
private static void TrackHistory(ShipRuntime ship)
{
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
}