249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
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<MapImpactSummary[]> {
|
|
const [mapRows, sessionRows] = await Promise.all([
|
|
query<RawMapRow>(
|
|
`SELECT map_id, map_name, map_start_dt
|
|
FROM playtime_display_map_history
|
|
ORDER BY map_start_dt ASC`,
|
|
),
|
|
query<RawSessionRow>(
|
|
`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<MapImpactSummary[]> | null = null;
|
|
|
|
function refresh(): Promise<MapImpactSummary[]> {
|
|
if (!pending) {
|
|
pending = computeMapImpactStats()
|
|
.then((data) => {
|
|
cachedData = data;
|
|
cachedAt = Date.now();
|
|
return data;
|
|
})
|
|
.finally(() => {
|
|
pending = null;
|
|
});
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
async function getMapImpactStatsCached(): Promise<MapImpactSummary[]> {
|
|
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<MapImpactSummary[]> {
|
|
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);
|
|
}
|