import { db } from '../db/index.js'; import { enrichGameChatTimeline, type ChatMatch, type ChatRoundReport, type ContextualChatEntry } from '../../game-chat.js'; import { parseGameEvent } from '../../telemetry.js'; import { TELEMETRY_REQUEST_RETENTION } from '../../telemetry-diagnostics.js'; import type { ServerRow } from './servers.js'; import type { TailResult } from './logs.js'; /** Historical context is scoped to this registration, including round-one matches. */ export function serverChatTimeline(server: ServerRow, tail: TailResult): ContextualChatEntry[] { const handle = db(); const matches = handle.prepare(`SELECT started_at AS startedAt, ended_at AS endedAt, last_received_at AS lastReceivedAt, status, start_observed AS startObserved FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 2000`).all(server.name) as Array & { startObserved: number }>; // Diagnostic history survives deletion. Its receipt time must belong to the // current registration so a reused server name cannot inherit old rounds. const cutoff = new Date(Date.now() - TELEMETRY_REQUEST_RETENTION.maxAgeDays * 86_400_000).toISOString(); const reports = handle.prepare(`SELECT received_at AS receivedAt, event, round, player_count AS playerCount FROM telemetry_requests WHERE server = ? AND status = 200 AND received_at >= ? AND julianday(received_at) >= julianday(?) AND event IN ('game_start', 'round_change', 'heartbeat', 'game_end') AND player_count >= 0 AND (event = 'game_start' OR round >= 0) ORDER BY id DESC LIMIT ?`).all(server.name, cutoff, server.created_at, TELEMETRY_REQUEST_RETENTION.maxPerServer) as ChatRoundReport[]; // Saving gameplay does not depend on diagnostic logging succeeding. Keep the // surviving accepted snapshot useful when its request log was not recorded. const latest = handle.prepare(`SELECT received_at AS receivedAt, payload FROM server_telemetry WHERE server = ? AND received_at >= ? AND julianday(received_at) >= julianday(?)`) .get(server.name, cutoff, server.created_at) as { receivedAt: string; payload: string | null } | undefined; if (latest?.payload) { try { const event = parseGameEvent(JSON.parse(latest.payload)); const round = event.event === 'game_start' ? null : event.round; if (event.server === server.name && !reports.some(report => report.receivedAt === latest.receivedAt && report.event === event.event && report.round === round && report.playerCount === event.players.length)) { reports.push({ receivedAt: latest.receivedAt, event: event.event, round, playerCount: event.players.length }); } } catch { /* Legacy malformed snapshots cannot establish chat context. */ } } return enrichGameChatTimeline(tail.lines, { mtime: tail.mtime, matches: matches.map(match => ({ ...match, startObserved: Boolean(match.startObserved) })), reports }); }