import { query } from '@/lib/db'; import { parseDbDate } from '@/lib/dates'; // --- Why this exists --------------------------------------------------- // // The original implementation of this leaderboard did everything in SQL: // for every map_history row it ran correlated subqueries to (a) find the // next map's start time (a MIN(...) scan over the whole table) and (b) // count DISTINCT steamids with a session overlapping a specific instant (a // scan over the whole sessions table) — twice per row (once for the // occurrence's start, once for its end). That's roughly // O(maps_rows * (maps_rows + sessions_rows)) of work done by MySQL on every // request, which is what made /maps/impact extremely slow once both tables // grew to real production size. // // This version fetches the two tables' raw columns once (cheap — no joins, // no correlated subqueries) and does the equivalent computation in Node // with a sweep-line: sort all session start/end instants once, then // answering "how many sessions were active at instant t" is a binary // search instead of a full table scan. That turns the whole computation // into roughly O((maps + sessions) * log(sessions)), done once, and it's // then cached (see getMapImpactLeaderboard below) so most requests don't // even pay that. interface RawMapRow { map_id: number; map_name: string; map_start_dt: string; } interface RawSessionRow { session_start_dt: string; session_end_dt: string | null; } export interface MapImpactSummary { map_name: string; times_played: number; total_minutes: number; last_played: string; avg_net_change: number | null; avg_retention_pct: number | null; best_session_change: number | null; worst_session_change: number | null; } // Binary search: count of values in a sorted ascending array that are <= target. function countLessOrEqual(sorted: number[], target: number): number { let lo = 0; let hi = sorted.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (sorted[mid] <= target) lo = mid + 1; else hi = mid; } return lo; } async function computeMapImpactStats(): Promise { const [mapRows, sessionRows] = await Promise.all([ query( `SELECT map_id, map_name, map_start_dt FROM playtime_display_map_history ORDER BY map_start_dt ASC`, ), query( `SELECT session_start_dt, session_end_dt FROM playtime_display_sessions`, ), ]); // Sweep-line population lookup: a session is "active at instant t" iff // session_start_dt <= t AND (session_end_dt IS NULL OR session_end_dt > t) // — same semantics the old SQL used. count(starts <= t) counts every // session that had begun by t; subtracting count(ends <= t) removes // exactly those that have also already ended by t, leaving the active // count. Sessions with a NULL end never appear in `ends`, so they count // as active forever once started — matching "IS NULL" in the original. const starts: number[] = []; const ends: number[] = []; for (const s of sessionRows) { starts.push(parseDbDate(s.session_start_dt).getTime()); if (s.session_end_dt) ends.push(parseDbDate(s.session_end_dt).getTime()); } starts.sort((a, b) => a - b); ends.sort((a, b) => a - b); function populationAt(t: number): number { return countLessOrEqual(starts, t) - countLessOrEqual(ends, t); } // Tickrate changes sometimes require a very quick map restart, producing // a second map_history row for the same map seconds later — treat // anything under 2 minutes as that artifact rather than a real play // (same threshold/semantics as REAL_MAP_PERIOD elsewhere in lib/queries.ts). const MIN_REAL_PERIOD_MS = 2 * 60_000; const perMap = new Map< string, { times_played: number; total_minutes: number; last_played: number; netChangeSum: number; netChangeCount: number; retentionSum: number; retentionCount: number; best: number; worst: number; } >(); for (let i = 0; i < mapRows.length; i++) { const m = mapRows[i]; const next = mapRows[i + 1]; // next row chronologically, unfiltered — matches NEXT_MAP_START's definition if (!next) continue; // currently-running map: no known end yet, excluded from impact calc const startMs = parseDbDate(m.map_start_dt).getTime(); const nextStartMs = parseDbDate(next.map_start_dt).getTime(); const durationMs = nextStartMs - startMs; if (durationMs < MIN_REAL_PERIOD_MS) continue; // tickrate-restart artifact, not a real occurrence const startPlayers = populationAt(startMs); const endPlayers = populationAt(nextStartMs); const netChange = endPlayers - startPlayers; const retentionPct = startPlayers > 0 ? (endPlayers / startPlayers) * 100 : null; let agg = perMap.get(m.map_name); if (!agg) { agg = { times_played: 0, total_minutes: 0, last_played: 0, netChangeSum: 0, netChangeCount: 0, retentionSum: 0, retentionCount: 0, best: -Infinity, worst: Infinity, }; perMap.set(m.map_name, agg); } agg.times_played += 1; agg.total_minutes += durationMs / 60000; if (startMs > agg.last_played) agg.last_played = startMs; agg.netChangeSum += netChange; agg.netChangeCount += 1; if (retentionPct != null) { agg.retentionSum += retentionPct; agg.retentionCount += 1; } if (netChange > agg.best) agg.best = netChange; if (netChange < agg.worst) agg.worst = netChange; } const result: MapImpactSummary[] = []; for (const [map_name, agg] of perMap) { result.push({ map_name, times_played: agg.times_played, total_minutes: Math.round(agg.total_minutes), last_played: new Date(agg.last_played).toISOString(), avg_net_change: agg.netChangeCount > 0 ? agg.netChangeSum / agg.netChangeCount : null, avg_retention_pct: agg.retentionCount > 0 ? agg.retentionSum / agg.retentionCount : null, best_session_change: agg.netChangeCount > 0 ? agg.best : null, worst_session_change: agg.netChangeCount > 0 ? agg.worst : null, }); } return result; } // --- Cache --------------------------------------------------------------- // // Recomputing is a couple of DB round trips plus an O(n log n) sweep — fast // compared to the old query, but there's no reason to redo it on every // single page view. Kept alive for 30 minutes, with stale-while-revalidate: // a stale cache is served immediately (never emptied) while a background // refresh runs, and only swapped in once the new data is actually ready. // A single in-flight recompute is shared by any requests that arrive while // it's running, rather than each kicking off its own. const CACHE_TTL_MS = 30 * 60 * 1000; let cachedData: MapImpactSummary[] | null = null; let cachedAt = 0; let pending: Promise | null = null; function refresh(): Promise { if (!pending) { pending = computeMapImpactStats() .then((data) => { cachedData = data; cachedAt = Date.now(); return data; }) .finally(() => { pending = null; }); } return pending; } async function getMapImpactStatsCached(): Promise { const isStale = !cachedData || Date.now() - cachedAt > CACHE_TTL_MS; if (!cachedData) { // Nothing to serve yet (first request since process start) — must wait. return refresh(); } if (isStale) { // Serve what we have right now; let the refresh happen in the // background so this (and any concurrent) request doesn't have to // wait on it. The cache is never cleared before the new data exists. refresh(); } return cachedData; } /** * Ranks maps by how they tend to affect server population, rather than * just how often they're played. For every completed occurrence of a map * (excluding the one currently running, since its "end" isn't known yet), * net_change = population-at-map-end minus population-at-map-start, and * retention_pct = end/start as a percentage (null when the map started * with 0 players). Those per-occurrence numbers are averaged (and * min/max'd) per map name — see computeMapImpactStats above. * * `sortBy` picks which story the leaderboard tells: 'impact' (average net * change — the maps that tend to grow or shrink the population) or * 'popularity' (times played — pure frequency, unrelated to impact). The * underlying per-map stats are cached (see above); this just sorts/slices * them per request, which is cheap even for hundreds of maps. */ export async function getMapImpactLeaderboard( sortBy: 'impact' | 'popularity', direction: 'asc' | 'desc', limit = 50, offset = 0, ): Promise { const all = await getMapImpactStatsCached(); const sign = direction === 'asc' ? 1 : -1; const sorted = [...all].sort((a, b) => { const av = sortBy === 'popularity' ? a.times_played : a.avg_net_change ?? 0; const bv = sortBy === 'popularity' ? b.times_played : b.avg_net_change ?? 0; if (av !== bv) return sign * (av - bv); return b.times_played - a.times_played; // stable tiebreaker, matches old ORDER BY ..., times_played DESC }); return sorted.slice(offset, offset + limit); }