import { writable, readonly, get } from 'svelte/store'; import { browser } from '$app/environment'; import type { GameTelemetry } from '$lib/telemetry.js'; export const telemetryByServer = writable>(new Map()); export const matchEventsByServer = writable>(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>(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 | 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 | null = null; let pendingTelemetry: Map | null = null; let pendingMatches: Map | 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; }; }