Batch live server updates once per animation frame
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { writable, readonly } from 'svelte/store';
|
||||
import { writable, readonly, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { GameTelemetry } from '$lib/telemetry.js';
|
||||
|
||||
@@ -56,13 +56,17 @@ export function serverTimeNow(): number {
|
||||
return serverClock ? serverClock.time + performance.now() - serverClock.receivedAt : Date.now();
|
||||
}
|
||||
|
||||
export function syncServerTime(serverNow?: number): void {
|
||||
function updateServerClock(serverNow?: number): void {
|
||||
if (typeof serverNow === 'number' && Number.isFinite(serverNow)) {
|
||||
// A delayed HTTP snapshot must not rewind time behind a newer stream
|
||||
// update and make that update look like it came from the future.
|
||||
const time = serverClock ? Math.max(serverNow, serverTimeNow()) : serverNow;
|
||||
serverClock = { time, receivedAt: performance.now() };
|
||||
}
|
||||
}
|
||||
|
||||
export function syncServerTime(serverNow?: number): void {
|
||||
updateServerClock(serverNow);
|
||||
// Refresh immediately: a suspended tab's interval may be well behind.
|
||||
_uiNow.set(serverTimeNow());
|
||||
}
|
||||
@@ -74,35 +78,67 @@ let source: EventSource | null = null;
|
||||
export function startServerStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
syncServerTime();
|
||||
source = new EventSource('/api/events/servers');
|
||||
source.addEventListener('server-telemetry', (evt) => {
|
||||
const stream = source = new EventSource('/api/events/servers');
|
||||
let stopped = false;
|
||||
let frame: number | null = null;
|
||||
let pendingStatus: Map<string, ServerStatus> | null = null;
|
||||
let pendingTelemetry: Map<string, GameTelemetry> | null = null;
|
||||
let pendingMatches: Map<string, GameTelemetry> | null = null;
|
||||
let receivedAt = 0;
|
||||
const flush = () => {
|
||||
frame = null;
|
||||
if (stopped) return;
|
||||
const statuses = pendingStatus, telemetry = pendingTelemetry, matches = pendingMatches;
|
||||
const statusAt = receivedAt;
|
||||
pendingStatus = pendingTelemetry = pendingMatches = null;
|
||||
// Publish the latest clock before exposing reports, including after a
|
||||
// suspended frame. Every changed store is published at most once per frame.
|
||||
syncServerTime();
|
||||
if (statuses) { statusByServer.set(statuses); statusReceivedAt.set(statusAt); }
|
||||
if (matches) matchEventsByServer.set(matches);
|
||||
if (telemetry) telemetryByServer.set(telemetry);
|
||||
};
|
||||
const schedule = () => { if (frame === null) frame = requestAnimationFrame(flush); };
|
||||
stream.addEventListener('server-telemetry', (evt) => {
|
||||
if (stopped) return;
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: GameTelemetry[]; snapshot?: boolean; serverNow?: number };
|
||||
syncServerTime(data.serverNow);
|
||||
matchEventsByServer.update((previous) => {
|
||||
const next = data.snapshot ? new Map<string, GameTelemetry>() : new Map(previous);
|
||||
for (const entry of data.entries) {
|
||||
if (entry.event === 'game_start' || entry.event === 'game_end') next.set(entry.server, entry);
|
||||
updateServerClock(data.serverNow);
|
||||
// Merge into one private map per frame. Snapshots replace all earlier
|
||||
// evidence; later deltas still apply in arrival order.
|
||||
if (data.snapshot) {
|
||||
pendingTelemetry = new Map();
|
||||
pendingMatches = new Map();
|
||||
}
|
||||
pendingTelemetry ??= new Map(get(telemetryByServer));
|
||||
for (const entry of data.entries) {
|
||||
pendingTelemetry.set(entry.server, entry);
|
||||
if (entry.event === 'game_start' || entry.event === 'game_end') {
|
||||
pendingMatches ??= new Map(get(matchEventsByServer));
|
||||
pendingMatches.set(entry.server, entry);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
telemetryByServer.update((previous) => {
|
||||
const next = data.snapshot ? new Map<string, GameTelemetry>() : new Map(previous);
|
||||
for (const entry of data.entries) next.set(entry.server, entry);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
schedule();
|
||||
});
|
||||
source.addEventListener('server-status', (evt) => {
|
||||
stream.addEventListener('server-status', (evt) => {
|
||||
if (stopped) return;
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[]; serverNow?: number; delta?: boolean };
|
||||
syncServerTime(data.serverNow);
|
||||
statusByServer.update(previous => {
|
||||
const m = data.delta ? new Map(previous) : new Map<string, ServerStatus>();
|
||||
for (const e of data.entries) m.set(e.server, { ...e, receivedAt: Date.now() });
|
||||
return m;
|
||||
});
|
||||
statusReceivedAt.set(Date.now());
|
||||
updateServerClock(data.serverNow);
|
||||
receivedAt = Date.now();
|
||||
if (!data.delta) pendingStatus = new Map();
|
||||
pendingStatus ??= new Map(get(statusByServer));
|
||||
for (const entry of data.entries) pendingStatus.set(entry.server, { ...entry, receivedAt });
|
||||
schedule();
|
||||
});
|
||||
if (tickInterval === null) {
|
||||
tickInterval = setInterval(() => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') _uiNow.set(serverTimeNow()); }, 1000);
|
||||
}
|
||||
return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; };
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
pendingStatus = pendingTelemetry = pendingMatches = null;
|
||||
stream.close(); source = null;
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
tickInterval = null;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user