145 lines
6.0 KiB
TypeScript
145 lines
6.0 KiB
TypeScript
import { writable, readonly, get } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
import type { GameTelemetry } from '$lib/telemetry.js';
|
|
|
|
export const telemetryByServer = writable<Map<string, GameTelemetry>>(new Map());
|
|
export const matchEventsByServer = writable<Map<string, GameTelemetry>>(new Map());
|
|
|
|
export type { StatusPlayer as RosterPlayer } from '$lib/server-observation.js';
|
|
import type { StatusPlayer as RosterPlayer, GameObservation } from '$lib/server-observation.js';
|
|
|
|
export type Health = 'online' | 'stale' | 'offline' | 'starting' | 'provisioning';
|
|
export type RconFailure = 'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players';
|
|
|
|
export interface RconEndpoint {
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
export interface ServerStatus {
|
|
receivedAt?: number;
|
|
server: string;
|
|
containerState?: string | null;
|
|
containerCheckedAt?: string | null;
|
|
telemetryAfter?: string | null;
|
|
rconStatus?: GameObservation;
|
|
telemetry?: GameTelemetry;
|
|
players: number | null;
|
|
map: string | null;
|
|
gametype: string | null;
|
|
roster: RosterPlayer[] | null;
|
|
health: Health;
|
|
lastSeenAt: string | null;
|
|
lastError: string | null;
|
|
endpoint: RconEndpoint | null;
|
|
reason: RconFailure | null;
|
|
replySnippet: string | null;
|
|
activeSince: string | null;
|
|
inactiveSince: string | null;
|
|
gameStartedAt: string | null;
|
|
gameEndedAt: string | null;
|
|
}
|
|
|
|
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
|
export const statusReceivedAt = writable(0);
|
|
|
|
// Shared 1s ticker for elapsed-time displays. One interval feeds every card
|
|
// on the page instead of N per-card timers. Timer starts on the first
|
|
// startServerStream() call in the browser.
|
|
const _uiNow = writable(Date.now());
|
|
export const uiNow = readonly(_uiNow);
|
|
let serverClock: { time: number; receivedAt: number } | null = null;
|
|
|
|
// Report timestamps belong to the backend clock, which can differ from the
|
|
// browser's wall clock. Advance that clock locally even if updates stop.
|
|
export function serverTimeNow(): number {
|
|
return serverClock ? serverClock.time + performance.now() - serverClock.receivedAt : Date.now();
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
let source: EventSource | null = null;
|
|
|
|
export function startServerStream(): (() => void) | undefined {
|
|
if (!browser || source) return;
|
|
syncServerTime();
|
|
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 };
|
|
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);
|
|
}
|
|
}
|
|
schedule();
|
|
});
|
|
stream.addEventListener('server-status', (evt) => {
|
|
if (stopped) return;
|
|
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[]; serverNow?: number; delta?: boolean };
|
|
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 () => {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
if (frame !== null) cancelAnimationFrame(frame);
|
|
pendingStatus = pendingTelemetry = pendingMatches = null;
|
|
stream.close(); source = null;
|
|
if (tickInterval) clearInterval(tickInterval);
|
|
tickInterval = null;
|
|
};
|
|
}
|