import { derived, get, writable } from 'svelte/store'; import { browser } from '$app/environment'; import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js'; import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js'; import { serverTitle } from '../server-labels.js'; import { toast } from '../toast.js'; export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({ servers: [], operations: [], receivedAt: 0 }); export const fleetError = writable(''); export const fleetRefreshing = writable(false); export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null); export const lobbyWatches = writable([]); export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) => snapshot.servers.map(server => { const status = received > snapshot.receivedAt ? live.get(server.name) : undefined; const current = status ? { ...server, last_players: status.players, last_map: status.map, last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError, roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince, inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt, game_ended_at: status.gameEndedAt } : server; const match = matches.get(server.name); // Apply lifecycle events immediately, until a newer status observes them // or confirms that the lobby has emptied. if (!match || Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '') || match.receivedAt < (current.game_started_at ?? '') || match.receivedAt < (current.game_ended_at ?? '')) return current; return { ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at, game_ended_at: match.event === 'game_end' ? match.receivedAt : null }; })); let inFlight: Promise | null = null; export function refreshFleet(): Promise { if (!browser) return Promise.resolve(); if (inFlight) return inFlight; inFlight = (async () => { fleetRefreshing.set(true); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 12_000); const requestedAt = Date.now(); try { const responses = await Promise.all([ fetch('/api/servers', { signal: controller.signal }), fetch('/api/fleet', { signal: controller.signal }) ]); if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.'); const [servers, operations] = await Promise.all(responses.map(response => response.json())); syncServerTime(servers.serverNow); fleetError.set(''); fleetIntegrations.set(servers.integrations); fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt }); } catch (error) { fleetError.set(error instanceof Error && error.name !== 'AbortError' ? error.message : 'Fleet refresh timed out. Retrying automatically.'); } finally { clearTimeout(timeout); fleetRefreshing.set(false); } })().finally(() => { inFlight = null; }); return inFlight; } export function followLobby(server: string, kind: LobbyWatch['kind'], operation?: FleetOperation): void { const id = `${server}:${kind}:${operation?.key ?? ''}`; lobbyWatches.update(watches => watches.some(watch => watch.id === id) ? watches : [...watches, { id, server, kind, operationKey: operation?.key, href: operation?.href ?? `/servers/${encodeURIComponent(server)}` }]); toast('Watching this lobby while the app is open.'); } export function cancelWatch(id: string): void { lobbyWatches.update(watches => watches.filter(watch => watch.id !== id)); } let stop: (() => void) | null = null; export function startFleetMonitor(): () => void { if (!browser || stop) return () => {}; try { const saved: unknown = JSON.parse(sessionStorage.getItem('electric-cherry:lobby-watches') ?? '[]'); if (Array.isArray(saved)) lobbyWatches.set(saved.filter((watch): watch is LobbyWatch => watch && typeof watch.id === 'string' && typeof watch.server === 'string' && ['empty', 'online', 'operation'].includes(watch.kind) && typeof watch.href === 'string' && /^\/(servers|campaigns|deployments)\//.test(watch.href) && (watch.kind !== 'operation' || typeof watch.operationKey === 'string'))); } catch { /* Watches still work with session storage disabled. */ } const stopSaving = lobbyWatches.subscribe(watches => { try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ } }); void refreshFleet(); const interval = setInterval(() => { if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet(); }, 10_000); const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); }; document.addEventListener('visibilitychange', onFocus); const check = () => { // Never complete a watch using an operation snapshot from a failed refresh. if (get(fleetError)) return; const snapshot = get(fleet); if (!snapshot.receivedAt || Date.now() - snapshot.receivedAt > 30_000) return; const servers = get(fleetServers); for (const watch of get(lobbyWatches)) { if (!watchSatisfied(watch, servers.find(server => server.name === watch.server), snapshot.operations, serverTimeNow())) continue; const message = `${serverTitle(watch.server)} ${watch.kind === 'empty' ? 'is empty' : watch.kind === 'online' ? 'is back online' : 'has finished its operation'}.`; cancelWatch(watch.id); toast(message, { variant: 'success', ttl: 0, href: watch.href }); } }; const unsubscribe = fleetServers.subscribe(check); stop = () => { clearInterval(interval); document.removeEventListener('visibilitychange', onFocus); unsubscribe(); stopSaving(); stop = null; }; return stop; }