diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index e3266e0..19fcfb8 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -46,6 +46,24 @@ interface WorldState { recentEvents: SimulationEventRecord[]; } +interface NetworkSample { + atMs: number; + bytes: number; +} + +interface NetworkStats { + snapshotBytes: number; + deltasReceived: number; + deltaBytes: number; + lastDeltaBytes: number; + lastEntityChanges: number; + eventsReceived: number; + streamConnected: boolean; + streamOpenedAtMs?: number; + lastDeltaAtMs?: number; + throughputSamples: NetworkSample[]; +} + export class GameViewer { private readonly container: HTMLElement; private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -67,11 +85,22 @@ export class GameViewer { private readonly detailTitleEl: HTMLHeadingElement; private readonly detailBodyEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement; + private readonly networkPanelEl: HTMLDivElement; private readonly resetButton: HTMLButtonElement; private readonly errorEl: HTMLDivElement; private readonly streamEl: HTMLDivElement; private world?: WorldState; private stream?: EventSource; + private readonly networkStats: NetworkStats = { + snapshotBytes: 0, + deltasReceived: 0, + deltaBytes: 0, + lastDeltaBytes: 0, + lastEntityChanges: 0, + eventsReceived: 0, + streamConnected: false, + throughputSamples: [], + }; private selected?: Selectable; private dragging = false; private lastPointer = new THREE.Vector2(); @@ -113,6 +142,10 @@ export class GameViewer {
Waiting for the authoritative snapshot.
+
`; @@ -121,6 +154,7 @@ export class GameViewer { this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement; this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement; + this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement; this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement; this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement; @@ -146,6 +180,7 @@ export class GameViewer { try { const snapshot = await fetchWorldSnapshot(); this.world = this.createWorldState(snapshot); + this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size; this.statusEl.textContent = `Snapshot ${snapshot.sequence}`; this.errorEl.hidden = true; this.applySnapshot(snapshot); @@ -163,18 +198,23 @@ export class GameViewer { this.stream?.close(); this.stream = openWorldStream(afterSequence, { onOpen: () => { + this.networkStats.streamConnected = true; + this.networkStats.streamOpenedAtMs = performance.now(); this.streamEl.textContent = "Stream Live"; + this.updateNetworkPanel(); }, onError: () => { + this.networkStats.streamConnected = false; this.streamEl.textContent = "Stream Reconnecting"; + this.updateNetworkPanel(); }, - onDelta: (delta) => { - void this.handleDelta(delta); + onDelta: (delta, rawBytes) => { + void this.handleDelta(delta, rawBytes); }, }); } - private async handleDelta(delta: WorldDelta) { + private async handleDelta(delta: WorldDelta, rawBytes: number) { if (!this.world) { return; } @@ -185,8 +225,10 @@ export class GameViewer { } this.applyDelta(delta); + this.recordDeltaStats(delta, rawBytes); this.statusEl.textContent = `Seq ${delta.sequence} ยท ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`; this.updatePanels(); + this.updateNetworkPanel(); } private async handleReset() { @@ -194,9 +236,11 @@ export class GameViewer { try { const snapshot = await resetWorld(); this.world = this.createWorldState(snapshot); + this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size; this.applySnapshot(snapshot); this.openDeltaStream(snapshot.sequence); this.updatePanels(); + this.updateNetworkPanel(); } finally { this.resetButton.disabled = false; } @@ -229,6 +273,7 @@ export class GameViewer { this.syncStations(snapshot.stations); this.syncShips(snapshot.ships, snapshot.tickIntervalMs); this.rebuildFactions(snapshot.factions); + this.updateNetworkPanel(); } private applyDelta(delta: WorldDelta) { @@ -515,9 +560,51 @@ export class GameViewer { 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.updateNetworkPanel(); this.renderer.render(this.scene, this.camera); } + private recordDeltaStats(delta: WorldDelta, rawBytes: number) { + const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length; + this.networkStats.deltasReceived += 1; + this.networkStats.deltaBytes += rawBytes; + this.networkStats.lastDeltaBytes = rawBytes; + this.networkStats.lastEntityChanges = changedEntities; + this.networkStats.eventsReceived += delta.events.length; + this.networkStats.lastDeltaAtMs = performance.now(); + this.networkStats.throughputSamples.push({ + atMs: performance.now(), + bytes: rawBytes, + }); + const cutoff = performance.now() - 4000; + this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff); + } + + private updateNetworkPanel() { + const now = performance.now(); + const uptimeSeconds = this.networkStats.streamOpenedAtMs + ? (now - this.networkStats.streamOpenedAtMs) / 1000 + : 0; + const recentBytes = this.networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0); + const recentWindowSeconds = this.networkStats.throughputSamples.length > 1 + ? Math.max((now - this.networkStats.throughputSamples[0].atMs) / 1000, 1) + : 1; + const kbPerSecond = recentBytes / 1024 / recentWindowSeconds; + const averageDeltaBytes = this.networkStats.deltasReceived > 0 + ? this.networkStats.deltaBytes / this.networkStats.deltasReceived + : 0; + const secondsSinceLastDelta = this.networkStats.lastDeltaAtMs + ? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1) + : "n/a"; + + this.networkPanelEl.textContent = this.buildNetworkPanelText({ + uptimeSeconds, + kbPerSecond, + averageDeltaBytes, + secondsSinceLastDelta, + }); + } + private updateShipPresentation() { const now = performance.now(); for (const visual of this.shipVisuals.values()) { @@ -584,6 +671,36 @@ export class GameViewer { return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`; } + private formatBytes(bytes: number) { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${Math.round(bytes)} B`; + } + + private buildNetworkPanelText(values: { + uptimeSeconds: number; + kbPerSecond: number; + averageDeltaBytes: number; + secondsSinceLastDelta: string; + }) { + return [ + `snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`, + `stream: ${this.networkStats.streamConnected ? "live" : "offline"}`, + `deltas: ${this.networkStats.deltasReceived}`, + `events: ${this.networkStats.eventsReceived}`, + `avg delta: ${this.formatBytes(values.averageDeltaBytes)}`, + `last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`, + `recent rate: ${values.kbPerSecond.toFixed(1)} KB/s`, + `changed: ${this.networkStats.lastEntityChanges}`, + `uptime: ${values.uptimeSeconds.toFixed(1)}s`, + `last packet: ${values.secondsSinceLastDelta}s`, + ].join("\n"); + } + private toThreeVector(vector: Vector3Dto) { return new THREE.Vector3(vector.x, vector.y, vector.z); } diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts index ce00052..a12d1c8 100644 --- a/apps/viewer/src/api.ts +++ b/apps/viewer/src/api.ts @@ -11,7 +11,7 @@ export async function fetchWorldSnapshot(signal?: AbortSignal) { export function openWorldStream( afterSequence: number, handlers: { - onDelta: (delta: WorldDelta) => void; + onDelta: (delta: WorldDelta, rawBytes: number) => void; onOpen?: () => void; onError?: () => void; }, @@ -21,7 +21,10 @@ export function openWorldStream( stream.addEventListener("error", () => handlers.onError?.()); stream.addEventListener("world-delta", (event) => { const message = event as MessageEvent; - handlers.onDelta(JSON.parse(message.data) as WorldDelta); + handlers.onDelta( + JSON.parse(message.data) as WorldDelta, + new Blob([message.data]).size, + ); }); return stream; } diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index ba3c842..0f79683 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -39,6 +39,7 @@ canvas { .topbar, .details-panel, +.network-panel, .faction-strip { position: absolute; backdrop-filter: blur(18px); @@ -118,6 +119,16 @@ canvas { overflow: auto; } +.network-panel { + top: 110px; + left: 20px; + width: min(360px, calc(100vw - 40px)); + border-radius: 24px; + padding: 18px; + color: var(--text); + pointer-events: auto; +} + .details-panel h2 { color: var(--accent); letter-spacing: 0.16em; @@ -125,6 +136,23 @@ canvas { text-transform: uppercase; } +.network-panel h2 { + margin: 0; + color: var(--accent); + letter-spacing: 0.16em; + font-size: 0.72rem; + text-transform: uppercase; +} + +.network-body { + margin-top: 14px; + color: var(--muted); + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.8rem; + line-height: 1.6; + white-space: pre-wrap; +} + .detail-title { margin-top: 12px; font-size: 1.05rem; @@ -214,6 +242,13 @@ canvas { max-height: 38vh; } + .network-panel { + top: 110px; + right: 20px; + left: 20px; + width: auto; + } + .faction-strip { left: 20px; right: 20px;