import { query } from '@/lib/db'; // map_end_dt was dropped from the schema — it was redundant with (and // occasionally got out of sync with) the next row's map_start_dt, since a // map's end is definitionally when the next one begins. This subquery // derives the same information: NULL for the currently-running map (no // later row exists yet), otherwise the next map's start time. const NEXT_MAP_START = (alias: string) => `(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > ${alias}.map_start_dt)`; // Tickrate changes sometimes require a very quick map restart, which // produces a second map_history row for the same map seconds later. Treat // anything under 2 minutes as such an artifact rather than a real play — // applied everywhere a map_history row is being counted/listed as an // actual occurrence. The currently-running map (no next row yet) is always // included since we can't know its eventual duration yet. const REAL_MAP_PERIOD = `( ${NEXT_MAP_START('m')} IS NULL OR TIMESTAMPDIFF(MINUTE, m.map_start_dt, ${NEXT_MAP_START('m')}) >= 2 )`; export interface PlayerRecord { steamid: string; current_name: string; current_country: string | null; player_tier: number; first_seen: string; last_seen: string; } export interface NameHistoryEntry { player_name: string; first_seen: string; last_seen: string; } export interface SessionEntry { session_id: number; session_start_dt: string; session_end_dt: string | null; session_active_minutes: number | null; maps_played: string | null; // comma-separated map names overlapping this session } export async function getPlayer(steamid: string): Promise { const rows = await query( `SELECT steamid, current_name, current_country, player_tier, first_seen, last_seen FROM playtime_display_players WHERE steamid = ?`, [steamid], ); return rows[0] ?? null; } export async function getNameHistory(steamid: string): Promise { return query( `SELECT player_name, first_seen, last_seen FROM playtime_display_name_history WHERE steamid = ? ORDER BY last_seen DESC`, [steamid], ); } export async function getSessions(steamid: string, limit = 100): Promise { // The maps_played subquery finds every map_history period that overlaps // this session's [start, end] window (same overlap logic used elsewhere // in the app), and concatenates the names. A session can span more than // one map if the player stayed connected through a map change. return query( `SELECT s.session_id, s.session_start_dt, s.session_end_dt, s.session_active_minutes, ( SELECT GROUP_CONCAT(DISTINCT m.map_name ORDER BY m.map_start_dt SEPARATOR ', ') FROM playtime_display_map_history m WHERE m.map_start_dt < COALESCE(s.session_end_dt, NOW()) AND COALESCE(${NEXT_MAP_START('m')}, NOW()) > s.session_start_dt AND ${REAL_MAP_PERIOD} ) AS maps_played FROM playtime_display_sessions s WHERE s.steamid = ? ORDER BY s.session_start_dt DESC LIMIT ?`, [steamid, limit], ); } export interface MapPopulationPoint { map_id: number; map_name: string; map_start_dt: string; map_end_dt: string | null; total_players: number; } export async function getMapPopulationSeries(limit = 40, before?: string): Promise { // Total DISTINCT players who were connected at any point during each map's // full session (not just at its start) — one data point per map. Fetched // most-recent-first (for the LIMIT), then the caller should reverse it // back to chronological order for display. // `before` (a DB-formatted datetime string) lets the caller page further // back into history than the initial batch, for pan-to-load-more charts. const rows = await query( `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW()) AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) ) AS total_players FROM playtime_display_map_history m WHERE ${REAL_MAP_PERIOD} ${before ? 'AND m.map_start_dt < ?' : ''} ORDER BY m.map_start_dt DESC LIMIT ?`, before ? [before, limit] : [limit], ); return rows.reverse(); } export interface CountrySummary { current_country: string | null; // null represents the "Unknown" bucket player_count: number; total_minutes: number; avg_minutes: number; } export async function getCountriesSummary(): Promise { // "Unknown" covers both NULL and '' — the actual sentinel written by the // plugin when GeoipCode2 can't resolve a country (private/local IP, GeoIP // miss, etc.) is an empty string, not SQL NULL, since the upsert always // writes some value rather than leaving the column untouched. NULL is // also handled in case any row ever ends up there some other way (e.g. // the column's own DEFAULT). const UNKNOWN = `(p.current_country IS NULL OR p.current_country = '')`; const KNOWN = `(p.current_country IS NOT NULL AND p.current_country != '')`; const rows = await query( `SELECT * FROM ( SELECT p.current_country, COUNT(*) AS player_count, SUM(pt.total_minutes) AS total_minutes, AVG(pt.total_minutes) AS avg_minutes FROM playtime_display_players p JOIN ( SELECT p2.steamid, (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) FROM playtime_display_sessions s WHERE s.steamid = p2.steamid ORDER BY s.session_id DESC LIMIT 1) AS total_minutes FROM playtime_display_players p2 ) pt ON pt.steamid = p.steamid WHERE ${KNOWN} GROUP BY p.current_country UNION ALL SELECT NULL AS current_country, COUNT(*) AS player_count, SUM(pt.total_minutes) AS total_minutes, AVG(pt.total_minutes) AS avg_minutes FROM playtime_display_players p JOIN ( SELECT p2.steamid, (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) FROM playtime_display_sessions s WHERE s.steamid = p2.steamid ORDER BY s.session_id DESC LIMIT 1) AS total_minutes FROM playtime_display_players p2 ) pt ON pt.steamid = p.steamid WHERE ${UNKNOWN} ) t ORDER BY player_count DESC, current_country IS NULL, current_country ASC`, ); // Skip the Unknown bucket entirely if it's empty, rather than showing a // dead "0 players" row. return rows.filter((r) => r.current_country !== null || r.player_count > 0); } export interface CountryPlayer { steamid: string; current_name: string; last_seen: string; total_minutes: number | null; } export async function getPlayersByCountry( countryCode: string | null, sort: 'playtime' | 'recent', ): Promise { const orderBy = sort === 'playtime' ? 'total_minutes DESC' : 'p.last_seen DESC'; return query( `SELECT p.steamid, p.current_name, p.last_seen, (SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes) FROM playtime_display_sessions s WHERE s.steamid = p.steamid ORDER BY s.session_id DESC LIMIT 1 ) AS total_minutes FROM playtime_display_players p WHERE ${countryCode === null ? "(p.current_country IS NULL OR p.current_country = '')" : 'p.current_country = ?'} ORDER BY ${orderBy} LIMIT 200`, countryCode === null ? [] : [countryCode], ); } export interface MapSummary { map_name: string; times_played: number; last_played: string; total_minutes: number; } export interface MapPeriod { map_id: number; map_name: string; map_start_dt: string; map_end_dt: string | null; player_count: number; } export interface MapPresentPlayer { steamid: string; current_name: string; current_country: string | null; session_start_dt: string; session_end_dt: string | null; } export interface MapVoteRow { vote_id: number; vote_time: string; result: string | null; steamid: string; current_name: string; current_country: string | null; vote_choice: string; vote_weight: number; } export async function searchMapNames(q: string, limit = 50, offset = 0): Promise { const like = `%${q}%`; return query( `SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played, SUM(TIMESTAMPDIFF(MINUTE, m.map_start_dt, COALESCE(${NEXT_MAP_START('m')}, NOW()))) AS total_minutes FROM playtime_display_map_history m WHERE m.map_name LIKE ? AND ${REAL_MAP_PERIOD} GROUP BY m.map_name ORDER BY last_played DESC LIMIT ? OFFSET ?`, [like, limit, offset], ); } export async function getMapPeriods(mapName: string): Promise { return query( `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW()) AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) ) AS player_count FROM playtime_display_map_history m WHERE m.map_name = ? AND ${REAL_MAP_PERIOD} ORDER BY m.map_start_dt DESC`, [mapName], ); } export async function getMapPeriod(mapId: number): Promise { const rows = await query( `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt, (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW()) AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) ) AS player_count FROM playtime_display_map_history m WHERE m.map_id = ?`, [mapId], ); return rows[0] ?? null; } export async function getPlayersPresentDuringMap(mapId: number): Promise { return query( `SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, p.current_country, s.session_start_dt, s.session_end_dt FROM playtime_display_sessions s LEFT JOIN playtime_display_players p ON p.steamid = s.steamid JOIN playtime_display_map_history m ON m.map_id = ? WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW()) AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) ORDER BY s.session_start_dt`, [mapId], ); } export async function getMapVotes(mapId: number): Promise { return query( `SELECT e.vote_id, e.vote_time, e.result, c.steamid, COALESCE(p.current_name, c.steamid) AS current_name, p.current_country, c.vote_choice, c.vote_weight FROM playtime_display_map_vote_events e JOIN playtime_display_map_vote_choices c ON c.vote_id = e.vote_id LEFT JOIN playtime_display_players p ON p.steamid = c.steamid WHERE e.map_id = ? ORDER BY e.vote_time, c.vote_weight DESC`, [mapId], ); } export async function getAdjacentMaps( mapStartDt: string, ): Promise<{ prev: MapPeriod | null; next: MapPeriod | null }> { const [prevRows, nextRows] = await Promise.all([ query( `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt FROM playtime_display_map_history m WHERE m.map_start_dt < ? AND ${REAL_MAP_PERIOD} ORDER BY m.map_start_dt DESC LIMIT 1`, [mapStartDt], ), query( `SELECT m.map_id, m.map_name, m.map_start_dt, ${NEXT_MAP_START('m')} AS map_end_dt FROM playtime_display_map_history m WHERE m.map_start_dt > ? AND ${REAL_MAP_PERIOD} ORDER BY m.map_start_dt ASC LIMIT 1`, [mapStartDt], ), ]); return { prev: prevRows[0] ?? null, next: nextRows[0] ?? null }; } export interface CurrentPlayer { steamid: string; current_name: string; current_country: string | null; } /** * "Currently online" defined explicitly: a session whose last heartbeat * (session_end_dt) was within the last N minutes. This runs NOW() inside * MySQL itself rather than passing a JS-computed timestamp, so it's immune * to any Node/MySQL timezone mismatch — same check the admin already * validated by hand (27 vs a real count of 29). * * This is deliberately separate from the historical bucket-overlap queries * used elsewhere (population-over-time chart, getPlayersAtTime): those have * no freshness concept at all, which is correct for a genuinely historical * point in time, but wrong for "right now" — a session whose heartbeat * stopped an hour ago (player disconnected without a clean close) would * still satisfy a historical overlap check forever, silently inflating * "currently online" counts. */ export async function getCurrentPlayers(freshnessMinutes = 3): Promise { // Capped at 64 — the game's hard player limit. In principle this query // should never find more than that many truly-simultaneous players, but // rapid reconnects within the freshness window could in theory produce // more distinct steamids than were ever actually online at once. Ordering // by most-recent heartbeat and capping keeps the freshest 64, discarding // anything older if that edge case ever occurs. // // LEFT JOIN (not INNER) + COALESCE fallback to the session's own stored // player_name: an INNER JOIN here was silently dropping any session whose // matching players row wasn't found, undercounting "currently online" // for reasons unrelated to whether the player is actually connected. return query( `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name, MAX(p.current_country) AS current_country FROM playtime_display_sessions s LEFT JOIN playtime_display_players p ON p.steamid = s.steamid WHERE s.session_end_dt > NOW() - INTERVAL ? MINUTE GROUP BY s.steamid ORDER BY MAX(s.session_end_dt) DESC LIMIT 64`, [freshnessMinutes], ); } export interface PlayerAtTime { steamid: string; current_name: string; current_country: string | null; } export async function getPlayersAtTime(atIso: string): Promise { return query( `SELECT s.steamid, COALESCE(MAX(p.current_name), MAX(s.player_name)) AS current_name, MAX(p.current_country) AS current_country FROM playtime_display_sessions s LEFT JOIN playtime_display_players p ON p.steamid = s.steamid WHERE s.session_start_dt <= ? AND (s.session_end_dt IS NULL OR s.session_end_dt >= ?) GROUP BY s.steamid`, [atIso, atIso], ); } /** * A player's current total playtime is just the counter snapshot on their * most recent session row — session_end_playtime_minutes if that session * has had at least one heartbeat/close, otherwise session_start_playtime_minutes * (covers the brief window right after connect, before the first update). * No need to touch `player_time` at all — this value already reflects it. */ export async function getTotalPlaytimeMinutes(steamid: string): Promise { const rows = await query<{ minutes: number | null }>( `SELECT COALESCE(session_end_playtime_minutes, session_start_playtime_minutes) AS minutes FROM playtime_display_sessions WHERE steamid = ? ORDER BY session_id DESC LIMIT 1`, [steamid], ); return rows[0]?.minutes ?? null; }