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

@@ -3,6 +3,8 @@ namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot(
string Label,
int Seed,
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<ResourceNodeSnapshot> Nodes,
@@ -10,6 +12,24 @@ public sealed record WorldSnapshot(
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
public sealed record WorldDelta(
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events,
IReadOnlyList<ResourceNodeDelta> Nodes,
IReadOnlyList<StationDelta> Stations,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions);
public sealed record SimulationEventRecord(
string EntityKind,
string EntityId,
string Kind,
string Message,
DateTimeOffset OccurredAtUtc);
public sealed record SystemSnapshot(
string Id,
string Label,
@@ -33,6 +53,14 @@ public sealed record ResourceNodeSnapshot(
float MaxOre,
string ItemId);
public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto Position,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record StationSnapshot(
string Id,
string Label,
@@ -45,6 +73,18 @@ public sealed record StationSnapshot(
float RefinedStock,
string FactionId);
public sealed record StationDelta(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto Position,
string Color,
int DockedShips,
float OreStored,
float RefinedStock,
string FactionId);
public sealed record ShipSnapshot(
string Id,
string Label,
@@ -52,6 +92,28 @@ public sealed record ShipSnapshot(
string ShipClass,
string SystemId,
Vector3Dto Position,
Vector3Dto Velocity,
Vector3Dto TargetPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
float Cargo,
float CargoCapacity,
string? CargoItemId,
string FactionId,
float Health,
IReadOnlyList<string> History);
public sealed record ShipDelta(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto Position,
Vector3Dto Velocity,
Vector3Dto TargetPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
@@ -73,4 +135,14 @@ public sealed record FactionSnapshot(
int ShipsBuilt,
int ShipsLost);
public sealed record FactionDelta(
string Id,
string Label,
string Color,
float Credits,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost);
public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -1,6 +1,8 @@
using SpaceGame.Simulation.Api.Simulation;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) =>
@@ -22,10 +24,30 @@ app.UseCors();
app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
app.MapGet("/api/world/stream", async (HttpContext httpContext, WorldService worldService, CancellationToken cancellationToken) =>
{
httpContext.Response.Headers.Append("Cache-Control", "no-cache");
httpContext.Response.Headers.Append("Content-Type", "text/event-stream");
var afterSequenceRaw = httpContext.Request.Query["afterSequence"].ToString();
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
var stream = worldService.Subscribe(afterSequence, cancellationToken);
await httpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
{
var payload = JsonSerializer.Serialize(delta, sseJsonOptions);
await httpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
}
});
app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
{
ok = true,
generatedAtUtc = worldService.GetSnapshot().GeneratedAtUtc,
sequence = worldService.GetStatus().Sequence,
generatedAtUtc = worldService.GetStatus().GeneratedAtUtc,
}));
app.MapPost("/api/world/reset", (WorldService worldService) =>
{

View File

@@ -4,7 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -13,7 +13,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -13,6 +13,7 @@ public sealed class SimulationWorld
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public int TickIntervalMs { get; init; } = 200;
public DateTimeOffset GeneratedAtUtc { get; set; }
}
@@ -30,6 +31,7 @@ public sealed class ResourceNodeRuntime
public required string ItemId { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class StationRuntime
@@ -43,6 +45,7 @@ public sealed class StationRuntime
public float RefinedStock { get; set; }
public float ProcessTimer { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipRuntime
@@ -53,6 +56,7 @@ public sealed class ShipRuntime
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public string State { get; set; } = "idle";
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
@@ -63,6 +67,7 @@ public sealed class ShipRuntime
public float Health { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class FactionRuntime
@@ -75,6 +80,7 @@ public sealed class FactionRuntime
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderRuntime
@@ -131,4 +137,16 @@ public readonly record struct Vector3(float X, float Y, float Z)
Y + ((target.Y - Y) * t),
Z + ((target.Z - Z) * t));
}
public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z);
public Vector3 Divide(float value)
{
if (value == 0f)
{
return Zero;
}
return new(X / value, Y / value, Z / value);
}
}

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;

View File

@@ -1,27 +1,81 @@
using System.Threading.Channels;
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class WorldService(IWebHostEnvironment environment)
{
private const int DeltaHistoryLimit = 256;
private readonly object _sync = new();
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
private readonly SimulationEngine _engine = new();
private readonly Dictionary<Guid, Channel<WorldDelta>> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
private long _sequence;
public WorldSnapshot GetSnapshot()
{
lock (_sync)
{
return _engine.BuildSnapshot(_world);
return _engine.BuildSnapshot(_world, _sequence);
}
}
public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus()
{
lock (_sync)
{
return (_sequence, _world.GeneratedAtUtc);
}
}
public ChannelReader<WorldDelta> Subscribe(long afterSequence, CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
Guid subscriberId;
lock (_sync)
{
subscriberId = Guid.NewGuid();
_subscribers.Add(subscriberId, channel);
foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence))
{
channel.Writer.TryWrite(delta);
}
}
cancellationToken.Register(() => Unsubscribe(subscriberId));
return channel.Reader;
}
public void Tick(float deltaSeconds)
{
WorldDelta? delta = null;
lock (_sync)
{
_engine.Tick(_world, deltaSeconds);
delta = _engine.Tick(_world, deltaSeconds, ++_sequence);
if (!HasMeaningfulDelta(delta))
{
return;
}
_history.Enqueue(delta);
while (_history.Count > DeltaHistoryLimit)
{
_history.Dequeue();
}
foreach (var subscriber in _subscribers.Values.ToList())
{
subscriber.Writer.TryWrite(delta);
}
}
}
@@ -30,7 +84,48 @@ public sealed class WorldService(IWebHostEnvironment environment)
lock (_sync)
{
_world = _loader.Load();
return _engine.BuildSnapshot(_world);
_sequence += 1;
_history.Clear();
var resetDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
DateTimeOffset.UtcNow,
true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow)],
[],
[],
[],
[]);
_history.Enqueue(resetDelta);
foreach (var subscriber in _subscribers.Values.ToList())
{
subscriber.Writer.TryWrite(resetDelta);
}
return _engine.BuildSnapshot(_world, _sequence);
}
}
private static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh
|| delta.Events.Count > 0
|| delta.Nodes.Count > 0
|| delta.Stations.Count > 0
|| delta.Ships.Count > 0
|| delta.Factions.Count > 0;
private void Unsubscribe(Guid subscriberId)
{
lock (_sync)
{
if (!_subscribers.Remove(subscriberId, out var channel))
{
return;
}
channel.Writer.TryComplete();
}
}
}