1634 lines
52 KiB
C#
1634 lines
52 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.DockingPads,
|
|
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}|{station.DockingPadAssignments.Count}|{string.Join(",", station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}";
|
|
|
|
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, "fuel").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,
|
|
GetDockingPadCount(station),
|
|
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 (CanProcessFuel(station) && GetInventoryAmount(station.Inventory, "gas") >= 20f)
|
|
{
|
|
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
station.ProcessTimer = 0f;
|
|
continue;
|
|
}
|
|
|
|
station.ProcessTimer += deltaSeconds;
|
|
if (station.ProcessTimer >= 6f)
|
|
{
|
|
station.ProcessTimer = 0f;
|
|
RemoveInventory(station.Inventory, "gas", 20f);
|
|
var addedFuel = TryAddStationInventory(world, station, "fuel", 18f);
|
|
if (addedFuel > 0.01f)
|
|
{
|
|
events.Add(new SimulationEventRecord("station", station.Id, "fuel-processed", $"{station.Definition.Label} processed 20 gas into {addedFuel:0.#} fuel", DateTimeOffset.UtcNow));
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!HasRefineryCapability(station) || GetInventoryAmount(station.Inventory, "ore") < 60f)
|
|
{
|
|
station.ProcessTimer = 0f;
|
|
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);
|
|
var refined = TryAddStationInventory(world, station, "refined-metals", 60f);
|
|
if (refined <= 0.01f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
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 += refined;
|
|
faction.Credits += refined * 0.3f;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool HasRefineryCapability(StationRuntime station) =>
|
|
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
|
|
|
|
private static bool CanProcessFuel(StationRuntime station) =>
|
|
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
|
|
|
|
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, "fuel") <= 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, "fuel") <= 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.InstalledModules, "power-core");
|
|
var tanks = CountModules(station.InstalledModules, "liquid-tank");
|
|
if (powerCores <= 0 || tanks <= 0)
|
|
{
|
|
station.EnergyStored = 0f;
|
|
station.Inventory.Remove("fuel");
|
|
return;
|
|
}
|
|
|
|
var energyCapacity = powerCores * StationEnergyPerPowerCore;
|
|
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
|
|
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
|
{
|
|
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
|
station.Inventory["fuel"] = 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, "fuel", 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("fuel");
|
|
return;
|
|
}
|
|
|
|
var energyCapacity = capacitors * CapacitorEnergyPerModule;
|
|
var fuelCapacity = reactors * ShipFuelPerReactor;
|
|
var fuelStored = GetInventoryAmount(ship.Inventory, "fuel");
|
|
var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored);
|
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
|
{
|
|
ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity);
|
|
ship.Inventory["fuel"] = 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, "fuel", 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 bool HasStationModules(StationRuntime station, params string[] modules) =>
|
|
modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
|
|
|
|
private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) =>
|
|
node.ItemId switch
|
|
{
|
|
"ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"),
|
|
"gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"),
|
|
_ => false,
|
|
};
|
|
|
|
private static float GetShipFuelCapacity(ShipRuntime ship) =>
|
|
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
|
|
|
|
private static bool NeedsRefuel(ShipRuntime ship) =>
|
|
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f);
|
|
|
|
private static string? GetStorageRequirement(string storageClass) =>
|
|
storageClass switch
|
|
{
|
|
"bulk-solid" => "bulk-bay",
|
|
"bulk-liquid" => "liquid-tank",
|
|
"bulk-gas" => "gas-tank",
|
|
_ => null,
|
|
};
|
|
|
|
private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
|
|
{
|
|
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
var storageClass = itemDefinition.Storage;
|
|
var requiredModule = GetStorageRequirement(storageClass);
|
|
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
var used = station.Inventory
|
|
.Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass)
|
|
.Sum((entry) => entry.Value);
|
|
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
|
|
if (accepted <= 0.01f)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
AddInventory(station.Inventory, itemId, accepted);
|
|
return accepted;
|
|
}
|
|
|
|
private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
|
|
recipe.Inputs.All((input) => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
|
|
|
|
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
|
|
{
|
|
if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
|
|
{
|
|
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
|
{
|
|
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
|
&& world.ModuleRecipes.ContainsKey(moduleId))
|
|
{
|
|
return moduleId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static int GetDockingPadCount(StationRuntime station) =>
|
|
CountModules(station.InstalledModules, "dock-bay-small") * 2;
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
}
|
|
|
|
private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
|
|
{
|
|
var padCount = Math.Max(1, GetDockingPadCount(station));
|
|
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
|
|
var radius = station.Definition.Radius + 14f;
|
|
return new Vector3(
|
|
station.Position.X + (MathF.Cos(angle) * radius),
|
|
station.Position.Y,
|
|
station.Position.Z + (MathF.Sin(angle) * radius));
|
|
}
|
|
|
|
private 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.Definition.Radius + 34f;
|
|
return new Vector3(
|
|
station.Position.X + (MathF.Cos(angle) * radius),
|
|
station.Position.Y,
|
|
station.Position.Z + (MathF.Sin(angle) * radius));
|
|
}
|
|
|
|
private 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));
|
|
}
|
|
|
|
private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
|
|
ship.AssignedDockingPadIndex is int padIndex
|
|
? GetDockingPadPosition(station, padIndex)
|
|
: station.Position;
|
|
|
|
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 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")
|
|
{
|
|
PlanResourceHarvest(ship, world, "ore", "mining-turret");
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-harvest-gas")
|
|
{
|
|
PlanResourceHarvest(ship, world, "gas", "gas-extractor");
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "construct-station")
|
|
{
|
|
PlanStationConstruction(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 PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
|
|
{
|
|
var behavior = ship.DefaultBehavior;
|
|
var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.StationId);
|
|
var node = behavior.NodeId is null
|
|
? world.Nodes
|
|
.Where((candidate) => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId)
|
|
.OrderByDescending((candidate) => candidate.OreRemaining)
|
|
.FirstOrDefault()
|
|
: world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
|
|
|
|
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
|
{
|
|
behavior.Kind = "idle";
|
|
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
behavior.NodeId ??= node.Id;
|
|
if (ship.DockedStationId == refinery.Id)
|
|
{
|
|
if (GetShipCargoAmount(ship) > 0.01f)
|
|
{
|
|
behavior.Phase = "unload";
|
|
}
|
|
else if (NeedsRefuel(ship))
|
|
{
|
|
behavior.Phase = "refuel";
|
|
}
|
|
else if (behavior.Phase is "dock" or "unload" or "refuel")
|
|
{
|
|
behavior.Phase = "undock";
|
|
}
|
|
}
|
|
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock")
|
|
{
|
|
behavior.Phase = "travel-to-station";
|
|
}
|
|
|
|
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 "refuel":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "refuel",
|
|
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 void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
|
|
{
|
|
var behavior = ship.DefaultBehavior;
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == behavior.StationId);
|
|
if (station is null)
|
|
{
|
|
behavior.Kind = "idle";
|
|
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
var moduleId = GetNextStationModuleToBuild(station, world);
|
|
behavior.ModuleId = moduleId;
|
|
if (moduleId is null)
|
|
{
|
|
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
if (ship.DockedStationId == station.Id)
|
|
{
|
|
if (NeedsRefuel(ship))
|
|
{
|
|
behavior.Phase = "refuel";
|
|
}
|
|
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
|
|
{
|
|
behavior.Phase = "construct-module";
|
|
}
|
|
else
|
|
{
|
|
behavior.Phase = "wait-for-materials";
|
|
}
|
|
}
|
|
else if (behavior.Phase is not "travel-to-station" and not "dock")
|
|
{
|
|
behavior.Phase = "travel-to-station";
|
|
}
|
|
|
|
switch (behavior.Phase)
|
|
{
|
|
case "dock":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "dock",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = station.Definition.Radius + 4f,
|
|
};
|
|
break;
|
|
case "refuel":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "refuel",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "construct-module":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "construct-module",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "wait-for-materials":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "idle",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
default:
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "travel",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = station.Definition.Radius + 8f,
|
|
};
|
|
behavior.Phase = "travel-to-station";
|
|
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 "refuel":
|
|
return UpdateRefuel(ship, world, deltaSeconds);
|
|
case "construct-module":
|
|
return UpdateConstructModule(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)
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
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)
|
|
{
|
|
var spoolDuration = ship.Definition.SpoolTime;
|
|
if (ship.State != "ftl")
|
|
{
|
|
if (ship.State != "spooling-ftl")
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "spooling-ftl";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "ftl";
|
|
}
|
|
speed = ship.Definition.FtlSpeed;
|
|
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
|
|
}
|
|
else if (distance > 200f)
|
|
{
|
|
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
|
if (ship.State != "warping")
|
|
{
|
|
if (ship.State != "spooling-warp")
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "spooling-warp";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "warping";
|
|
}
|
|
speed = ship.Definition.Speed;
|
|
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
|
|
}
|
|
else
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
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)
|
|
{
|
|
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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
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 = "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";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
|
|
if (padIndex is null)
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "awaiting-dock";
|
|
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, ship.Definition.Speed * 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 = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "docking-approach";
|
|
ship.Position = ship.Position.MoveToward(padPosition, 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";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "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 = "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 = "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 = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
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)
|
|
{
|
|
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 UpdateRefuel(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.AssignedDockingPadIndex = 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 = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "refueling";
|
|
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - 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) ? "refueled" : "none";
|
|
}
|
|
|
|
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId 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 || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
|
|
{
|
|
ship.AssignedDockingPadIndex = null;
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "waiting-materials";
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
return "none";
|
|
}
|
|
|
|
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
|
|
{
|
|
ship.State = "construction-blocked";
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "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 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";
|
|
}
|
|
|
|
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 = "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";
|
|
}
|
|
|
|
ship.State = "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 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 = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel";
|
|
break;
|
|
case ("unload", "unloaded"):
|
|
ship.DefaultBehavior.Phase = "refuel";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
ship.DefaultBehavior.Phase = "undock";
|
|
break;
|
|
case ("undock", "undocked"):
|
|
ship.DefaultBehavior.Phase = "travel-to-node";
|
|
ship.DefaultBehavior.NodeId = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-harvest-gas")
|
|
{
|
|
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 = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel";
|
|
break;
|
|
case ("unload", "unloaded"):
|
|
ship.DefaultBehavior.Phase = "refuel";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
ship.DefaultBehavior.Phase = "undock";
|
|
break;
|
|
case ("undock", "undocked"):
|
|
ship.DefaultBehavior.Phase = "travel-to-node";
|
|
ship.DefaultBehavior.NodeId = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "construct-station")
|
|
{
|
|
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
|
{
|
|
case ("travel-to-station", "arrived"):
|
|
ship.DefaultBehavior.Phase = "dock";
|
|
break;
|
|
case ("dock", "docked"):
|
|
ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "construct-module";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
ship.DefaultBehavior.Phase = "construct-module";
|
|
break;
|
|
case ("construct-module", "module-constructed"):
|
|
ship.DefaultBehavior.Phase = "travel-to-station";
|
|
ship.DefaultBehavior.ModuleId = 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);
|
|
}
|