diff --git a/hasura/functions/leaderboard/get_leaderboard.sql b/hasura/functions/leaderboard/get_leaderboard.sql new file mode 100644 index 00000000..c8222b57 --- /dev/null +++ b/hasura/functions/leaderboard/get_leaderboard.sql @@ -0,0 +1,334 @@ +CREATE OR REPLACE FUNCTION public.get_leaderboard( + _category TEXT, + _window_days INT, + _match_type TEXT DEFAULT NULL, + _exclude_tournaments BOOLEAN DEFAULT FALSE +) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + IF _category = 'elo' THEN + RETURN QUERY SELECT * FROM _leaderboard_elo(_window_days, _match_type, _exclude_tournaments); + + ELSIF _category = 'best_kdr' THEN + RETURN QUERY SELECT * FROM _leaderboard_kdr(_window_days, _match_type, _exclude_tournaments); + + ELSIF _category = 'best_win_rate' THEN + RETURN QUERY SELECT * FROM _leaderboard_win_rate(_window_days, _match_type, _exclude_tournaments); + + ELSIF _category = 'highest_hs_pct' THEN + RETURN QUERY SELECT * FROM _leaderboard_hs_pct(_window_days, _match_type, _exclude_tournaments); + + ELSE + RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct', _category; + END IF; +END; +$$; + +-- ============================================================ +-- ELO leaderboard +-- value = current ELO, secondary = ELO change, tertiary = win streak +-- ============================================================ +CREATE OR REPLACE FUNCTION public._leaderboard_elo( + _window_days INT, + _match_type TEXT, + _exclude_tournaments BOOLEAN +) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + IF _exclude_tournaments THEN + RETURN QUERY + WITH last_elo_raw AS ( + SELECT DISTINCT ON (pe.steam_id) + pe.steam_id, + pe.current as raw_current + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + ORDER BY pe.steam_id, pe.created_at DESC + ), + tournament_adj AS ( + SELECT pe.steam_id, SUM(pe.change) as tourney_total + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + AND EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pe.match_id) + GROUP BY pe.steam_id + ), + first_elo AS ( + SELECT DISTINCT ON (pe.steam_id) + pe.steam_id, + pe.current - pe.change as starting_elo + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + ORDER BY pe.steam_id, pe.created_at ASC + ), + match_counts AS ( + SELECT pe.steam_id, COUNT(*)::int as matches_played + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + AND NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pe.match_id) + GROUP BY pe.steam_id + ), + win_streak AS ( + SELECT sub.steam_id, + COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak + FROM ( + SELECT + mlp.steam_id, + CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won, + ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn + FROM match_lineup_players mlp + JOIN match_lineups ml ON ml.id = mlp.match_lineup_id + JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) + JOIN match_options mo ON mo.id = m.match_options_id + WHERE m.status = 'Finished' + AND mlp.steam_id IS NOT NULL + AND m.winning_lineup_id IS NOT NULL + AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo.type = _match_type) + AND NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = m.id) + ) sub + GROUP BY sub.steam_id + ) + SELECT + le.steam_id::text as player_steam_id, + p.name as player_name, + p.avatar_url as player_avatar_url, + p.country as player_country, + (le.raw_current - COALESCE(ta.tourney_total, 0))::float as value, + ((le.raw_current - COALESCE(ta.tourney_total, 0)) - fe.starting_elo)::float as secondary_value, + COALESCE(ws.streak, 0)::float as tertiary_value, + COALESCE(mc.matches_played, 0)::int as matches_played + FROM last_elo_raw le + LEFT JOIN tournament_adj ta ON ta.steam_id = le.steam_id + JOIN first_elo fe ON fe.steam_id = le.steam_id + LEFT JOIN match_counts mc ON mc.steam_id = le.steam_id + LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id + JOIN players p ON p.steam_id = le.steam_id + ORDER BY value DESC; + + ELSE + RETURN QUERY + WITH last_elo AS ( + SELECT DISTINCT ON (pe.steam_id) + pe.steam_id, + pe.current as current_elo + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + ORDER BY pe.steam_id, pe.created_at DESC + ), + first_elo AS ( + SELECT DISTINCT ON (pe.steam_id) + pe.steam_id, + pe.current - pe.change as starting_elo + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + ORDER BY pe.steam_id, pe.created_at ASC + ), + match_counts AS ( + SELECT pe.steam_id, COUNT(*)::int as matches_played + FROM player_elo pe + WHERE 1=1 + AND (_match_type IS NULL OR pe.type = _match_type) + AND (_window_days = 0 OR pe.created_at >= NOW() - make_interval(days => _window_days)) + GROUP BY pe.steam_id + ), + win_streak AS ( + SELECT sub.steam_id, + COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak + FROM ( + SELECT + mlp.steam_id, + CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won, + ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn + FROM match_lineup_players mlp + JOIN match_lineups ml ON ml.id = mlp.match_lineup_id + JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) + JOIN match_options mo ON mo.id = m.match_options_id + WHERE m.status = 'Finished' + AND mlp.steam_id IS NOT NULL + AND m.winning_lineup_id IS NOT NULL + AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo.type = _match_type) + ) sub + GROUP BY sub.steam_id + ) + SELECT + le.steam_id::text as player_steam_id, + p.name as player_name, + p.avatar_url as player_avatar_url, + p.country as player_country, + le.current_elo::float as value, + (le.current_elo - fe.starting_elo)::float as secondary_value, + COALESCE(ws.streak, 0)::float as tertiary_value, + mc.matches_played::int as matches_played + FROM last_elo le + JOIN first_elo fe ON fe.steam_id = le.steam_id + JOIN match_counts mc ON mc.steam_id = le.steam_id + LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id + JOIN players p ON p.steam_id = le.steam_id + ORDER BY value DESC; + END IF; +END; +$$; + +-- ============================================================ +-- K/D Ratio leaderboard +-- value = K/D ratio, secondary = kills, tertiary = deaths +-- ============================================================ +CREATE OR REPLACE FUNCTION public._leaderboard_kdr( + _window_days INT, + _match_type TEXT, + _exclude_tournaments BOOLEAN +) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + RETURN QUERY + WITH kills AS ( + SELECT + pk.attacker_steam_id as steam_id, + COUNT(*) as kill_count, + COUNT(DISTINCT pk.match_id)::int as match_count + FROM player_kills pk + LEFT JOIN matches m ON (_match_type IS NOT NULL AND m.id = pk.match_id) + LEFT JOIN match_options mo ON (_match_type IS NOT NULL AND mo.id = m.match_options_id) + WHERE pk.attacker_steam_id IS NOT NULL + AND pk.attacker_steam_id != pk.attacked_steam_id + AND (_window_days = 0 OR pk.time >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo.type = _match_type) + AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pk.match_id)) + GROUP BY pk.attacker_steam_id + ), + deaths AS ( + SELECT + dk.attacked_steam_id as steam_id, + COUNT(*) as death_count + FROM player_kills dk + LEFT JOIN matches m2 ON (_match_type IS NOT NULL AND m2.id = dk.match_id) + LEFT JOIN match_options mo2 ON (_match_type IS NOT NULL AND mo2.id = m2.match_options_id) + WHERE 1=1 + AND (_window_days = 0 OR dk.time >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo2.type = _match_type) + AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = dk.match_id)) + GROUP BY dk.attacked_steam_id + ) + SELECT + k.steam_id::text as player_steam_id, + p.name as player_name, + p.avatar_url as player_avatar_url, + p.country as player_country, + CASE WHEN COALESCE(d.death_count, 0) = 0 + THEN k.kill_count::float + ELSE ROUND((k.kill_count::numeric / d.death_count::numeric), 2)::float + END as value, + k.kill_count::float as secondary_value, + COALESCE(d.death_count, 0)::float as tertiary_value, + k.match_count as matches_played + FROM kills k + LEFT JOIN deaths d ON d.steam_id = k.steam_id + JOIN players p ON p.steam_id = k.steam_id + WHERE k.match_count >= 5 + ORDER BY value DESC; +END; +$$; + +-- ============================================================ +-- Win Rate leaderboard +-- value = win%, secondary = wins, tertiary = losses +-- ============================================================ +CREATE OR REPLACE FUNCTION public._leaderboard_win_rate( + _window_days INT, + _match_type TEXT, + _exclude_tournaments BOOLEAN +) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + RETURN QUERY + WITH player_matches AS ( + SELECT + mlp.steam_id, + m.id as match_id, + CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won + FROM match_lineup_players mlp + JOIN match_lineups ml ON ml.id = mlp.match_lineup_id + JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) + JOIN match_options mo ON mo.id = m.match_options_id + WHERE m.status = 'Finished' + AND mlp.steam_id IS NOT NULL + AND m.winning_lineup_id IS NOT NULL + AND (_window_days = 0 OR m.ended_at >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo.type = _match_type) + AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = m.id)) + ) + SELECT + pm.steam_id::text as player_steam_id, + p.name as player_name, + p.avatar_url as player_avatar_url, + p.country as player_country, + ROUND((SUM(pm.won)::numeric / COUNT(*)::numeric) * 100, 2)::float as value, + SUM(pm.won)::float as secondary_value, + (COUNT(*) - SUM(pm.won))::float as tertiary_value, + COUNT(*)::int as matches_played + FROM player_matches pm + JOIN players p ON p.steam_id = pm.steam_id + GROUP BY pm.steam_id, p.name, p.avatar_url, p.country + HAVING COUNT(*) >= 5 + ORDER BY value DESC; +END; +$$; + +-- ============================================================ +-- Headshot % leaderboard +-- value = HS%, secondary = total kills, tertiary = null +-- ============================================================ +CREATE OR REPLACE FUNCTION public._leaderboard_hs_pct( + _window_days INT, + _match_type TEXT, + _exclude_tournaments BOOLEAN +) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + RETURN QUERY + SELECT + pk.attacker_steam_id::text as player_steam_id, + p.name as player_name, + p.avatar_url as player_avatar_url, + p.country as player_country, + ROUND((SUM(CASE WHEN pk.headshot THEN 1 ELSE 0 END)::numeric / COUNT(*)::numeric) * 100, 2)::float as value, + COUNT(*)::float as secondary_value, + NULL::float as tertiary_value, + COUNT(DISTINCT pk.match_id)::int as matches_played + FROM player_kills pk + JOIN players p ON p.steam_id = pk.attacker_steam_id + LEFT JOIN matches m ON (_match_type IS NOT NULL AND m.id = pk.match_id) + LEFT JOIN match_options mo ON (_match_type IS NOT NULL AND mo.id = m.match_options_id) + WHERE pk.attacker_steam_id IS NOT NULL + AND pk.attacker_steam_id != pk.attacked_steam_id + AND (_window_days = 0 OR pk.time >= NOW() - make_interval(days => _window_days)) + AND (_match_type IS NULL OR mo.type = _match_type) + AND (NOT _exclude_tournaments OR NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pk.match_id)) + GROUP BY pk.attacker_steam_id, p.name, p.avatar_url, p.country + HAVING COUNT(*) >= 25 + ORDER BY value DESC; +END; +$$; diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index acdf43e2..d7d56e0b 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -334,36 +334,6 @@ type Mutation { ): SuccessOutput } -type Query { - getLeaderboard( - category: String! - window_days: Int! - match_type: String - limit: Int - offset: Int - exclude_tournaments: Boolean - sort_by: String - sort_dir: String - ): LeaderboardResponse! -} - -type LeaderboardResponse { - entries: [LeaderboardEntry!]! - total: Int! -} - -type LeaderboardEntry { - rank: Int! - player_steam_id: String! - player_name: String! - player_avatar_url: String - player_country: String - value: Float! - secondary_value: Float - tertiary_value: Float - matches_played: Int -} - input SampleInput { username: String! password: String! diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index a77d4b01..e38d7c23 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -443,14 +443,6 @@ actions: permissions: - role: administrator comment: Write content to file on game server - - name: getLeaderboard - definition: - kind: "" - handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' - forward_client_headers: true - permissions: - - role: guest - comment: Get leaderboard rankings by category and time window custom_types: enums: [] input_objects: @@ -504,6 +496,4 @@ custom_types: - name: StorageStats - name: StorageSummary - name: TableSizeInfo - - name: LeaderboardResponse - - name: LeaderboardEntry scalars: [] diff --git a/hasura/metadata/databases/databases.yaml b/hasura/metadata/databases/databases.yaml index 65a11b20..e0f5d8a9 100644 --- a/hasura/metadata/databases/databases.yaml +++ b/hasura/metadata/databases/databases.yaml @@ -12,3 +12,4 @@ retries: 1 use_prepared_statements: true tables: "!include default/tables/tables.yaml" + functions: "!include default/functions/functions.yaml" diff --git a/hasura/metadata/databases/default/functions/functions.yaml b/hasura/metadata/databases/default/functions/functions.yaml new file mode 100644 index 00000000..205883e6 --- /dev/null +++ b/hasura/metadata/databases/default/functions/functions.yaml @@ -0,0 +1 @@ +- "!include public_get_leaderboard.yaml" diff --git a/hasura/metadata/databases/default/functions/public_get_leaderboard.yaml b/hasura/metadata/databases/default/functions/public_get_leaderboard.yaml new file mode 100644 index 00000000..3319f3f2 --- /dev/null +++ b/hasura/metadata/databases/default/functions/public_get_leaderboard.yaml @@ -0,0 +1,8 @@ +function: + name: get_leaderboard + schema: public +configuration: + exposed_as: query + session_argument: null +permissions: + - role: guest diff --git a/hasura/metadata/databases/default/tables/public_leaderboard_entries.yaml b/hasura/metadata/databases/default/tables/public_leaderboard_entries.yaml new file mode 100644 index 00000000..55a4ada2 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_leaderboard_entries.yaml @@ -0,0 +1,18 @@ +table: + name: leaderboard_entries + schema: public +select_permissions: + - role: guest + permission: + columns: + - player_steam_id + - player_name + - player_avatar_url + - player_country + - value + - secondary_value + - tertiary_value + - matches_played + filter: {} + allow_aggregations: true + comment: "" diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 6fce9818..20f5d2bc 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -31,6 +31,7 @@ - "!include public_friends.yaml" - "!include public_game_server_nodes.yaml" - "!include public_game_versions.yaml" +- "!include public_leaderboard_entries.yaml" - "!include public_lobbies.yaml" - "!include public_lobby_players.yaml" - "!include public_map_pools.yaml" diff --git a/hasura/migrations/default/1771545600000_leaderboard_functions/down.sql b/hasura/migrations/default/1771545600000_leaderboard_functions/down.sql new file mode 100644 index 00000000..7e4cd09a --- /dev/null +++ b/hasura/migrations/default/1771545600000_leaderboard_functions/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_matches_ended_at; +DROP TABLE IF EXISTS leaderboard_entries; diff --git a/hasura/migrations/default/1771545600000_leaderboard_functions/up.sql b/hasura/migrations/default/1771545600000_leaderboard_functions/up.sql new file mode 100644 index 00000000..048cc44b --- /dev/null +++ b/hasura/migrations/default/1771545600000_leaderboard_functions/up.sql @@ -0,0 +1,17 @@ +-- Type-definition table for Hasura-tracked leaderboard function. +-- Never written to directly; only used as the SETOF return type. +CREATE TABLE IF NOT EXISTS leaderboard_entries ( + player_steam_id TEXT NOT NULL, + player_name TEXT NOT NULL, + player_avatar_url TEXT, + player_country TEXT, + value FLOAT NOT NULL DEFAULT 0, + secondary_value FLOAT, + tertiary_value FLOAT, + matches_played INT DEFAULT 0 +); + +-- Missing index for time-window queries on matches.ended_at +CREATE INDEX IF NOT EXISTS idx_matches_ended_at + ON matches(ended_at DESC) + WHERE ended_at IS NOT NULL; diff --git a/src/app.module.ts b/src/app.module.ts index 324f7d84..19d5200b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -43,7 +43,6 @@ import { K8sModule } from "./k8s/k8s.module"; import { FileManagerModule } from "./file-manager/file-manager.module"; import { BrandingModule } from "./branding/branding.module"; import { FixturesModule } from "./fixtures/fixtures.module"; -import { LeaderboardModule } from "./leaderboard/leaderboard.module"; @Module({ imports: [ @@ -127,7 +126,6 @@ import { LeaderboardModule } from "./leaderboard/leaderboard.module"; FileManagerModule, BrandingModule, FixturesModule, - LeaderboardModule, ], providers: [loggerFactory()], controllers: [AppController, QuickConnectController], diff --git a/src/leaderboard/leaderboard.controller.ts b/src/leaderboard/leaderboard.controller.ts deleted file mode 100644 index ca4b6519..00000000 --- a/src/leaderboard/leaderboard.controller.ts +++ /dev/null @@ -1,605 +0,0 @@ -import { BadRequestException, Controller } from "@nestjs/common"; -import { HasuraAction } from "src/hasura/hasura.controller"; -import { PostgresService } from "src/postgres/postgres.service"; -import { CacheService } from "src/cache/cache.service"; - -const VALID_CATEGORIES = [ - "elo", - "best_kdr", - "best_win_rate", - "highest_hs_pct", -] as const; - -type LeaderboardCategory = (typeof VALID_CATEGORIES)[number]; - -const VALID_MATCH_TYPES = ["Competitive", "Wingman", "Duel"] as const; - -const VALID_SORT_FIELDS = [ - "value", - "secondary_value", - "tertiary_value", - "matches_played", -] as const; - -const MAX_RESULTS = 500; - -interface LeaderboardArgs { - category: string; - window_days: number; - match_type?: string; - limit?: number; - offset?: number; - exclude_tournaments?: boolean; - sort_by?: string; - sort_dir?: string; -} - -interface LeaderboardEntry { - __typename: "LeaderboardEntry"; - rank: number; - player_steam_id: string; - player_name: string; - player_avatar_url: string | null; - player_country: string | null; - value: number; - secondary_value: number | null; - tertiary_value: number | null; - matches_played: number | null; -} - -interface LeaderboardResponse { - __typename: "LeaderboardResponse"; - entries: LeaderboardEntry[]; - total: number; -} - -@Controller("leaderboard") -export class LeaderboardController { - constructor( - private readonly postgres: PostgresService, - private readonly cache: CacheService, - ) {} - - @HasuraAction() - public async getLeaderboard( - args: LeaderboardArgs, - ): Promise { - const { - category, - window_days, - match_type, - limit: rawLimit, - offset: rawOffset, - exclude_tournaments, - sort_by, - sort_dir, - } = args; - - if (!VALID_CATEGORIES.includes(category as LeaderboardCategory)) { - throw new BadRequestException( - `Invalid category. Must be one of: ${VALID_CATEGORIES.join(", ")}`, - ); - } - - if (match_type && !VALID_MATCH_TYPES.includes(match_type as any)) { - throw new BadRequestException( - `Invalid match_type. Must be one of: ${VALID_MATCH_TYPES.join(", ")}`, - ); - } - - const limit = Math.min(Math.max(rawLimit || 25, 1), 100); - const offset = Math.max(rawOffset || 0, 0); - const excludeTournaments = exclude_tournaments ?? false; - - // Cache the full ranked result set (without pagination) so page changes are instant - const cacheKey = `leaderboard:${category}:${window_days}:${match_type || "all"}:${excludeTournaments ? "no_tourney" : "all"}`; - - let allRows = await this.cache.remember( - cacheKey, - async () => { - return this.executeQuery( - category as LeaderboardCategory, - window_days, - match_type || null, - excludeTournaments, - ); - }, - 300, - ); - - // Apply custom sorting if requested - if ( - sort_by && - VALID_SORT_FIELDS.includes(sort_by as (typeof VALID_SORT_FIELDS)[number]) - ) { - const dir = sort_dir === "asc" ? 1 : -1; - const field = sort_by as keyof LeaderboardEntry; - allRows = [...allRows].sort((a, b) => { - const aVal = (a[field] as number) ?? 0; - const bVal = (b[field] as number) ?? 0; - return (aVal - bVal) * dir; - }); - // Re-rank after sorting - allRows = allRows.map((row, index) => ({ ...row, rank: index + 1 })); - } - - return { - __typename: "LeaderboardResponse", - entries: allRows.slice(offset, offset + limit), - total: allRows.length, - }; - } - - private async executeQuery( - category: LeaderboardCategory, - windowDays: number, - matchType: string | null, - excludeTournaments: boolean, - ): Promise { - switch (category) { - case "elo": - return this.queryElo(windowDays, matchType, excludeTournaments); - case "best_kdr": - return this.queryBestKdr(windowDays, matchType, excludeTournaments); - case "best_win_rate": - return this.queryBestWinRate(windowDays, matchType, excludeTournaments); - case "highest_hs_pct": - return this.queryHighestHsPct( - windowDays, - matchType, - excludeTournaments, - ); - } - } - - private tournamentExclusionFilter(matchIdExpr: string): string { - return `AND NOT EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = ${matchIdExpr})`; - } - - private mapRows(rows: any[]): LeaderboardEntry[] { - return rows.map( - (row, index): LeaderboardEntry => ({ - __typename: "LeaderboardEntry", - rank: index + 1, - player_steam_id: String(row.steam_id), - player_name: row.name || "Unknown", - player_avatar_url: row.avatar_url || null, - player_country: row.country || null, - value: Number(row.value), - secondary_value: - row.secondary_value != null ? Number(row.secondary_value) : null, - tertiary_value: - row.tertiary_value != null ? Number(row.tertiary_value) : null, - matches_played: - row.matches_played != null ? Number(row.matches_played) : null, - }), - ); - } - - /** - * Combined ELO query returning: - * value = Current ELO - * secondary_value = ELO Change (ending - starting ELO) - * matches_played = match count - */ - private async queryElo( - windowDays: number, - matchType: string | null, - excludeTournaments: boolean, - ): Promise { - const params: any[] = []; - let paramIdx = 1; - - const eloTypeFilter = matchType ? `AND pe.type = $${paramIdx++}` : ""; - if (matchType) params.push(matchType); - - const timeFilter = - windowDays > 0 - ? `AND pe.created_at >= NOW() - make_interval(days => $${paramIdx++})` - : ""; - if (windowDays > 0) params.push(windowDays); - - const streakMatchTypeFilter = matchType - ? `AND mo.type = $${paramIdx++}` - : ""; - if (matchType) params.push(matchType); - - const streakTimeFilter = - windowDays > 0 - ? `AND m.ended_at >= NOW() - make_interval(days => $${paramIdx++})` - : ""; - if (windowDays > 0) params.push(windowDays); - - const streakTournamentFilter = excludeTournaments - ? this.tournamentExclusionFilter("m.id") - : ""; - - params.push(MAX_RESULTS); - const limitParam = `$${paramIdx++}`; - - let sql: string; - - if (excludeTournaments) { - sql = ` - WITH last_elo_raw AS ( - SELECT DISTINCT ON (pe.steam_id) - pe.steam_id, - pe.current as raw_current - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - ORDER BY pe.steam_id, pe.created_at DESC - ), - tournament_adj AS ( - SELECT pe.steam_id, SUM(pe.change) as tourney_total - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - AND EXISTS (SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = pe.match_id) - GROUP BY pe.steam_id - ), - first_elo AS ( - SELECT DISTINCT ON (pe.steam_id) - pe.steam_id, - pe.current - pe.change as starting_elo - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - ORDER BY pe.steam_id, pe.created_at ASC - ), - match_counts AS ( - SELECT pe.steam_id, COUNT(*) as matches_played - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - ${this.tournamentExclusionFilter("pe.match_id")} - GROUP BY pe.steam_id - ), - win_streak AS ( - SELECT sub.steam_id, - COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak - FROM ( - SELECT - mlp.steam_id, - CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won, - ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn - FROM match_lineup_players mlp - JOIN match_lineups ml ON ml.id = mlp.match_lineup_id - JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) - JOIN match_options mo ON mo.id = m.match_options_id - WHERE m.status = 'Finished' - AND mlp.steam_id IS NOT NULL - AND m.winning_lineup_id IS NOT NULL - ${streakTimeFilter} - ${streakMatchTypeFilter} - ${streakTournamentFilter} - ) sub - GROUP BY sub.steam_id - ) - SELECT - le.steam_id, - p.name, - p.avatar_url, - p.country, - le.raw_current - COALESCE(ta.tourney_total, 0) as value, - (le.raw_current - COALESCE(ta.tourney_total, 0)) - fe.starting_elo as secondary_value, - COALESCE(ws.streak, 0) as tertiary_value, - COALESCE(mc.matches_played, 0) as matches_played - FROM last_elo_raw le - LEFT JOIN tournament_adj ta ON ta.steam_id = le.steam_id - JOIN first_elo fe ON fe.steam_id = le.steam_id - LEFT JOIN match_counts mc ON mc.steam_id = le.steam_id - LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id - JOIN players p ON p.steam_id = le.steam_id - ORDER BY value DESC - LIMIT ${limitParam} - `; - } else { - sql = ` - WITH last_elo AS ( - SELECT DISTINCT ON (pe.steam_id) - pe.steam_id, - pe.current as current_elo - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - ORDER BY pe.steam_id, pe.created_at DESC - ), - first_elo AS ( - SELECT DISTINCT ON (pe.steam_id) - pe.steam_id, - pe.current - pe.change as starting_elo - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - ORDER BY pe.steam_id, pe.created_at ASC - ), - match_counts AS ( - SELECT pe.steam_id, COUNT(*) as matches_played - FROM player_elo pe - WHERE 1=1 - ${eloTypeFilter} - ${timeFilter} - GROUP BY pe.steam_id - ), - win_streak AS ( - SELECT sub.steam_id, - COALESCE(MIN(CASE WHEN sub.won = 0 THEN sub.rn END) - 1, MAX(sub.rn))::int as streak - FROM ( - SELECT - mlp.steam_id, - CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won, - ROW_NUMBER() OVER (PARTITION BY mlp.steam_id ORDER BY m.ended_at DESC) as rn - FROM match_lineup_players mlp - JOIN match_lineups ml ON ml.id = mlp.match_lineup_id - JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) - JOIN match_options mo ON mo.id = m.match_options_id - WHERE m.status = 'Finished' - AND mlp.steam_id IS NOT NULL - AND m.winning_lineup_id IS NOT NULL - ${streakTimeFilter} - ${streakMatchTypeFilter} - ) sub - GROUP BY sub.steam_id - ) - SELECT - le.steam_id, - p.name, - p.avatar_url, - p.country, - le.current_elo as value, - le.current_elo - fe.starting_elo as secondary_value, - COALESCE(ws.streak, 0) as tertiary_value, - mc.matches_played - FROM last_elo le - JOIN first_elo fe ON fe.steam_id = le.steam_id - JOIN match_counts mc ON mc.steam_id = le.steam_id - LEFT JOIN win_streak ws ON ws.steam_id = le.steam_id - JOIN players p ON p.steam_id = le.steam_id - ORDER BY value DESC - LIMIT ${limitParam} - `; - } - - const rows = await this.postgres.query(sql, params); - return this.mapRows(rows); - } - - private async queryBestKdr( - windowDays: number, - matchType: string | null, - excludeTournaments: boolean, - ): Promise { - const params: any[] = []; - let paramIdx = 1; - - const killTimeFilter = - windowDays > 0 - ? `AND pk.time >= NOW() - make_interval(days => $${paramIdx++})` - : ""; - if (windowDays > 0) params.push(windowDays); - - let killMatchTypeJoin = ""; - let killMatchTypeFilter = ""; - if (matchType) { - killMatchTypeJoin = ` - JOIN matches m ON m.id = pk.match_id - JOIN match_options mo ON mo.id = m.match_options_id`; - killMatchTypeFilter = `AND mo.type = $${paramIdx++}`; - params.push(matchType); - } - - const killTournamentFilter = excludeTournaments - ? this.tournamentExclusionFilter("pk.match_id") - : ""; - - const deathTimeParamIdx = windowDays > 0 ? paramIdx++ : 0; - if (windowDays > 0) params.push(windowDays); - - let deathMatchTypeJoin = ""; - let deathMatchTypeFilter = ""; - if (matchType) { - deathMatchTypeJoin = ` - JOIN matches m2 ON m2.id = dk.match_id - JOIN match_options mo2 ON mo2.id = m2.match_options_id`; - deathMatchTypeFilter = `AND mo2.type = $${paramIdx++}`; - params.push(matchType); - } - - const deathTournamentFilter = excludeTournaments - ? this.tournamentExclusionFilter("dk.match_id") - : ""; - - params.push(MAX_RESULTS); - const limitParam = `$${paramIdx++}`; - - const deathTimeFilter = - windowDays > 0 - ? `AND dk.time >= NOW() - make_interval(days => $${deathTimeParamIdx})` - : ""; - - const sql = ` - WITH kills AS ( - SELECT - pk.attacker_steam_id as steam_id, - COUNT(*) as kill_count, - COUNT(DISTINCT pk.match_id) as match_count - FROM player_kills pk - ${killMatchTypeJoin} - WHERE pk.attacker_steam_id IS NOT NULL - AND pk.attacker_steam_id != pk.attacked_steam_id - ${killTimeFilter} - ${killMatchTypeFilter} - ${killTournamentFilter} - GROUP BY pk.attacker_steam_id - ), - deaths AS ( - SELECT - dk.attacked_steam_id as steam_id, - COUNT(*) as death_count - FROM player_kills dk - ${deathMatchTypeJoin} - WHERE 1=1 - ${deathTimeFilter} - ${deathMatchTypeFilter} - ${deathTournamentFilter} - GROUP BY dk.attacked_steam_id - ) - SELECT - k.steam_id, - p.name, - p.avatar_url, - p.country, - CASE WHEN COALESCE(d.death_count, 0) = 0 - THEN k.kill_count::float - ELSE ROUND((k.kill_count::numeric / d.death_count::numeric), 2)::float - END as value, - k.kill_count as secondary_value, - COALESCE(d.death_count, 0) as tertiary_value, - k.match_count as matches_played - FROM kills k - LEFT JOIN deaths d ON d.steam_id = k.steam_id - JOIN players p ON p.steam_id = k.steam_id - WHERE k.match_count >= 5 - ORDER BY value DESC - LIMIT ${limitParam} - `; - - const rows = await this.postgres.query(sql, params); - return this.mapRows(rows); - } - - /** - * Win Rate query returning: - * value = Win rate % - * secondary_value = Wins - * tertiary_value = Losses - * matches_played = Total matches - */ - private async queryBestWinRate( - windowDays: number, - matchType: string | null, - excludeTournaments: boolean, - ): Promise { - const params: any[] = []; - let paramIdx = 1; - - const timeFilter = - windowDays > 0 - ? `AND m.ended_at >= NOW() - make_interval(days => $${paramIdx++})` - : ""; - if (windowDays > 0) params.push(windowDays); - - const matchTypeFilter = matchType ? `AND mo.type = $${paramIdx++}` : ""; - if (matchType) params.push(matchType); - - const tournamentFilter = excludeTournaments - ? this.tournamentExclusionFilter("m.id") - : ""; - - params.push(MAX_RESULTS); - const limitParam = `$${paramIdx++}`; - - const sql = ` - WITH player_matches AS ( - SELECT - mlp.steam_id, - m.id as match_id, - CASE WHEN m.winning_lineup_id = mlp.match_lineup_id THEN 1 ELSE 0 END as won - FROM match_lineup_players mlp - JOIN match_lineups ml ON ml.id = mlp.match_lineup_id - JOIN matches m ON (m.lineup_1_id = ml.id OR m.lineup_2_id = ml.id) - JOIN match_options mo ON mo.id = m.match_options_id - WHERE m.status = 'Finished' - AND mlp.steam_id IS NOT NULL - AND m.winning_lineup_id IS NOT NULL - ${timeFilter} - ${matchTypeFilter} - ${tournamentFilter} - ) - SELECT - pm.steam_id, - p.name, - p.avatar_url, - p.country, - ROUND((SUM(pm.won)::numeric / COUNT(*)::numeric) * 100, 2)::float as value, - SUM(pm.won) as secondary_value, - COUNT(*) - SUM(pm.won) as tertiary_value, - COUNT(*) as matches_played - FROM player_matches pm - JOIN players p ON p.steam_id = pm.steam_id - GROUP BY pm.steam_id, p.name, p.avatar_url, p.country - HAVING COUNT(*) >= 5 - ORDER BY value DESC - LIMIT ${limitParam} - `; - - const rows = await this.postgres.query(sql, params); - return this.mapRows(rows); - } - - private async queryHighestHsPct( - windowDays: number, - matchType: string | null, - excludeTournaments: boolean, - ): Promise { - const params: any[] = []; - let paramIdx = 1; - - const timeFilter = - windowDays > 0 - ? `AND pk.time >= NOW() - make_interval(days => $${paramIdx++})` - : ""; - if (windowDays > 0) params.push(windowDays); - - let matchTypeJoin = ""; - let matchTypeFilter = ""; - if (matchType) { - matchTypeJoin = ` - JOIN matches m ON m.id = pk.match_id - JOIN match_options mo ON mo.id = m.match_options_id`; - matchTypeFilter = `AND mo.type = $${paramIdx++}`; - params.push(matchType); - } - - const tournamentFilter = excludeTournaments - ? this.tournamentExclusionFilter("pk.match_id") - : ""; - - params.push(MAX_RESULTS); - const limitParam = `$${paramIdx++}`; - - const sql = ` - SELECT - pk.attacker_steam_id as steam_id, - p.name, - p.avatar_url, - p.country, - ROUND((SUM(CASE WHEN pk.headshot THEN 1 ELSE 0 END)::numeric / COUNT(*)::numeric) * 100, 2)::float as value, - COUNT(*) as secondary_value, - NULL as tertiary_value, - COUNT(DISTINCT pk.match_id) as matches_played - FROM player_kills pk - JOIN players p ON p.steam_id = pk.attacker_steam_id - ${matchTypeJoin} - WHERE pk.attacker_steam_id IS NOT NULL - AND pk.attacker_steam_id != pk.attacked_steam_id - ${timeFilter} - ${matchTypeFilter} - ${tournamentFilter} - GROUP BY pk.attacker_steam_id, p.name, p.avatar_url, p.country - HAVING COUNT(*) >= 25 - ORDER BY value DESC - LIMIT ${limitParam} - `; - - const rows = await this.postgres.query(sql, params); - return this.mapRows(rows); - } -} diff --git a/src/leaderboard/leaderboard.module.ts b/src/leaderboard/leaderboard.module.ts deleted file mode 100644 index 6d26ad5f..00000000 --- a/src/leaderboard/leaderboard.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { LeaderboardController } from "./leaderboard.controller"; -import { CacheModule } from "../cache/cache.module"; -import { PostgresModule } from "../postgres/postgres.module"; -import { loggerFactory } from "../utilities/LoggerFactory"; - -@Module({ - imports: [CacheModule, PostgresModule], - controllers: [LeaderboardController], - providers: [loggerFactory()], -}) -export class LeaderboardModule {}