diff --git a/NEXT-STEPS.md b/NEXT-STEPS.md index 7d2efb2..d8b1f52 100644 --- a/NEXT-STEPS.md +++ b/NEXT-STEPS.md @@ -65,66 +65,81 @@ That turns the simulation into a real strategy loop. 2. Make pirate target selection explicitly prefer economic targets. 3. Surface faction stocks, throughput, and build priorities in the HUD/debug views. 4. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`. -5. Break [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) into smaller planning / faction / combat / logistics modules. +5. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules. -## Migration To .NET +## Network / Multiplayer -If the long-term goal is multiplayer, scale, persistence, or server authority, a .NET migration is a sensible next architectural track. +The repository now has a split architecture: -Recommended direction: +- [apps/backend](/home/jbourdon/repos/space-game/apps/backend) + - authoritative .NET simulation +- [apps/viewer](/home/jbourdon/repos/space-game/apps/viewer) + - rendering and observer UI +- `GET /api/world` + - initial snapshot +- `GET /api/world/stream` + - incremental SSE delta stream -- keep the current Vite / Three.js client for rendering and input -- move simulation authority into a .NET backend -- treat the browser client as a renderer + command UI +The next networking step is not “move the simulation into .NET.” That is already done for the active runtime. -Suggested migration phases: +The next steps are about scaling the transport and authority model cleanly. -1. Define a shared simulation contract. - - ship state snapshots - - orders - - behaviors - - assignments - - combat / economy events +Recommended work: -2. Extract the pure simulation model from the Three.js runtime. - - separate rendering state from simulation state - - remove direct scene dependencies from game logic +- add client-to-server command submission + - direct orders + - automation changes + - selection / observer commands only where useful +- add persistence + - saves + - world seeds + - reconnect support +- add player ownership / permissions + - command authority + - eventually fog of war / restricted information +- harden replication contracts + - explicit entity lifecycle events + - versioning + - reconnect / catch-up semantics -3. Rebuild the simulation core in .NET. - - `Order` - - `DefaultBehavior` - - `Assignment` - - `ControllerTask` - - faction economy - - combat resolution +## Interest Management -4. Add server-driven ticking. - - authoritative world step on the server - - deterministic or near-deterministic update model - - event stream / snapshot replication to clients +The current stream is world-wide. -5. Add persistence and multiplayer infrastructure. - - saves - - world seeds - - reconnect support - - eventually player ownership / fog / permissions +That means every observer receives deltas for the full simulation, even when only looking at one part of space. -Suggested .NET stack: +Recommended work: -- ASP.NET Core for API / realtime transport -- SignalR or custom websocket layer for simulation updates -- PostgreSQL for persistence -- background hosted service for world ticks +- add observer/view-scoped subscriptions + - visible systems + - nearby ships / stations / nodes + - faction-scoped or player-scoped channels later +- support subscribe / unsubscribe as camera focus changes +- send only relevant deltas per observer +- keep coarse strategic updates available for off-screen context + - system ownership + - major combat + - economy summaries -Suggested immediate prep before migration: +This is the key step that makes many simultaneous observers practical without broadcasting the entire world to everyone. -- isolate simulation data structures from rendering objects -- isolate faction AI from UI code -- isolate travel / docking / mining / combat systems into separate modules -- make event emission explicit and serializable +## Replication Quality -The key rule for the migration is: +The backend already sends: -- do not port Three.js-shaped code into .NET -- first separate the simulation from rendering in TypeScript -- then move the pure simulation into .NET cleanly +- initial snapshot +- incremental deltas +- event records + +Recommended work: + +- add stronger event typing + - spawn + - destroy + - dock + - undock + - cargo transfer + - combat hit / kill +- improve interpolation and extrapolation policies per entity type +- add resync handling when a client falls too far behind +- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index 3ca2a3a..da0cee2 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -3,6 +3,8 @@ namespace SpaceGame.Simulation.Api.Contracts; public sealed record WorldSnapshot( string Label, int Seed, + long Sequence, + int TickIntervalMs, DateTimeOffset GeneratedAtUtc, IReadOnlyList Systems, IReadOnlyList Nodes, @@ -10,6 +12,24 @@ public sealed record WorldSnapshot( IReadOnlyList Ships, IReadOnlyList Factions); +public sealed record WorldDelta( + long Sequence, + int TickIntervalMs, + DateTimeOffset GeneratedAtUtc, + bool RequiresSnapshotRefresh, + IReadOnlyList Events, + IReadOnlyList Nodes, + IReadOnlyList Stations, + IReadOnlyList Ships, + IReadOnlyList 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 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); diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index 93466bf..a30dd5e 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -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) => { diff --git a/apps/backend/Properties/launchSettings.json b/apps/backend/Properties/launchSettings.json index c55f6a2..d49908c 100644 --- a/apps/backend/Properties/launchSettings.json +++ b/apps/backend/Properties/launchSettings.json @@ -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" diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index f2868cb..8c0c051 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -13,6 +13,7 @@ public sealed class SimulationWorld public required List Ships { get; init; } public required List Factions { get; init; } public required Dictionary 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 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 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); + } } diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index 09d643a..67392c6 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -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(); + + 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 BuildNodeDeltas(SimulationWorld world) + { + var deltas = new List(); + 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 BuildStationDeltas(SimulationWorld world) + { + var deltas = new List(); + 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 BuildShipDeltas(SimulationWorld world) + { + var deltas = new List(); + 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 BuildFactionDeltas(SimulationWorld world) + { + var deltas = new List(); + 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 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 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; diff --git a/apps/backend/Simulation/WorldService.cs b/apps/backend/Simulation/WorldService.cs index f43d2bb..6e34e5a 100644 --- a/apps/backend/Simulation/WorldService.cs +++ b/apps/backend/Simulation/WorldService.cs @@ -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> _subscribers = []; + private readonly Queue _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 Subscribe(long afterSequence, CancellationToken cancellationToken) + { + var channel = Channel.CreateUnbounded(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(); } } } diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 3337e9f..e3266e0 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -1,11 +1,18 @@ import * as THREE from "three"; -import { fetchWorldSnapshot, resetWorld } from "./api"; +import { fetchWorldSnapshot, openWorldStream, resetWorld } from "./api"; import type { + FactionDelta, FactionSnapshot, + ResourceNodeDelta, ResourceNodeSnapshot, + ShipDelta, ShipSnapshot, + SimulationEventRecord, + StationDelta, StationSnapshot, SystemSnapshot, + Vector3Dto, + WorldDelta, WorldSnapshot, } from "./contracts"; @@ -15,6 +22,30 @@ type Selectable = | { kind: "node"; id: string } | { kind: "system"; id: string }; +interface ShipVisual { + mesh: THREE.Mesh; + startPosition: THREE.Vector3; + authoritativePosition: THREE.Vector3; + targetPosition: THREE.Vector3; + velocity: THREE.Vector3; + receivedAtMs: number; + blendDurationMs: number; +} + +interface WorldState { + label: string; + seed: number; + sequence: number; + tickIntervalMs: number; + generatedAtUtc: string; + systems: Map; + nodes: Map; + stations: Map; + ships: Map; + factions: Map; + recentEvents: SimulationEventRecord[]; +} + export class GameViewer { private readonly container: HTMLElement; private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -29,13 +60,18 @@ export class GameViewer { private readonly stationGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group(); private readonly selectableTargets = new Map(); + private readonly nodeMeshes = new Map(); + private readonly stationMeshes = new Map(); + private readonly shipVisuals = new Map(); private readonly statusEl: HTMLDivElement; private readonly detailTitleEl: HTMLHeadingElement; private readonly detailBodyEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement; private readonly resetButton: HTMLButtonElement; private readonly errorEl: HTMLDivElement; - private snapshot?: WorldSnapshot; + private readonly streamEl: HTMLDivElement; + private world?: WorldState; + private stream?: EventSource; private selected?: Selectable; private dragging = false; private lastPointer = new THREE.Vector2(); @@ -66,20 +102,22 @@ export class GameViewer {

Space Game Observer

-
Connecting
+
Bootstrapping
+
Stream Offline
`; this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement; + this.streamEl = hud.querySelector(".stream-pill") as HTMLDivElement; this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement; this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement; @@ -100,55 +138,137 @@ export class GameViewer { } async start() { - await this.refreshSnapshot(); - window.setInterval(() => { - void this.refreshSnapshot(); - }, 500); + await this.bootstrapWorld(); this.renderer.setAnimationLoop(() => this.render()); } - private async refreshSnapshot() { + private async bootstrapWorld() { try { const snapshot = await fetchWorldSnapshot(); - this.snapshot = snapshot; - this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`; + this.world = this.createWorldState(snapshot); + this.statusEl.textContent = `Snapshot ${snapshot.sequence}`; this.errorEl.hidden = true; this.applySnapshot(snapshot); + this.openDeltaStream(snapshot.sequence); this.updatePanels(); } catch (error) { this.statusEl.textContent = "Backend offline"; + this.streamEl.textContent = "Stream Offline"; this.errorEl.hidden = false; - this.errorEl.textContent = error instanceof Error ? error.message : "Unable to load the backend snapshot."; + this.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot."; } } + private openDeltaStream(afterSequence: number) { + this.stream?.close(); + this.stream = openWorldStream(afterSequence, { + onOpen: () => { + this.streamEl.textContent = "Stream Live"; + }, + onError: () => { + this.streamEl.textContent = "Stream Reconnecting"; + }, + onDelta: (delta) => { + void this.handleDelta(delta); + }, + }); + } + + private async handleDelta(delta: WorldDelta) { + if (!this.world) { + return; + } + + if (delta.requiresSnapshotRefresh) { + await this.bootstrapWorld(); + return; + } + + this.applyDelta(delta); + this.statusEl.textContent = `Seq ${delta.sequence} · ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`; + this.updatePanels(); + } + private async handleReset() { this.resetButton.disabled = true; try { const snapshot = await resetWorld(); - this.snapshot = snapshot; + this.world = this.createWorldState(snapshot); this.applySnapshot(snapshot); + this.openDeltaStream(snapshot.sequence); this.updatePanels(); } finally { this.resetButton.disabled = false; } } + private createWorldState(snapshot: WorldSnapshot): WorldState { + return { + label: snapshot.label, + seed: snapshot.seed, + sequence: snapshot.sequence, + tickIntervalMs: snapshot.tickIntervalMs, + generatedAtUtc: snapshot.generatedAtUtc, + systems: new Map(snapshot.systems.map((system) => [system.id, system])), + nodes: new Map(snapshot.nodes.map((node) => [node.id, node])), + stations: new Map(snapshot.stations.map((station) => [station.id, station])), + ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])), + factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])), + recentEvents: [], + }; + } + private applySnapshot(snapshot: WorldSnapshot) { const signature = `${snapshot.seed}|${snapshot.systems.length}`; if (signature !== this.worldSignature) { this.worldSignature = signature; this.rebuildSystems(snapshot.systems); } - this.rebuildNodes(snapshot.nodes); - this.rebuildStations(snapshot.stations); - this.rebuildShips(snapshot.ships); + + this.syncNodes(snapshot.nodes); + this.syncStations(snapshot.stations); + this.syncShips(snapshot.ships, snapshot.tickIntervalMs); this.rebuildFactions(snapshot.factions); } + private applyDelta(delta: WorldDelta) { + if (!this.world) { + return; + } + + this.world.sequence = delta.sequence; + this.world.tickIntervalMs = delta.tickIntervalMs; + this.world.generatedAtUtc = delta.generatedAtUtc; + this.world.recentEvents = [...delta.events, ...this.world.recentEvents].slice(0, 18); + + for (const node of delta.nodes) { + this.world.nodes.set(node.id, node); + } + + for (const station of delta.stations) { + this.world.stations.set(station.id, station); + } + + for (const ship of delta.ships) { + this.world.ships.set(ship.id, ship); + } + + for (const faction of delta.factions) { + this.world.factions.set(faction.id, faction); + } + + this.applyNodeDeltas(delta.nodes); + this.applyStationDeltas(delta.stations); + this.applyShipDeltas(delta.ships, delta.tickIntervalMs); + if (delta.factions.length > 0) { + this.rebuildFactions([...this.world.factions.values()]); + } + } + private rebuildSystems(systems: SystemSnapshot[]) { this.systemGroup.clear(); this.selectableTargets.clear(); + for (const system of systems) { const root = new THREE.Group(); root.position.set(system.position.x, system.position.y, system.position.z); @@ -168,6 +288,7 @@ export class GameViewer { root.add(star, halo); this.selectableTargets.set(star, { kind: "system", id: system.id }); this.selectableTargets.set(halo, { kind: "system", id: system.id }); + for (const planet of system.planets) { const orbit = new THREE.LineLoop( new THREE.BufferGeometry().setFromPoints( @@ -193,50 +314,115 @@ export class GameViewer { planetMesh.position.set(planet.orbitRadius, 0, 0); root.add(orbit, planetMesh); } + this.systemGroup.add(root); } } - private rebuildNodes(nodes: ResourceNodeSnapshot[]) { + private syncNodes(nodes: ResourceNodeSnapshot[]) { this.nodeGroup.clear(); + this.nodeMeshes.clear(); for (const node of nodes) { - const mesh = new THREE.Mesh( - new THREE.IcosahedronGeometry(12, 0), - new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }), - ); - mesh.position.set(node.position.x, node.position.y, node.position.z); - mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + const mesh = this.createNodeMesh(node); + this.nodeMeshes.set(node.id, mesh); this.nodeGroup.add(mesh); this.selectableTargets.set(mesh, { kind: "node", id: node.id }); } } - private rebuildStations(stations: StationSnapshot[]) { + private syncStations(stations: StationSnapshot[]) { this.stationGroup.clear(); + this.stationMeshes.clear(); for (const station of stations) { - const mesh = new THREE.Mesh( - new THREE.CylinderGeometry(24, 24, 18, 10), - new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), - ); - mesh.rotation.x = Math.PI / 2; - mesh.position.set(station.position.x, station.position.y, station.position.z); + const mesh = this.createStationMesh(station); + this.stationMeshes.set(station.id, mesh); this.stationGroup.add(mesh); this.selectableTargets.set(mesh, { kind: "station", id: station.id }); } } - private rebuildShips(ships: ShipSnapshot[]) { + private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) { this.shipGroup.clear(); + this.shipVisuals.clear(); for (const ship of ships) { - const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7); - geometry.rotateX(Math.PI / 2); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }), - ); - mesh.position.set(ship.position.x, ship.position.y, ship.position.z); + const mesh = this.createShipMesh(ship); this.shipGroup.add(mesh); this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); + const position = this.toThreeVector(ship.position); + this.shipVisuals.set(ship.id, { + mesh, + startPosition: position.clone(), + authoritativePosition: position.clone(), + targetPosition: this.toThreeVector(ship.targetPosition), + velocity: this.toThreeVector(ship.velocity), + receivedAtMs: performance.now(), + blendDurationMs: Math.max(tickIntervalMs, 80), + }); + } + } + + private applyNodeDeltas(nodes: ResourceNodeDelta[]) { + for (const node of nodes) { + const mesh = this.nodeMeshes.get(node.id); + if (!mesh) { + const nextMesh = this.createNodeMesh(node); + this.nodeMeshes.set(node.id, nextMesh); + this.nodeGroup.add(nextMesh); + this.selectableTargets.set(nextMesh, { kind: "node", id: node.id }); + continue; + } + + mesh.position.copy(this.toThreeVector(node.position)); + mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + } + } + + private applyStationDeltas(stations: StationDelta[]) { + for (const station of stations) { + const mesh = this.stationMeshes.get(station.id); + if (!mesh) { + const nextMesh = this.createStationMesh(station); + this.stationMeshes.set(station.id, nextMesh); + this.stationGroup.add(nextMesh); + this.selectableTargets.set(nextMesh, { kind: "station", id: station.id }); + continue; + } + + mesh.position.copy(this.toThreeVector(station.position)); + const material = mesh.material as THREE.MeshStandardMaterial; + material.color.set(station.color); + material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); + } + } + + private applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) { + for (const ship of ships) { + const visual = this.shipVisuals.get(ship.id); + if (!visual) { + const mesh = this.createShipMesh(ship); + const position = this.toThreeVector(ship.position); + this.shipGroup.add(mesh); + this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); + this.shipVisuals.set(ship.id, { + mesh, + startPosition: position.clone(), + authoritativePosition: position.clone(), + targetPosition: this.toThreeVector(ship.targetPosition), + velocity: this.toThreeVector(ship.velocity), + receivedAtMs: performance.now(), + blendDurationMs: Math.max(tickIntervalMs, 80), + }); + continue; + } + + visual.startPosition.copy(visual.mesh.position); + visual.authoritativePosition.copy(this.toThreeVector(ship.position)); + visual.targetPosition.copy(this.toThreeVector(ship.targetPosition)); + visual.velocity.copy(this.toThreeVector(ship.velocity)); + visual.receivedAtMs = performance.now(); + visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); + const material = visual.mesh.material as THREE.MeshStandardMaterial; + material.color.set(this.shipColor(ship.role)); } } @@ -255,71 +441,153 @@ export class GameViewer { } private updatePanels() { - if (!this.snapshot) { + if (!this.world) { return; } + if (!this.selected) { - this.detailTitleEl.textContent = this.snapshot.label; - this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}
Stations ${this.snapshot.stations.length}
Ships ${this.snapshot.ships.length}`; - return; - } - - const selected = this.selected; - if (selected.kind === "ship") { - const ship = this.snapshot.ships.find((candidate) => candidate.id === selected.id); - if (ship) { - this.detailTitleEl.textContent = ship.label; - this.detailBodyEl.innerHTML = ` -

${ship.shipClass} · ${ship.role} · ${ship.systemId}

-

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

-

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

-

${ship.history.join("
")}

- `; - } - return; - } - - if (selected.kind === "station") { - const station = this.snapshot.stations.find((candidate) => candidate.id === selected.id); - if (station) { - this.detailTitleEl.textContent = station.label; - this.detailBodyEl.innerHTML = ` -

${station.category} · ${station.systemId}

-

Ore ${station.oreStored.toFixed(0)}
Refined ${station.refinedStock.toFixed(0)}
Docked ${station.dockedShips}

- `; - } - return; - } - - if (selected.kind === "node") { - const node = this.snapshot.nodes.find((candidate) => candidate.id === selected.id); - if (node) { - this.detailTitleEl.textContent = `Node ${node.id}`; - this.detailBodyEl.innerHTML = ` -

${node.systemId}

-

${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

- `; - } - return; - } - - const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id); - if (system) { - this.detailTitleEl.textContent = system.label; + this.detailTitleEl.textContent = this.world.label; this.detailBodyEl.innerHTML = ` -

${system.id}

-

Planets ${system.planets.length}

+ Systems ${this.world.systems.size}
+ Stations ${this.world.stations.size}
+ Ships ${this.world.ships.size}
+ Recent events ${this.world.recentEvents.length} `; + return; } + + if (this.selected.kind === "ship") { + const ship = this.world.ships.get(this.selected.id); + if (!ship) { + return; + } + this.detailTitleEl.textContent = ship.label; + this.detailBodyEl.innerHTML = ` +

${ship.shipClass} · ${ship.role} · ${ship.systemId}

+

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

+

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

+

Velocity ${this.formatVector(ship.velocity)}

+

${ship.history.join("
")}

+ `; + return; + } + + if (this.selected.kind === "station") { + const station = this.world.stations.get(this.selected.id); + if (!station) { + return; + } + this.detailTitleEl.textContent = station.label; + this.detailBodyEl.innerHTML = ` +

${station.category} · ${station.systemId}

+

Ore ${station.oreStored.toFixed(0)}
Refined ${station.refinedStock.toFixed(0)}
Docked ${station.dockedShips}

+

${this.renderRecentEvents("station", station.id)}

+ `; + return; + } + + if (this.selected.kind === "node") { + const node = this.world.nodes.get(this.selected.id); + if (!node) { + return; + } + this.detailTitleEl.textContent = `Node ${node.id}`; + this.detailBodyEl.innerHTML = ` +

${node.systemId}

+

${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

+ `; + return; + } + + const system = this.world.systems.get(this.selected.id); + if (!system) { + return; + } + this.detailTitleEl.textContent = system.label; + this.detailBodyEl.innerHTML = ` +

${system.id}

+

Planets ${system.planets.length}

+ `; } private render() { const delta = Math.min(this.clock.getDelta(), 0.033); this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2)); this.camera.lookAt(this.focus); + this.updateShipPresentation(); this.renderer.render(this.scene, this.camera); } + private updateShipPresentation() { + const now = performance.now(); + for (const visual of this.shipVisuals.values()) { + const elapsedMs = now - visual.receivedAtMs; + const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); + visual.mesh.position.lerpVectors(visual.startPosition, visual.authoritativePosition, blendT); + + if (blendT >= 1) { + const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35); + visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds); + } + + const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position); + if (desiredHeading.lengthSq() > 0.01) { + visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); + } + } + } + + private createNodeMesh(node: ResourceNodeSnapshot) { + const mesh = new THREE.Mesh( + new THREE.IcosahedronGeometry(12, 0), + new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }), + ); + mesh.position.copy(this.toThreeVector(node.position)); + mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); + return mesh; + } + + private createStationMesh(station: StationSnapshot) { + const mesh = new THREE.Mesh( + new THREE.CylinderGeometry(24, 24, 18, 10), + new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), + ); + mesh.rotation.x = Math.PI / 2; + mesh.position.copy(this.toThreeVector(station.position)); + return mesh; + } + + private createShipMesh(ship: ShipSnapshot) { + const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7); + geometry.rotateX(Math.PI / 2); + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }), + ); + mesh.position.copy(this.toThreeVector(ship.position)); + return mesh; + } + + private renderRecentEvents(entityKind: string, entityId: string) { + if (!this.world) { + return ""; + } + + return this.world.recentEvents + .filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId)) + .slice(0, 8) + .map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`) + .join("
"); + } + + private formatVector(vector: Vector3Dto) { + return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`; + } + + private toThreeVector(vector: Vector3Dto) { + return new THREE.Vector3(vector.x, vector.y, vector.z); + } + private onPointerDown = (event: PointerEvent) => { this.dragging = true; this.lastPointer.set(event.clientX, event.clientY); @@ -351,7 +619,7 @@ export class GameViewer { }; private onDoubleClick = () => { - if (!this.snapshot || !this.selected) { + if (!this.world || !this.selected) { return; } const nextFocus = this.resolveSelectionPosition(this.selected); @@ -369,23 +637,27 @@ export class GameViewer { }; private resolveSelectionPosition(selection: Selectable) { - if (!this.snapshot) { + if (!this.world) { return undefined; } + if (selection.kind === "ship") { - const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id); - return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined; + const ship = this.world.ships.get(selection.id); + return ship ? this.toThreeVector(ship.position) : undefined; } + if (selection.kind === "station") { - const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id); - return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined; + const station = this.world.stations.get(selection.id); + return station ? this.toThreeVector(station.position) : undefined; } + if (selection.kind === "node") { - const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id); - return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined; + const node = this.world.nodes.get(selection.id); + return node ? this.toThreeVector(node.position) : undefined; } - const system = this.snapshot.systems.find((candidate) => candidate.id === selection.id); - return system ? new THREE.Vector3(system.position.x, system.position.y, system.position.z) : undefined; + + const system = this.world.systems.get(selection.id); + return system ? this.toThreeVector(system.position) : undefined; } private shipSize(ship: ShipSnapshot) { diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index c84c174..ce00052 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -1,4 +1,4 @@ -import type { WorldSnapshot } from "./contracts"; +import type { WorldDelta, WorldSnapshot } from "./contracts"; export async function fetchWorldSnapshot(signal?: AbortSignal) { const response = await fetch("/api/world", { signal }); @@ -8,6 +8,24 @@ export async function fetchWorldSnapshot(signal?: AbortSignal) { return response.json() as Promise; } +export function openWorldStream( + afterSequence: number, + handlers: { + onDelta: (delta: WorldDelta) => void; + onOpen?: () => void; + onError?: () => void; + }, +) { + const stream = new EventSource(`/api/world/stream?afterSequence=${afterSequence}`); + stream.addEventListener("open", () => handlers.onOpen?.()); + stream.addEventListener("error", () => handlers.onError?.()); + stream.addEventListener("world-delta", (event) => { + const message = event as MessageEvent; + handlers.onDelta(JSON.parse(message.data) as WorldDelta); + }); + return stream; +} + export async function resetWorld() { const response = await fetch("/api/world/reset", { method: "POST", diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 4ec6b4f..5299642 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -1,6 +1,8 @@ export interface WorldSnapshot { label: string; seed: number; + sequence: number; + tickIntervalMs: number; generatedAtUtc: string; systems: SystemSnapshot[]; nodes: ResourceNodeSnapshot[]; @@ -9,6 +11,26 @@ export interface WorldSnapshot { factions: FactionSnapshot[]; } +export interface WorldDelta { + sequence: number; + tickIntervalMs: number; + generatedAtUtc: string; + requiresSnapshotRefresh: boolean; + events: SimulationEventRecord[]; + nodes: ResourceNodeDelta[]; + stations: StationDelta[]; + ships: ShipDelta[]; + factions: FactionDelta[]; +} + +export interface SimulationEventRecord { + entityKind: string; + entityId: string; + kind: string; + message: string; + occurredAtUtc: string; +} + export interface Vector3Dto { x: number; y: number; @@ -41,6 +63,8 @@ export interface ResourceNodeSnapshot { itemId: string; } +export interface ResourceNodeDelta extends ResourceNodeSnapshot {} + export interface StationSnapshot { id: string; label: string; @@ -54,6 +78,8 @@ export interface StationSnapshot { factionId: string; } +export interface StationDelta extends StationSnapshot {} + export interface ShipSnapshot { id: string; label: string; @@ -61,6 +87,8 @@ export interface ShipSnapshot { shipClass: string; systemId: string; position: Vector3Dto; + velocity: Vector3Dto; + targetPosition: Vector3Dto; state: string; orderKind: string | null; defaultBehaviorKind: string; @@ -73,6 +101,8 @@ export interface ShipSnapshot { history: string[]; } +export interface ShipDelta extends ShipSnapshot {} + export interface FactionSnapshot { id: string; label: string; @@ -83,3 +113,5 @@ export interface FactionSnapshot { shipsBuilt: number; shipsLost: number; } + +export interface FactionDelta extends FactionSnapshot {}