import { db } from '../db/index.js'; import type { GameTelemetry, ServerTiming } from '../../telemetry.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { recordLeaderboardTelemetry } from './leaderboard-ingest.js'; import { latestGameSubmission } from './status-evidence.js'; export function listTelemetry(): GameTelemetry[] { const rows = db() .prepare( `SELECT t.payload FROM server_telemetry t JOIN servers s ON s.name = t.server WHERE s.destroyed_at IS NULL AND t.payload IS NOT NULL` ) .all() as Array<{ payload: string }>; return rows.map((row) => JSON.parse(row.payload) as GameTelemetry); } export function saveTelemetry(report: GameTelemetry): GameTelemetry { return saveTelemetryWithOutcome(report).telemetry; } /** Acknowledgements retain their match id even when the live snapshot must stay put. */ export function saveTelemetryWithOutcome(report: GameTelemetry): { telemetry: GameTelemetry; current: boolean; } { // Keep callers outside the HTTP route atomic too. const handle = db(); return handle.transaction(() => { const row = handle .prepare('SELECT payload FROM server_telemetry WHERE server = ?') .get(report.server) as { payload: string | null } | undefined; const previous: GameTelemetry | undefined = row?.payload ? JSON.parse(row.payload) : undefined; const recorded = recordLeaderboardTelemetry(handle, report); if (!recorded.current) { return { telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } }, current: false }; } const match = handle .prepare('SELECT id, started_at, start_observed FROM leaderboard_matches WHERE id = ?') .get(recorded.matchId) as { id: number; started_at: string; start_observed: number }; const sameMatch = previous?.timing?.matchId === match.id; const serverTiming = handle .prepare( `SELECT active_since AS activeSince, inactive_since AS inactiveSince, game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?` ) .get(report.server) as ServerTiming; // Record each occupancy transition at receipt, before a heartbeat can // replace game_start or an empty report between status polls. const after = latestGameSubmission(report.server); if (!after || Date.parse(report.receivedAt) > Date.parse(after)) { const occupied = report.players.length > 0; serverTiming.activeSince = occupied ? (serverTiming.activeSince ?? report.receivedAt) : null; serverTiming.inactiveSince = occupied ? null : (serverTiming.inactiveSince ?? report.receivedAt); // Match boundaries follow history, independently of roster occupancy. // Recover starts discarded by older receivers only when a start was // actually observed; an inferred match has no known start time. const startedAt = report.event === 'game_start' ? report.receivedAt : ((sameMatch ? serverTiming.gameStartedAt : null) ?? (match.start_observed ? match.started_at : null)); serverTiming.gameStartedAt = startedAt && (!after || Date.parse(startedAt) > Date.parse(after)) ? startedAt : null; serverTiming.gameEndedAt = report.event === 'game_end' ? report.receivedAt : null; handle .prepare( `UPDATE servers SET active_since = ?, inactive_since = ?, game_started_at = ?, game_ended_at = ? WHERE name = ?` ) .run( serverTiming.activeSince, serverTiming.inactiveSince, serverTiming.gameStartedAt, serverTiming.gameEndedAt, report.server ); } // A gap beyond the normal heartbeat window cannot establish continuity. const continuous = previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000; report = { ...report, timing: { matchId: match.id, server: serverTiming, players: Object.fromEntries( report.players.map((player) => { const prior = continuous ? previous?.timing?.players[player.guid] : undefined; return [ player.guid, { serverJoinedAt: prior?.serverJoinedAt ?? report.receivedAt, matchJoinedAt: sameMatch && prior ? prior.matchJoinedAt : report.receivedAt, roundStarted: sameMatch && prior ? prior.roundStarted : report.event === 'game_start' ? 1 : report.round } ]; }) ) } }; handle .prepare( `INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload` ) .run( report.server, report.event === 'game_start' ? 0 : report.round, report.players.length, report.receivedAt, JSON.stringify(report) ); if (report.event === 'game_end') { // Legacy betas also take match completion directly from the script. if (report.round >= MIN_RECORDED_ROUND) handle .prepare( `UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND server_deleted_at IS NULL AND stage = 'awaiting-match' AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')` ) .run(report.receivedAt, report.server, report.receivedAt); } return { telemetry: report, current: true }; })(); }