import { db } from '../db/index.js'; import { matchChatTimeline } from './chat.js'; import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type MatchHistoryResponse, type ServerMatch } from '../../match-history.js'; import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js'; const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status, CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt, m.start_players AS startPlayers, (SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers, CASE WHEN m.status = 'completed' THEN (SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id AND p.present = 1) END AS endPlayers, CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs, CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`; export function serverMatchHistory( server?: string, limit = 25, query: MatchHistoryQuery = readMatchQuery(new URLSearchParams()), now = Date.now() ): MatchHistoryResponse { const handle = db(); const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`]; const parameters: Array = []; if (server !== undefined) { clauses.push('m.server = ?'); parameters.push(server); } if (query.game) { clauses.push('m.game = ?'); parameters.push(query.game); } const maps = handle .prepare( `SELECT m.map_key AS key, MAX(m.map_name) AS name, m.game FROM leaderboard_matches m WHERE ${clauses.join(' AND ')} GROUP BY m.map_key, m.game ORDER BY name COLLATE NOCASE, game` ) .all(...parameters) as MatchHistoryResponse['maps']; if (query.map) { clauses.push('m.map_key = ?'); parameters.push(query.map); } if (query.status) { clauses.push('m.status = ?'); parameters.push(query.status); } if (query.days) { clauses.push('julianday(m.started_at) >= julianday(?)'); parameters.push(new Date(now - query.days * 86400000).toISOString()); } // Search readable instance names as well as their stored identifiers. Tokens allow "Town Lobby 1". for (const token of query.q .toLowerCase() .replace(/\blobby\s+(?=\d+\b)/g, '') .split(/[\s_]+/) .filter(Boolean)) { clauses.push("instr(lower(m.server || ' ' || m.map_name), ?) > 0"); parameters.push(token); } const where = `WHERE ${clauses.join(' AND ')}`; const total = ( handle .prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`) .get(...parameters) as { total: number } ).total; const page = Math.min(query.page, Math.max(1, Math.ceil(total / limit))); const column = { started: 'startedAt', round: 'observedRound', duration: 'durationMs', players: 'uniquePlayers' }[query.sort]; const direction = query.direction === 'asc' ? 'ASC' : 'DESC'; const matches = handle .prepare( `SELECT ${matchSummary} FROM leaderboard_matches m ${where} ORDER BY ${column} IS NULL ASC, ${column} ${direction}, m.id DESC LIMIT ? OFFSET ?` ) .all(...parameters, limit, (page - 1) * limit) as ServerMatch[]; return { matches, total, page, limit, maps }; } /** Read durable match records without depending on a current server or scoreboard. */ export function getMatchDetail(id: number): MatchDetail | null { if (!Number.isSafeInteger(id) || id < 1) return null; const handle = db(); const match = handle .prepare( `SELECT ${matchSummary}, m.start_observed AS startObserved FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed' AND m.highest_round >= ${MIN_RECORDED_ROUND}` ) .get(id) as | (Omit & { startObserved: number }) | undefined; if (!match) return null; const players = handle .prepare( `SELECT p.guid, COALESCE((SELECT identity.name FROM leaderboard_players identity WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6') ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1), (SELECT identity.name FROM leaderboard_players identity WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name, p.has_stats AS hasStats, p.present AS presentAtEnd, p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt, CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound, CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived, CASE WHEN p.has_stats = 1 THEN p.kills END AS kills, CASE WHEN p.has_stats = 1 THEN p.headshots END AS headshots, CASE WHEN p.has_stats = 1 THEN p.downs END AS downs, CASE WHEN p.has_stats = 1 THEN p.revives END AS revives, CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore, EXISTS (SELECT 1 FROM leaderboard_players identity WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile FROM leaderboard_match_players p WHERE p.match_id = ? ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` ) .all(match.game, id) as Array< Omit & { hasStats: number; presentAtEnd: number; canViewProfile: number; } >; return { ...match, chat: matchChatTimeline(id), startObserved: Boolean(match.startObserved), players: players.map((player) => ({ ...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd), canViewProfile: Boolean(player.canViewProfile) })) }; }