489 lines
14 KiB
C#
489 lines
14 KiB
C#
using SpaceGame.Simulation.Api.Contracts;
|
|
|
|
namespace SpaceGame.Simulation.Api.Simulation;
|
|
|
|
public sealed class SimulationEngine
|
|
{
|
|
public void Tick(SimulationWorld world, float deltaSeconds)
|
|
{
|
|
UpdateStations(world, deltaSeconds);
|
|
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
RefreshControlLayers(ship);
|
|
PlanControllerTask(ship, world);
|
|
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
|
AdvanceControlState(ship, controllerEvent);
|
|
TrackHistory(ship);
|
|
}
|
|
|
|
world.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
public WorldSnapshot BuildSnapshot(SimulationWorld world)
|
|
{
|
|
return new WorldSnapshot(
|
|
world.Label,
|
|
world.Seed,
|
|
world.GeneratedAtUtc,
|
|
world.Systems.Select((system) => new SystemSnapshot(
|
|
system.Definition.Id,
|
|
system.Definition.Label,
|
|
ToDto(system.Position),
|
|
system.Definition.StarColor,
|
|
system.Definition.StarSize,
|
|
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
|
|
planet.Label,
|
|
planet.OrbitRadius,
|
|
planet.Size,
|
|
planet.Color,
|
|
planet.HasRing)).ToList())).ToList(),
|
|
world.Nodes.Select((node) => new ResourceNodeSnapshot(
|
|
node.Id,
|
|
node.SystemId,
|
|
ToDto(node.Position),
|
|
node.OreRemaining,
|
|
node.MaxOre,
|
|
node.ItemId)).ToList(),
|
|
world.Stations.Select((station) => new StationSnapshot(
|
|
station.Id,
|
|
station.Definition.Label,
|
|
station.Definition.Category,
|
|
station.SystemId,
|
|
ToDto(station.Position),
|
|
station.Definition.Color,
|
|
station.DockedShipIds.Count,
|
|
station.OreStored,
|
|
station.RefinedStock,
|
|
station.FactionId)).ToList(),
|
|
world.Ships.Select((ship) => new ShipSnapshot(
|
|
ship.Id,
|
|
ship.Definition.Label,
|
|
ship.Definition.Role,
|
|
ship.Definition.ShipClass,
|
|
ship.SystemId,
|
|
ToDto(ship.Position),
|
|
ship.State,
|
|
ship.Order?.Kind,
|
|
ship.DefaultBehavior.Kind,
|
|
ship.ControllerTask.Kind,
|
|
ship.Cargo,
|
|
ship.Definition.CargoCapacity,
|
|
ship.Definition.CargoItemId,
|
|
ship.FactionId,
|
|
ship.Health,
|
|
ship.History.ToList())).ToList(),
|
|
world.Factions.Select((faction) => new FactionSnapshot(
|
|
faction.Id,
|
|
faction.Label,
|
|
faction.Color,
|
|
faction.Credits,
|
|
faction.OreMined,
|
|
faction.GoodsProduced,
|
|
faction.ShipsBuilt,
|
|
faction.ShipsLost)).ToList());
|
|
}
|
|
|
|
private void UpdateStations(SimulationWorld world, float deltaSeconds)
|
|
{
|
|
foreach (var station in world.Stations)
|
|
{
|
|
if (station.Definition.Category != "refining" || station.OreStored < 60f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
station.ProcessTimer += deltaSeconds;
|
|
if (station.ProcessTimer < 8f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
station.ProcessTimer = 0f;
|
|
station.OreStored -= 60f;
|
|
station.RefinedStock += 60f;
|
|
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
|
|
if (faction is not null)
|
|
{
|
|
faction.GoodsProduced += 60f;
|
|
faction.Credits += 18f;
|
|
}
|
|
}
|
|
}
|
|
|
|
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":
|
|
ship.State = "idle";
|
|
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";
|
|
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";
|
|
return "none";
|
|
}
|
|
|
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
|
if (distance <= task.Threshold)
|
|
{
|
|
ship.Position = task.TargetPosition.Value;
|
|
ship.TargetPosition = ship.Position;
|
|
ship.SystemId = task.TargetSystemId;
|
|
ship.State = "arriving";
|
|
return "arrived";
|
|
}
|
|
|
|
var speed = ship.Definition.Speed;
|
|
if (ship.SystemId != task.TargetSystemId)
|
|
{
|
|
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
|
|
speed = ship.Definition.FtlSpeed;
|
|
}
|
|
else if (distance > 200f)
|
|
{
|
|
ship.State = distance > 500f ? "warping" : "spooling-warp";
|
|
speed = ship.Definition.Speed * 4.5f;
|
|
}
|
|
else
|
|
{
|
|
ship.State = "approaching";
|
|
speed = ship.Definition.Speed;
|
|
}
|
|
|
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
|
|
ship.TargetPosition = task.TargetPosition.Value;
|
|
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)
|
|
{
|
|
ship.State = "idle";
|
|
return "none";
|
|
}
|
|
|
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
|
if (distance > task.Threshold)
|
|
{
|
|
ship.State = "mining-approach";
|
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "mining";
|
|
ship.ActionTimer += deltaSeconds;
|
|
if (ship.ActionTimer < 1f)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.ActionTimer = 0f;
|
|
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo);
|
|
mined = MathF.Min(mined, node.OreRemaining);
|
|
ship.Cargo += mined;
|
|
node.OreRemaining -= mined;
|
|
if (node.OreRemaining <= 0f)
|
|
{
|
|
node.OreRemaining = node.MaxOre;
|
|
}
|
|
|
|
return ship.Cargo >= 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";
|
|
return "none";
|
|
}
|
|
|
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
|
if (distance > task.Threshold)
|
|
{
|
|
ship.State = "docking-approach";
|
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
|
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;
|
|
return "docked";
|
|
}
|
|
|
|
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
ship.State = "idle";
|
|
return "none";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
if (station is null)
|
|
{
|
|
ship.DockedStationId = null;
|
|
ship.State = "idle";
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "transferring";
|
|
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
|
|
ship.Cargo -= moved;
|
|
station.OreStored += 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 ship.Cargo <= 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";
|
|
return "none";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
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 = ship.Cargo >= 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}|{ship.Cargo: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={ship.Cargo:0.#}");
|
|
if (ship.History.Count > 18)
|
|
{
|
|
ship.History.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
|
|
}
|