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

@@ -65,66 +65,81 @@ That turns the simulation into a real strategy loop.
2. Make pirate target selection explicitly prefer economic targets. 2. Make pirate target selection explicitly prefer economic targets.
3. Surface faction stocks, throughput, and build priorities in the HUD/debug views. 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`. 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 The next networking step is not “move the simulation into .NET.” That is already done for the active runtime.
- move simulation authority into a .NET backend
- treat the browser client as a renderer + command UI
Suggested migration phases: The next steps are about scaling the transport and authority model cleanly.
1. Define a shared simulation contract. Recommended work:
- ship state snapshots
- orders
- behaviors
- assignments
- combat / economy events
2. Extract the pure simulation model from the Three.js runtime. - add client-to-server command submission
- separate rendering state from simulation state - direct orders
- remove direct scene dependencies from game logic - 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. ## Interest Management
- `Order`
- `DefaultBehavior`
- `Assignment`
- `ControllerTask`
- faction economy
- combat resolution
4. Add server-driven ticking. The current stream is world-wide.
- authoritative world step on the server
- deterministic or near-deterministic update model
- event stream / snapshot replication to clients
5. Add persistence and multiplayer infrastructure. That means every observer receives deltas for the full simulation, even when only looking at one part of space.
- saves
- world seeds
- reconnect support
- eventually player ownership / fog / permissions
Suggested .NET stack: Recommended work:
- ASP.NET Core for API / realtime transport - add observer/view-scoped subscriptions
- SignalR or custom websocket layer for simulation updates - visible systems
- PostgreSQL for persistence - nearby ships / stations / nodes
- background hosted service for world ticks - 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 ## Replication Quality
- isolate faction AI from UI code
- isolate travel / docking / mining / combat systems into separate modules
- make event emission explicit and serializable
The key rule for the migration is: The backend already sends:
- do not port Three.js-shaped code into .NET - initial snapshot
- first separate the simulation from rendering in TypeScript - incremental deltas
- then move the pure simulation into .NET cleanly - 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

View File

@@ -3,6 +3,8 @@ namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot( public sealed record WorldSnapshot(
string Label, string Label,
int Seed, int Seed,
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc, DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems, IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<ResourceNodeSnapshot> Nodes, IReadOnlyList<ResourceNodeSnapshot> Nodes,
@@ -10,6 +12,24 @@ public sealed record WorldSnapshot(
IReadOnlyList<ShipSnapshot> Ships, IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions); 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( public sealed record SystemSnapshot(
string Id, string Id,
string Label, string Label,
@@ -33,6 +53,14 @@ public sealed record ResourceNodeSnapshot(
float MaxOre, float MaxOre,
string ItemId); string ItemId);
public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto Position,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record StationSnapshot( public sealed record StationSnapshot(
string Id, string Id,
string Label, string Label,
@@ -45,6 +73,18 @@ public sealed record StationSnapshot(
float RefinedStock, float RefinedStock,
string FactionId); 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( public sealed record ShipSnapshot(
string Id, string Id,
string Label, string Label,
@@ -52,6 +92,28 @@ public sealed record ShipSnapshot(
string ShipClass, string ShipClass,
string SystemId, string SystemId,
Vector3Dto Position, 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 State,
string? OrderKind, string? OrderKind,
string DefaultBehaviorKind, string DefaultBehaviorKind,
@@ -73,4 +135,14 @@ public sealed record FactionSnapshot(
int ShipsBuilt, int ShipsBuilt,
int ShipsLost); 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); public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -1,6 +1,8 @@
using SpaceGame.Simulation.Api.Simulation; using SpaceGame.Simulation.Api.Simulation;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
builder.WebHost.UseUrls("http://127.0.0.1:5079"); builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) => builder.Services.AddCors((options) =>
@@ -22,10 +24,30 @@ app.UseCors();
app.MapGet("/", () => Results.Redirect("/api/world")); app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot())); 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 app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
{ {
ok = true, ok = true,
generatedAtUtc = worldService.GetSnapshot().GeneratedAtUtc, sequence = worldService.GetStatus().Sequence,
generatedAtUtc = worldService.GetStatus().GeneratedAtUtc,
})); }));
app.MapPost("/api/world/reset", (WorldService worldService) => app.MapPost("/api/world/reset", (WorldService worldService) =>
{ {

View File

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

View File

@@ -13,6 +13,7 @@ public sealed class SimulationWorld
public required List<ShipRuntime> Ships { get; init; } public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; } public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; } public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public int TickIntervalMs { get; init; } = 200;
public DateTimeOffset GeneratedAtUtc { get; set; } public DateTimeOffset GeneratedAtUtc { get; set; }
} }
@@ -30,6 +31,7 @@ public sealed class ResourceNodeRuntime
public required string ItemId { get; init; } public required string ItemId { get; init; }
public float OreRemaining { get; set; } public float OreRemaining { get; set; }
public float MaxOre { get; init; } public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;
} }
public sealed class StationRuntime public sealed class StationRuntime
@@ -43,6 +45,7 @@ public sealed class StationRuntime
public float RefinedStock { get; set; } public float RefinedStock { get; set; }
public float ProcessTimer { get; set; } public float ProcessTimer { get; set; }
public HashSet<string> DockedShipIds { get; } = []; public HashSet<string> DockedShipIds { get; } = [];
public string LastDeltaSignature { get; set; } = string.Empty;
} }
public sealed class ShipRuntime public sealed class ShipRuntime
@@ -53,6 +56,7 @@ public sealed class ShipRuntime
public required string FactionId { get; init; } public required string FactionId { get; init; }
public required Vector3 Position { get; set; } public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; } public required Vector3 TargetPosition { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public string State { get; set; } = "idle"; public string State { get; set; } = "idle";
public ShipOrderRuntime? Order { get; set; } public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; } public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
@@ -63,6 +67,7 @@ public sealed class ShipRuntime
public float Health { get; set; } public float Health { get; set; }
public List<string> History { get; } = []; public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty; public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
} }
public sealed class FactionRuntime public sealed class FactionRuntime
@@ -75,6 +80,7 @@ public sealed class FactionRuntime
public float GoodsProduced { get; set; } public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; } public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; } public int ShipsLost { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
} }
public sealed class ShipOrderRuntime public sealed class ShipOrderRuntime
@@ -131,4 +137,16 @@ public readonly record struct Vector3(float X, float Y, float Z)
Y + ((target.Y - Y) * t), Y + ((target.Y - Y) * t),
Z + ((target.Z - Z) * 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 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) 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); RefreshControlLayers(ship);
PlanControllerTask(ship, world); PlanControllerTask(ship, world);
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, controllerEvent); AdvanceControlState(ship, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
TrackHistory(ship); TrackHistory(ship);
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
} }
world.GeneratedAtUtc = DateTimeOffset.UtcNow; 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( return new WorldSnapshot(
world.Label, world.Label,
world.Seed, world.Seed,
sequence,
world.TickIntervalMs,
world.GeneratedAtUtc, world.GeneratedAtUtc,
world.Systems.Select((system) => new SystemSnapshot( world.Systems.Select((system) => new SystemSnapshot(
system.Definition.Id, system.Definition.Id,
@@ -38,42 +63,44 @@ public sealed class SimulationEngine
planet.Size, planet.Size,
planet.Color, planet.Color,
planet.HasRing)).ToList())).ToList(), planet.HasRing)).ToList())).ToList(),
world.Nodes.Select((node) => new ResourceNodeSnapshot( world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot(
node.Id, node.Id,
node.SystemId, node.SystemId,
ToDto(node.Position), node.Position,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
node.ItemId)).ToList(), node.ItemId)).ToList(),
world.Stations.Select((station) => new StationSnapshot( world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot(
station.Id, station.Id,
station.Definition.Label, station.Label,
station.Definition.Category, station.Category,
station.SystemId, station.SystemId,
ToDto(station.Position), station.Position,
station.Definition.Color, station.Color,
station.DockedShipIds.Count, station.DockedShips,
station.OreStored, station.OreStored,
station.RefinedStock, station.RefinedStock,
station.FactionId)).ToList(), station.FactionId)).ToList(),
world.Ships.Select((ship) => new ShipSnapshot( world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
ship.Id, ship.Id,
ship.Definition.Label, ship.Label,
ship.Definition.Role, ship.Role,
ship.Definition.ShipClass, ship.ShipClass,
ship.SystemId, ship.SystemId,
ToDto(ship.Position), ship.Position,
ship.Velocity,
ship.TargetPosition,
ship.State, ship.State,
ship.Order?.Kind, ship.OrderKind,
ship.DefaultBehavior.Kind, ship.DefaultBehaviorKind,
ship.ControllerTask.Kind, ship.ControllerTaskKind,
ship.Cargo, ship.Cargo,
ship.Definition.CargoCapacity, ship.CargoCapacity,
ship.Definition.CargoItemId, ship.CargoItemId,
ship.FactionId, ship.FactionId,
ship.Health, ship.Health,
ship.History.ToList())).ToList(), ship.History)).ToList(),
world.Factions.Select((faction) => new FactionSnapshot( world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot(
faction.Id, faction.Id,
faction.Label, faction.Label,
faction.Color, faction.Color,
@@ -84,7 +111,211 @@ public sealed class SimulationEngine
faction.ShipsLost)).ToList()); 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) foreach (var station in world.Stations)
{ {
@@ -102,6 +333,7 @@ public sealed class SimulationEngine
station.ProcessTimer = 0f; station.ProcessTimer = 0f;
station.OreStored -= 60f; station.OreStored -= 60f;
station.RefinedStock += 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); var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
if (faction is not null) if (faction is not null)
{ {
@@ -251,6 +483,7 @@ public sealed class SimulationEngine
{ {
case "idle": case "idle":
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
case "travel": case "travel":
return UpdateTravel(ship, world, deltaSeconds); return UpdateTravel(ship, world, deltaSeconds);
@@ -264,6 +497,7 @@ public sealed class SimulationEngine
return UpdateUndock(ship, world, deltaSeconds); return UpdateUndock(ship, world, deltaSeconds);
default: default:
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
} }
@@ -274,9 +508,11 @@ public sealed class SimulationEngine
if (task.TargetPosition is null || task.TargetSystemId is null) if (task.TargetPosition is null || task.TargetSystemId is null)
{ {
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value); var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance <= task.Threshold) if (distance <= task.Threshold)
{ {
@@ -305,7 +541,6 @@ public sealed class SimulationEngine
} }
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds); ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
ship.TargetPosition = task.TargetPosition.Value;
return "none"; return "none";
} }
@@ -316,9 +551,11 @@ public sealed class SimulationEngine
if (node is null || task.TargetPosition is null) if (node is null || task.TargetPosition is null)
{ {
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value); var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold) if (distance > task.Threshold)
{ {
@@ -354,9 +591,11 @@ public sealed class SimulationEngine
if (station is null || task.TargetPosition is null) if (station is null || task.TargetPosition is null)
{ {
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value); var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold) if (distance > task.Threshold)
{ {
@@ -377,6 +616,7 @@ public sealed class SimulationEngine
ship.DockedStationId = station.Id; ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id); station.DockedShipIds.Add(ship.Id);
ship.Position = station.Position; ship.Position = station.Position;
ship.TargetPosition = station.Position;
return "docked"; return "docked";
} }
@@ -385,6 +625,7 @@ public sealed class SimulationEngine
if (ship.DockedStationId is null) if (ship.DockedStationId is null)
{ {
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -393,9 +634,11 @@ public sealed class SimulationEngine
{ {
ship.DockedStationId = null; ship.DockedStationId = null;
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = station.Position;
ship.State = "transferring"; ship.State = "transferring";
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds); var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
ship.Cargo -= moved; ship.Cargo -= moved;
@@ -416,9 +659,11 @@ public sealed class SimulationEngine
if (ship.DockedStationId is null || task.TargetPosition is null) if (ship.DockedStationId is null || task.TargetPosition is null)
{ {
ship.State = "idle"; ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = task.TargetPosition.Value;
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
station?.DockedShipIds.Remove(ship.Id); station?.DockedShipIds.Remove(ship.Id);
ship.DockedStationId = null; ship.DockedStationId = null;

View File

@@ -1,27 +1,81 @@
using System.Threading.Channels;
using SpaceGame.Simulation.Api.Contracts; using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation; namespace SpaceGame.Simulation.Api.Simulation;
public sealed class WorldService(IWebHostEnvironment environment) public sealed class WorldService(IWebHostEnvironment environment)
{ {
private const int DeltaHistoryLimit = 256;
private readonly object _sync = new(); private readonly object _sync = new();
private readonly ScenarioLoader _loader = new(environment.ContentRootPath); private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
private readonly SimulationEngine _engine = new(); 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 SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
private long _sequence;
public WorldSnapshot GetSnapshot() public WorldSnapshot GetSnapshot()
{ {
lock (_sync) 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) public void Tick(float deltaSeconds)
{ {
WorldDelta? delta = null;
lock (_sync) 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) lock (_sync)
{ {
_world = _loader.Load(); _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();
} }
} }
} }

View File

@@ -1,11 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import { fetchWorldSnapshot, resetWorld } from "./api"; import { fetchWorldSnapshot, openWorldStream, resetWorld } from "./api";
import type { import type {
FactionDelta,
FactionSnapshot, FactionSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot, ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot, ShipSnapshot,
SimulationEventRecord,
StationDelta,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
Vector3Dto,
WorldDelta,
WorldSnapshot, WorldSnapshot,
} from "./contracts"; } from "./contracts";
@@ -15,6 +22,30 @@ type Selectable =
| { kind: "node"; id: string } | { kind: "node"; id: string }
| { kind: "system"; 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<string, SystemSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
ships: Map<string, ShipSnapshot>;
factions: Map<string, FactionSnapshot>;
recentEvents: SimulationEventRecord[];
}
export class GameViewer { export class GameViewer {
private readonly container: HTMLElement; private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -29,13 +60,18 @@ export class GameViewer {
private readonly stationGroup = new THREE.Group(); private readonly stationGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group();
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>(); private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
private readonly stationMeshes = new Map<string, THREE.Mesh>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly statusEl: HTMLDivElement; private readonly statusEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement; private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement; private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement;
private readonly resetButton: HTMLButtonElement; private readonly resetButton: HTMLButtonElement;
private readonly errorEl: HTMLDivElement; private readonly errorEl: HTMLDivElement;
private snapshot?: WorldSnapshot; private readonly streamEl: HTMLDivElement;
private world?: WorldState;
private stream?: EventSource;
private selected?: Selectable; private selected?: Selectable;
private dragging = false; private dragging = false;
private lastPointer = new THREE.Vector2(); private lastPointer = new THREE.Vector2();
@@ -66,20 +102,22 @@ export class GameViewer {
<h1>Space Game Observer</h1> <h1>Space Game Observer</h1>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="status-pill">Connecting</div> <div class="status-pill">Bootstrapping</div>
<div class="status-pill stream-pill">Stream Offline</div>
<button type="button" class="reset-button">Reset World</button> <button type="button" class="reset-button">Reset World</button>
</div> </div>
</header> </header>
<aside class="details-panel"> <aside class="details-panel">
<h2>Selection</h2> <h2>Selection</h2>
<h3 class="detail-title">Nothing selected</h3> <h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Click a star, station, node, or ship to inspect the server snapshot.</div> <div class="detail-body">Waiting for the authoritative snapshot.</div>
<div class="error-strip" hidden></div> <div class="error-strip" hidden></div>
</aside> </aside>
<section class="faction-strip"></section> <section class="faction-strip"></section>
`; `;
this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement; 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.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
@@ -100,55 +138,137 @@ export class GameViewer {
} }
async start() { async start() {
await this.refreshSnapshot(); await this.bootstrapWorld();
window.setInterval(() => {
void this.refreshSnapshot();
}, 500);
this.renderer.setAnimationLoop(() => this.render()); this.renderer.setAnimationLoop(() => this.render());
} }
private async refreshSnapshot() { private async bootstrapWorld() {
try { try {
const snapshot = await fetchWorldSnapshot(); const snapshot = await fetchWorldSnapshot();
this.snapshot = snapshot; this.world = this.createWorldState(snapshot);
this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`; this.statusEl.textContent = `Snapshot ${snapshot.sequence}`;
this.errorEl.hidden = true; this.errorEl.hidden = true;
this.applySnapshot(snapshot); this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels(); this.updatePanels();
} catch (error) { } catch (error) {
this.statusEl.textContent = "Backend offline"; this.statusEl.textContent = "Backend offline";
this.streamEl.textContent = "Stream Offline";
this.errorEl.hidden = false; 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() { private async handleReset() {
this.resetButton.disabled = true; this.resetButton.disabled = true;
try { try {
const snapshot = await resetWorld(); const snapshot = await resetWorld();
this.snapshot = snapshot; this.world = this.createWorldState(snapshot);
this.applySnapshot(snapshot); this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels(); this.updatePanels();
} finally { } finally {
this.resetButton.disabled = false; 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) { private applySnapshot(snapshot: WorldSnapshot) {
const signature = `${snapshot.seed}|${snapshot.systems.length}`; const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.worldSignature) { if (signature !== this.worldSignature) {
this.worldSignature = signature; this.worldSignature = signature;
this.rebuildSystems(snapshot.systems); this.rebuildSystems(snapshot.systems);
} }
this.rebuildNodes(snapshot.nodes);
this.rebuildStations(snapshot.stations); this.syncNodes(snapshot.nodes);
this.rebuildShips(snapshot.ships); this.syncStations(snapshot.stations);
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions); 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[]) { private rebuildSystems(systems: SystemSnapshot[]) {
this.systemGroup.clear(); this.systemGroup.clear();
this.selectableTargets.clear(); this.selectableTargets.clear();
for (const system of systems) { for (const system of systems) {
const root = new THREE.Group(); const root = new THREE.Group();
root.position.set(system.position.x, system.position.y, system.position.z); root.position.set(system.position.x, system.position.y, system.position.z);
@@ -168,6 +288,7 @@ export class GameViewer {
root.add(star, halo); root.add(star, halo);
this.selectableTargets.set(star, { kind: "system", id: system.id }); this.selectableTargets.set(star, { kind: "system", id: system.id });
this.selectableTargets.set(halo, { kind: "system", id: system.id }); this.selectableTargets.set(halo, { kind: "system", id: system.id });
for (const planet of system.planets) { for (const planet of system.planets) {
const orbit = new THREE.LineLoop( const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints( new THREE.BufferGeometry().setFromPoints(
@@ -193,50 +314,115 @@ export class GameViewer {
planetMesh.position.set(planet.orbitRadius, 0, 0); planetMesh.position.set(planet.orbitRadius, 0, 0);
root.add(orbit, planetMesh); root.add(orbit, planetMesh);
} }
this.systemGroup.add(root); this.systemGroup.add(root);
} }
} }
private rebuildNodes(nodes: ResourceNodeSnapshot[]) { private syncNodes(nodes: ResourceNodeSnapshot[]) {
this.nodeGroup.clear(); this.nodeGroup.clear();
this.nodeMeshes.clear();
for (const node of nodes) { for (const node of nodes) {
const mesh = new THREE.Mesh( const mesh = this.createNodeMesh(node);
new THREE.IcosahedronGeometry(12, 0), this.nodeMeshes.set(node.id, mesh);
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);
this.nodeGroup.add(mesh); this.nodeGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "node", id: node.id }); this.selectableTargets.set(mesh, { kind: "node", id: node.id });
} }
} }
private rebuildStations(stations: StationSnapshot[]) { private syncStations(stations: StationSnapshot[]) {
this.stationGroup.clear(); this.stationGroup.clear();
this.stationMeshes.clear();
for (const station of stations) { for (const station of stations) {
const mesh = new THREE.Mesh( const mesh = this.createStationMesh(station);
new THREE.CylinderGeometry(24, 24, 18, 10), this.stationMeshes.set(station.id, mesh);
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);
this.stationGroup.add(mesh); this.stationGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "station", id: station.id }); this.selectableTargets.set(mesh, { kind: "station", id: station.id });
} }
} }
private rebuildShips(ships: ShipSnapshot[]) { private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
this.shipGroup.clear(); this.shipGroup.clear();
this.shipVisuals.clear();
for (const ship of ships) { for (const ship of ships) {
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7); const mesh = this.createShipMesh(ship);
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);
this.shipGroup.add(mesh); this.shipGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); 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() { private updatePanels() {
if (!this.snapshot) { if (!this.world) {
return; return;
} }
if (!this.selected) { if (!this.selected) {
this.detailTitleEl.textContent = this.snapshot.label; this.detailTitleEl.textContent = this.world.label;
this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}<br>Stations ${this.snapshot.stations.length}<br>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 = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
<p class="history">${ship.history.join("<br>")}</p>
`;
}
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 = `
<p>${station.category} · ${station.systemId}</p>
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
`;
}
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 = `
<p>${node.systemId}</p>
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
}
return;
}
const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id);
if (system) {
this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${system.id}</p> Systems ${this.world.systems.size}<br>
<p>Planets ${system.planets.length}</p> Stations ${this.world.stations.size}<br>
Ships ${this.world.ships.size}<br>
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 = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
<p>Velocity ${this.formatVector(ship.velocity)}</p>
<p class="history">${ship.history.join("<br>")}</p>
`;
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 = `
<p>${station.category} · ${station.systemId}</p>
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
<p class="history">${this.renderRecentEvents("station", station.id)}</p>
`;
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 = `
<p>${node.systemId}</p>
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
}
const system = this.world.systems.get(this.selected.id);
if (!system) {
return;
}
this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = `
<p>${system.id}</p>
<p>Planets ${system.planets.length}</p>
`;
} }
private render() { private render() {
const delta = Math.min(this.clock.getDelta(), 0.033); 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.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.camera.lookAt(this.focus);
this.updateShipPresentation();
this.renderer.render(this.scene, this.camera); 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("<br>");
}
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) => { private onPointerDown = (event: PointerEvent) => {
this.dragging = true; this.dragging = true;
this.lastPointer.set(event.clientX, event.clientY); this.lastPointer.set(event.clientX, event.clientY);
@@ -351,7 +619,7 @@ export class GameViewer {
}; };
private onDoubleClick = () => { private onDoubleClick = () => {
if (!this.snapshot || !this.selected) { if (!this.world || !this.selected) {
return; return;
} }
const nextFocus = this.resolveSelectionPosition(this.selected); const nextFocus = this.resolveSelectionPosition(this.selected);
@@ -369,23 +637,27 @@ export class GameViewer {
}; };
private resolveSelectionPosition(selection: Selectable) { private resolveSelectionPosition(selection: Selectable) {
if (!this.snapshot) { if (!this.world) {
return undefined; return undefined;
} }
if (selection.kind === "ship") { if (selection.kind === "ship") {
const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id); const ship = this.world.ships.get(selection.id);
return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined; return ship ? this.toThreeVector(ship.position) : undefined;
} }
if (selection.kind === "station") { if (selection.kind === "station") {
const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id); const station = this.world.stations.get(selection.id);
return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined; return station ? this.toThreeVector(station.position) : undefined;
} }
if (selection.kind === "node") { if (selection.kind === "node") {
const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id); const node = this.world.nodes.get(selection.id);
return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined; 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) { private shipSize(ship: ShipSnapshot) {

View File

@@ -1,4 +1,4 @@
import type { WorldSnapshot } from "./contracts"; import type { WorldDelta, WorldSnapshot } from "./contracts";
export async function fetchWorldSnapshot(signal?: AbortSignal) { export async function fetchWorldSnapshot(signal?: AbortSignal) {
const response = await fetch("/api/world", { signal }); const response = await fetch("/api/world", { signal });
@@ -8,6 +8,24 @@ export async function fetchWorldSnapshot(signal?: AbortSignal) {
return response.json() as Promise<WorldSnapshot>; return response.json() as Promise<WorldSnapshot>;
} }
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<string>;
handlers.onDelta(JSON.parse(message.data) as WorldDelta);
});
return stream;
}
export async function resetWorld() { export async function resetWorld() {
const response = await fetch("/api/world/reset", { const response = await fetch("/api/world/reset", {
method: "POST", method: "POST",

View File

@@ -1,6 +1,8 @@
export interface WorldSnapshot { export interface WorldSnapshot {
label: string; label: string;
seed: number; seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string; generatedAtUtc: string;
systems: SystemSnapshot[]; systems: SystemSnapshot[];
nodes: ResourceNodeSnapshot[]; nodes: ResourceNodeSnapshot[];
@@ -9,6 +11,26 @@ export interface WorldSnapshot {
factions: FactionSnapshot[]; 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 { export interface Vector3Dto {
x: number; x: number;
y: number; y: number;
@@ -41,6 +63,8 @@ export interface ResourceNodeSnapshot {
itemId: string; itemId: string;
} }
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface StationSnapshot { export interface StationSnapshot {
id: string; id: string;
label: string; label: string;
@@ -54,6 +78,8 @@ export interface StationSnapshot {
factionId: string; factionId: string;
} }
export interface StationDelta extends StationSnapshot {}
export interface ShipSnapshot { export interface ShipSnapshot {
id: string; id: string;
label: string; label: string;
@@ -61,6 +87,8 @@ export interface ShipSnapshot {
shipClass: string; shipClass: string;
systemId: string; systemId: string;
position: Vector3Dto; position: Vector3Dto;
velocity: Vector3Dto;
targetPosition: Vector3Dto;
state: string; state: string;
orderKind: string | null; orderKind: string | null;
defaultBehaviorKind: string; defaultBehaviorKind: string;
@@ -73,6 +101,8 @@ export interface ShipSnapshot {
history: string[]; history: string[];
} }
export interface ShipDelta extends ShipSnapshot {}
export interface FactionSnapshot { export interface FactionSnapshot {
id: string; id: string;
label: string; label: string;
@@ -83,3 +113,5 @@ export interface FactionSnapshot {
shipsBuilt: number; shipsBuilt: number;
shipsLost: number; shipsLost: number;
} }
export interface FactionDelta extends FactionSnapshot {}