Add world delta streaming and viewer smoothing

This commit is contained in:
2026-03-12 19:03:13 -04:00
parent 2fb90162ef
commit 9849dbae61
10 changed files with 966 additions and 177 deletions

View File

@@ -4,27 +4,52 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationEngine
{
public void Tick(SimulationWorld world, float deltaSeconds)
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{
UpdateStations(world, deltaSeconds);
var events = new List<SimulationEventRecord>();
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;
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)
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,
@@ -38,42 +63,44 @@ public sealed class SimulationEngine
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
world.Nodes.Select((node) => new ResourceNodeSnapshot(
world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot(
node.Id,
node.SystemId,
ToDto(node.Position),
node.Position,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
world.Stations.Select((station) => new StationSnapshot(
world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot(
station.Id,
station.Definition.Label,
station.Definition.Category,
station.Label,
station.Category,
station.SystemId,
ToDto(station.Position),
station.Definition.Color,
station.DockedShipIds.Count,
station.Position,
station.Color,
station.DockedShips,
station.OreStored,
station.RefinedStock,
station.FactionId)).ToList(),
world.Ships.Select((ship) => new ShipSnapshot(
world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
ship.Id,
ship.Definition.Label,
ship.Definition.Role,
ship.Definition.ShipClass,
ship.Label,
ship.Role,
ship.ShipClass,
ship.SystemId,
ToDto(ship.Position),
ship.Position,
ship.Velocity,
ship.TargetPosition,
ship.State,
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.OrderKind,
ship.DefaultBehaviorKind,
ship.ControllerTaskKind,
ship.Cargo,
ship.Definition.CargoCapacity,
ship.Definition.CargoItemId,
ship.CargoCapacity,
ship.CargoItemId,
ship.FactionId,
ship.Health,
ship.History.ToList())).ToList(),
world.Factions.Select((faction) => new FactionSnapshot(
ship.History)).ToList(),
world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot(
faction.Id,
faction.Label,
faction.Color,
@@ -84,7 +111,211 @@ public sealed class SimulationEngine
faction.ShipsLost)).ToList());
}
private void UpdateStations(SimulationWorld world, float deltaSeconds)
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}|{station.OreStored:0.###}|{station.RefinedStock: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,
ship.Cargo.ToString("0.###"),
ship.Health.ToString("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.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.OreStored,
station.RefinedStock,
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.Cargo,
ship.Definition.CargoCapacity,
ship.Definition.CargoItemId,
ship.FactionId,
ship.Health,
ship.History.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)
{
@@ -102,6 +333,7 @@ public sealed class SimulationEngine
station.ProcessTimer = 0f;
station.OreStored -= 60f;
station.RefinedStock += 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)
{
@@ -251,6 +483,7 @@ public sealed class SimulationEngine
{
case "idle":
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
case "travel":
return UpdateTravel(ship, world, deltaSeconds);
@@ -264,6 +497,7 @@ public sealed class SimulationEngine
return UpdateUndock(ship, world, deltaSeconds);
default:
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
}
@@ -274,9 +508,11 @@ public sealed class SimulationEngine
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)
{
@@ -305,7 +541,6 @@ public sealed class SimulationEngine
}
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
ship.TargetPosition = task.TargetPosition.Value;
return "none";
}
@@ -316,9 +551,11 @@ public sealed class SimulationEngine
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)
{
@@ -354,9 +591,11 @@ public sealed class SimulationEngine
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)
{
@@ -377,6 +616,7 @@ public sealed class SimulationEngine
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = station.Position;
ship.TargetPosition = station.Position;
return "docked";
}
@@ -385,6 +625,7 @@ public sealed class SimulationEngine
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
@@ -393,9 +634,11 @@ public sealed class SimulationEngine
{
ship.DockedStationId = null;
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = station.Position;
ship.State = "transferring";
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
ship.Cargo -= moved;
@@ -416,9 +659,11 @@ public sealed class SimulationEngine
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);
station?.DockedShipIds.Remove(ship.Id);
ship.DockedStationId = null;