import { freshStatusTimestamp } from './server-freshness.js'; import { parseGameEvent, type GameTelemetry } from './telemetry.js'; export interface StatusPlayer { num: number | null; score: number | null; ping: number | null; guid: string | null; name: string; } export interface GameObservation { last_players: number | null; last_map: string | null; last_gametype: string | null; last_seen_at: string | null; last_error: string | null; /** Consecutive failed monitoring checks; absent on older snapshots. */ rcon_failures?: number; roster: StatusPlayer[] | null; } export interface ServerObservation extends GameObservation { name: string; game?: string | null; health: 'online' | 'stale' | 'offline' | 'starting' | 'provisioning'; container_state?: string | null; container_checked_at?: string | null; last_checked_at?: string | null; telemetry_after?: string | null; status_source?: 'telemetry' | 'rcon' | null; rcon_status?: GameObservation; telemetry?: GameTelemetry; active_since?: string | null; inactive_since?: string | null; game_started_at?: string | null; game_ended_at?: string | null; } /** Keep a first transient monitoring failure in diagnostics without demoting health. */ export function rconHealthError(status: Pick): string | null { const reason = status.last_error?.split(':')[0]; return status.rcon_failures === 1 && (reason === 'timeout' || reason === 'send-error') ? null : status.last_error; } export function freshTelemetry(report: GameTelemetry | undefined, now: number, after?: string | null): report is GameTelemetry { if (!report || !freshStatusTimestamp(report.receivedAt, now)) return false; if (after && (!Number.isFinite(Date.parse(after)) || Date.parse(report.receivedAt) <= Date.parse(after))) return false; try { parseGameEvent(report); return true; } catch { return false; } } function playerMatchGuid(guid: string, game: string | null | undefined, source: 'rcon' | 'telemetry'): string { if (game !== 't6') return guid; // T6 status prints hexadecimal account IDs; telemetry sends decimal IDs. // Keep integer precision for IDs beyond Number.MAX_SAFE_INTEGER. if (source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(guid)) return BigInt(`0x${guid.replace(/^0x/i, '')}`).toString(); if (source === 'telemetry' && /^\d+$/.test(guid)) return BigInt(guid).toString(); return guid; } /** Display policy: use one current roster/count source, keeping raw RCON for diagnostics/fallback. */ export function selectServerObservation(server: T, report: GameTelemetry | undefined, now: number): T { if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry; const raw = server.rcon_status ?? server; const result: T = { ...server, ...raw, rcon_status: raw, last_error: rconHealthError(raw), telemetry: report, status_source: raw.last_seen_at ? 'rcon' : null }; const containerUnknown = server.container_state === 'unknown'; if (['provisioning','starting'].includes(server.health) || (server.container_state && server.container_state !== 'running' && !containerUnknown) || (!server.container_state && server.health === 'offline')) { return { ...result, health: server.health, last_error: null, last_players: null, roster: null, status_source: null }; } const checked = server.container_checked_at ?? server.last_checked_at ?? raw.last_seen_at; const containerFresh = server.container_state === 'running' && freshStatusTimestamp(checked ?? null, now); const after = !server.telemetry_after || Date.parse(raw.last_seen_at ?? '') > Date.parse(server.telemetry_after); const fresh = after && freshStatusTimestamp(raw.last_seen_at, now) && !result.last_error; if (containerFresh && report?.server === server.name && freshTelemetry(report, now, server.telemetry_after)) { const players = report.players.length; // Telemetry owns roster membership. Supplement missing pings only with // unambiguous RCON identities from the current map and server lifecycle. const pings = new Map(); const guidKey = (guid: string, source: 'rcon' | 'telemetry') => playerMatchGuid(guid, server.game, source); if (fresh && raw.last_map === report.map) { for (const player of raw.roster ?? []) { const ping = player.ping; if (player.guid) { const guid = guidKey(player.guid, 'rcon'); pings.set(guid, pings.has(guid) || ping === null || !Number.isSafeInteger(ping) || ping < 0 ? null : ping); } } } // New reports carry persisted anchors through both SSE and HTTP. Keep // the fallback for telemetry saved before server timing was included. const timing = report.timing?.server ?? { activeSince: server.active_since ?? report.receivedAt, inactiveSince: server.inactive_since ?? report.receivedAt, gameStartedAt: report.event === 'game_start' ? report.receivedAt : server.game_started_at, gameEndedAt: report.event === 'game_start' ? null : report.event === 'game_end' ? report.receivedAt : server.game_ended_at }; return { ...result, health: 'online', status_source: 'telemetry', last_players: players, last_map: report.map, last_seen_at: report.receivedAt, last_error: null, roster: report.players.map(player => ({ num: null, score: 'score' in player ? player.score : null, ping: player.ping ?? pings.get(guidKey(player.guid, 'telemetry')) ?? null, guid: player.guid, name: player.name })), active_since: players > 0 ? timing.activeSince : null, inactive_since: players === 0 ? timing.inactiveSince : null, game_started_at: players === 0 ? null : timing.gameStartedAt, game_ended_at: players === 0 ? null : timing.gameEndedAt }; } // A current authenticated RCON reply can establish game liveness when // inspection is unavailable, without claiming the container was inspected. const containerExpired = !containerUnknown && !!server.container_checked_at && !containerFresh; const age = now - Date.parse(raw.last_seen_at ?? ''); const health = fresh && !containerExpired ? 'online' : after && age >= 0 && age <= 120_000 ? 'stale' : 'offline'; return { ...result, health, last_error: health === 'online' ? result.last_error : raw.last_error }; }