Add world delta streaming and viewer smoothing
This commit is contained in:
109
NEXT-STEPS.md
109
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.
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user