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

@@ -1,11 +1,18 @@
import * as THREE from "three";
import { fetchWorldSnapshot, resetWorld } from "./api";
import { fetchWorldSnapshot, openWorldStream, resetWorld } from "./api";
import type {
FactionDelta,
FactionSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SimulationEventRecord,
StationDelta,
StationSnapshot,
SystemSnapshot,
Vector3Dto,
WorldDelta,
WorldSnapshot,
} from "./contracts";
@@ -15,6 +22,30 @@ type Selectable =
| { kind: "node"; id: string }
| { kind: "system"; id: string };
interface ShipVisual {
mesh: THREE.Mesh;
startPosition: THREE.Vector3;
authoritativePosition: THREE.Vector3;
targetPosition: THREE.Vector3;
velocity: THREE.Vector3;
receivedAtMs: number;
blendDurationMs: number;
}
interface WorldState {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: Map<string, SystemSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
ships: Map<string, ShipSnapshot>;
factions: Map<string, FactionSnapshot>;
recentEvents: SimulationEventRecord[];
}
export class GameViewer {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -29,13 +60,18 @@ export class GameViewer {
private readonly stationGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group();
private readonly selectableTargets = new Map<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 detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly resetButton: HTMLButtonElement;
private readonly errorEl: HTMLDivElement;
private snapshot?: WorldSnapshot;
private readonly streamEl: HTMLDivElement;
private world?: WorldState;
private stream?: EventSource;
private selected?: Selectable;
private dragging = false;
private lastPointer = new THREE.Vector2();
@@ -66,20 +102,22 @@ export class GameViewer {
<h1>Space Game Observer</h1>
</div>
<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>
</div>
</header>
<aside class="details-panel">
<h2>Selection</h2>
<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>
</aside>
<section class="faction-strip"></section>
`;
this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement;
this.streamEl = hud.querySelector(".stream-pill") as HTMLDivElement;
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
@@ -100,55 +138,137 @@ export class GameViewer {
}
async start() {
await this.refreshSnapshot();
window.setInterval(() => {
void this.refreshSnapshot();
}, 500);
await this.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
}
private async refreshSnapshot() {
private async bootstrapWorld() {
try {
const snapshot = await fetchWorldSnapshot();
this.snapshot = snapshot;
this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`;
this.world = this.createWorldState(snapshot);
this.statusEl.textContent = `Snapshot ${snapshot.sequence}`;
this.errorEl.hidden = true;
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
} catch (error) {
this.statusEl.textContent = "Backend offline";
this.streamEl.textContent = "Stream Offline";
this.errorEl.hidden = false;
this.errorEl.textContent = error instanceof Error ? error.message : "Unable to load the backend snapshot.";
this.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
}
}
private openDeltaStream(afterSequence: number) {
this.stream?.close();
this.stream = openWorldStream(afterSequence, {
onOpen: () => {
this.streamEl.textContent = "Stream Live";
},
onError: () => {
this.streamEl.textContent = "Stream Reconnecting";
},
onDelta: (delta) => {
void this.handleDelta(delta);
},
});
}
private async handleDelta(delta: WorldDelta) {
if (!this.world) {
return;
}
if (delta.requiresSnapshotRefresh) {
await this.bootstrapWorld();
return;
}
this.applyDelta(delta);
this.statusEl.textContent = `Seq ${delta.sequence} · ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`;
this.updatePanels();
}
private async handleReset() {
this.resetButton.disabled = true;
try {
const snapshot = await resetWorld();
this.snapshot = snapshot;
this.world = this.createWorldState(snapshot);
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
} finally {
this.resetButton.disabled = false;
}
}
private createWorldState(snapshot: WorldSnapshot): WorldState {
return {
label: snapshot.label,
seed: snapshot.seed,
sequence: snapshot.sequence,
tickIntervalMs: snapshot.tickIntervalMs,
generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])),
ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])),
factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])),
recentEvents: [],
};
}
private applySnapshot(snapshot: WorldSnapshot) {
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.worldSignature) {
this.worldSignature = signature;
this.rebuildSystems(snapshot.systems);
}
this.rebuildNodes(snapshot.nodes);
this.rebuildStations(snapshot.stations);
this.rebuildShips(snapshot.ships);
this.syncNodes(snapshot.nodes);
this.syncStations(snapshot.stations);
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions);
}
private applyDelta(delta: WorldDelta) {
if (!this.world) {
return;
}
this.world.sequence = delta.sequence;
this.world.tickIntervalMs = delta.tickIntervalMs;
this.world.generatedAtUtc = delta.generatedAtUtc;
this.world.recentEvents = [...delta.events, ...this.world.recentEvents].slice(0, 18);
for (const node of delta.nodes) {
this.world.nodes.set(node.id, node);
}
for (const station of delta.stations) {
this.world.stations.set(station.id, station);
}
for (const ship of delta.ships) {
this.world.ships.set(ship.id, ship);
}
for (const faction of delta.factions) {
this.world.factions.set(faction.id, faction);
}
this.applyNodeDeltas(delta.nodes);
this.applyStationDeltas(delta.stations);
this.applyShipDeltas(delta.ships, delta.tickIntervalMs);
if (delta.factions.length > 0) {
this.rebuildFactions([...this.world.factions.values()]);
}
}
private rebuildSystems(systems: SystemSnapshot[]) {
this.systemGroup.clear();
this.selectableTargets.clear();
for (const system of systems) {
const root = new THREE.Group();
root.position.set(system.position.x, system.position.y, system.position.z);
@@ -168,6 +288,7 @@ export class GameViewer {
root.add(star, halo);
this.selectableTargets.set(star, { kind: "system", id: system.id });
this.selectableTargets.set(halo, { kind: "system", id: system.id });
for (const planet of system.planets) {
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
@@ -193,50 +314,115 @@ export class GameViewer {
planetMesh.position.set(planet.orbitRadius, 0, 0);
root.add(orbit, planetMesh);
}
this.systemGroup.add(root);
}
}
private rebuildNodes(nodes: ResourceNodeSnapshot[]) {
private syncNodes(nodes: ResourceNodeSnapshot[]) {
this.nodeGroup.clear();
this.nodeMeshes.clear();
for (const node of nodes) {
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
);
mesh.position.set(node.position.x, node.position.y, node.position.z);
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
const mesh = this.createNodeMesh(node);
this.nodeMeshes.set(node.id, mesh);
this.nodeGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
}
}
private rebuildStations(stations: StationSnapshot[]) {
private syncStations(stations: StationSnapshot[]) {
this.stationGroup.clear();
this.stationMeshes.clear();
for (const station of stations) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
mesh.position.set(station.position.x, station.position.y, station.position.z);
const mesh = this.createStationMesh(station);
this.stationMeshes.set(station.id, mesh);
this.stationGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
}
}
private rebuildShips(ships: ShipSnapshot[]) {
private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
this.shipGroup.clear();
this.shipVisuals.clear();
for (const ship of ships) {
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
);
mesh.position.set(ship.position.x, ship.position.y, ship.position.z);
const mesh = this.createShipMesh(ship);
this.shipGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
const position = this.toThreeVector(ship.position);
this.shipVisuals.set(ship.id, {
mesh,
startPosition: position.clone(),
authoritativePosition: position.clone(),
targetPosition: this.toThreeVector(ship.targetPosition),
velocity: this.toThreeVector(ship.velocity),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(tickIntervalMs, 80),
});
}
}
private applyNodeDeltas(nodes: ResourceNodeDelta[]) {
for (const node of nodes) {
const mesh = this.nodeMeshes.get(node.id);
if (!mesh) {
const nextMesh = this.createNodeMesh(node);
this.nodeMeshes.set(node.id, nextMesh);
this.nodeGroup.add(nextMesh);
this.selectableTargets.set(nextMesh, { kind: "node", id: node.id });
continue;
}
mesh.position.copy(this.toThreeVector(node.position));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
}
}
private applyStationDeltas(stations: StationDelta[]) {
for (const station of stations) {
const mesh = this.stationMeshes.get(station.id);
if (!mesh) {
const nextMesh = this.createStationMesh(station);
this.stationMeshes.set(station.id, nextMesh);
this.stationGroup.add(nextMesh);
this.selectableTargets.set(nextMesh, { kind: "station", id: station.id });
continue;
}
mesh.position.copy(this.toThreeVector(station.position));
const material = mesh.material as THREE.MeshStandardMaterial;
material.color.set(station.color);
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
}
}
private applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) {
for (const ship of ships) {
const visual = this.shipVisuals.get(ship.id);
if (!visual) {
const mesh = this.createShipMesh(ship);
const position = this.toThreeVector(ship.position);
this.shipGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
this.shipVisuals.set(ship.id, {
mesh,
startPosition: position.clone(),
authoritativePosition: position.clone(),
targetPosition: this.toThreeVector(ship.targetPosition),
velocity: this.toThreeVector(ship.velocity),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(tickIntervalMs, 80),
});
continue;
}
visual.startPosition.copy(visual.mesh.position);
visual.authoritativePosition.copy(this.toThreeVector(ship.position));
visual.targetPosition.copy(this.toThreeVector(ship.targetPosition));
visual.velocity.copy(this.toThreeVector(ship.velocity));
visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(this.shipColor(ship.role));
}
}
@@ -255,71 +441,153 @@ export class GameViewer {
}
private updatePanels() {
if (!this.snapshot) {
if (!this.world) {
return;
}
if (!this.selected) {
this.detailTitleEl.textContent = this.snapshot.label;
this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}<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.detailTitleEl.textContent = this.world.label;
this.detailBodyEl.innerHTML = `
<p>${system.id}</p>
<p>Planets ${system.planets.length}</p>
Systems ${this.world.systems.size}<br>
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() {
const delta = Math.min(this.clock.getDelta(), 0.033);
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
this.camera.lookAt(this.focus);
this.updateShipPresentation();
this.renderer.render(this.scene, this.camera);
}
private updateShipPresentation() {
const now = performance.now();
for (const visual of this.shipVisuals.values()) {
const elapsedMs = now - visual.receivedAtMs;
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
visual.mesh.position.lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
if (blendT >= 1) {
const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35);
visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
}
const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position);
if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
}
}
}
private createNodeMesh(node: ResourceNodeSnapshot) {
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
);
mesh.position.copy(this.toThreeVector(node.position));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh;
}
private createStationMesh(station: StationSnapshot) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
mesh.position.copy(this.toThreeVector(station.position));
return mesh;
}
private createShipMesh(ship: ShipSnapshot) {
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
);
mesh.position.copy(this.toThreeVector(ship.position));
return mesh;
}
private renderRecentEvents(entityKind: string, entityId: string) {
if (!this.world) {
return "";
}
return this.world.recentEvents
.filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId))
.slice(0, 8)
.map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`)
.join("<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) => {
this.dragging = true;
this.lastPointer.set(event.clientX, event.clientY);
@@ -351,7 +619,7 @@ export class GameViewer {
};
private onDoubleClick = () => {
if (!this.snapshot || !this.selected) {
if (!this.world || !this.selected) {
return;
}
const nextFocus = this.resolveSelectionPosition(this.selected);
@@ -369,23 +637,27 @@ export class GameViewer {
};
private resolveSelectionPosition(selection: Selectable) {
if (!this.snapshot) {
if (!this.world) {
return undefined;
}
if (selection.kind === "ship") {
const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id);
return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined;
const ship = this.world.ships.get(selection.id);
return ship ? this.toThreeVector(ship.position) : undefined;
}
if (selection.kind === "station") {
const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id);
return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined;
const station = this.world.stations.get(selection.id);
return station ? this.toThreeVector(station.position) : undefined;
}
if (selection.kind === "node") {
const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id);
return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined;
const node = this.world.nodes.get(selection.id);
return node ? this.toThreeVector(node.position) : undefined;
}
const system = this.snapshot.systems.find((candidate) => candidate.id === selection.id);
return system ? new THREE.Vector3(system.position.x, system.position.y, system.position.z) : undefined;
const system = this.world.systems.get(selection.id);
return system ? this.toThreeVector(system.position) : undefined;
}
private shipSize(ship: ShipSnapshot) {

View File

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

View File

@@ -1,6 +1,8 @@
export interface WorldSnapshot {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
nodes: ResourceNodeSnapshot[];
@@ -9,6 +11,26 @@ export interface WorldSnapshot {
factions: FactionSnapshot[];
}
export interface WorldDelta {
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
nodes: ResourceNodeDelta[];
stations: StationDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
}
export interface SimulationEventRecord {
entityKind: string;
entityId: string;
kind: string;
message: string;
occurredAtUtc: string;
}
export interface Vector3Dto {
x: number;
y: number;
@@ -41,6 +63,8 @@ export interface ResourceNodeSnapshot {
itemId: string;
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface StationSnapshot {
id: string;
label: string;
@@ -54,6 +78,8 @@ export interface StationSnapshot {
factionId: string;
}
export interface StationDelta extends StationSnapshot {}
export interface ShipSnapshot {
id: string;
label: string;
@@ -61,6 +87,8 @@ export interface ShipSnapshot {
shipClass: string;
systemId: string;
position: Vector3Dto;
velocity: Vector3Dto;
targetPosition: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
@@ -73,6 +101,8 @@ export interface ShipSnapshot {
history: string[];
}
export interface ShipDelta extends ShipSnapshot {}
export interface FactionSnapshot {
id: string;
label: string;
@@ -83,3 +113,5 @@ export interface FactionSnapshot {
shipsBuilt: number;
shipsLost: number;
}
export interface FactionDelta extends FactionSnapshot {}