From 6b62c401fe3840db06bd8c3fc2208c8c51e0676c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 17 Sep 2026 23:44:17 -0400 Subject: [PATCH] feature: faceit and premier leaderboard categories - get_leaderboard gains faceit_elo and premier_rank, served from the ratings cached on players; an unplaced premier rank of 0 is treated as no rank - neither takes a window, season or source: a cached rating is not a match statistic, and filtering one by last-7-days would empty the board - the faceit poll now tops up the least recently refreshed ratings, bounded and at low concurrency, reusing refreshPlayer's per-player hourly lock --- .../functions/leaderboard/get_leaderboard.sql | 60 ++++++- src/faceit/faceit.service.spec.ts | 93 +++++++++++ src/faceit/faceit.service.ts | 62 +++++++ src/faceit/jobs/PollAllFaceitMatchHistory.ts | 22 +++ test/external-rank-leaderboard.spec.ts | 152 ++++++++++++++++++ 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 src/faceit/faceit.service.spec.ts create mode 100644 test/external-rank-leaderboard.spec.ts diff --git a/hasura/functions/leaderboard/get_leaderboard.sql b/hasura/functions/leaderboard/get_leaderboard.sql index f8c7798b9..6ed5bdec6 100644 --- a/hasura/functions/leaderboard/get_leaderboard.sql +++ b/hasura/functions/leaderboard/get_leaderboard.sql @@ -17,6 +17,7 @@ DROP FUNCTION IF EXISTS public._leaderboard_win_rate(INT, TEXT, BOOLEAN); DROP FUNCTION IF EXISTS public._leaderboard_hs_pct(INT, TEXT, BOOLEAN); DROP FUNCTION IF EXISTS public._leaderboard_hltv_metric(TEXT, INT, TEXT, BOOLEAN, TEXT); DROP FUNCTION IF EXISTS public._leaderboard_udr(INT, TEXT, BOOLEAN, TEXT); +DROP FUNCTION IF EXISTS public._leaderboard_external_rank(TEXT); -- Belt-and-suspenders: sweep up any other historical get_leaderboard arity we -- did not enumerate above (e.g. an even older 3-arg overload). @@ -36,7 +37,8 @@ BEGIN '_leaderboard_hs_pct', '_leaderboard_awards', '_leaderboard_hltv_metric', - '_leaderboard_udr' + '_leaderboard_udr', + '_leaderboard_external_rank' ) LOOP EXECUTE 'DROP FUNCTION ' || r.sig; @@ -125,8 +127,14 @@ BEGIN ELSIF _category = 'best_udr' THEN RETURN QUERY SELECT * FROM _leaderboard_udr(_window_days, _match_type, _exclude_tournaments, _role, _season_id, _source); + ELSIF _category = 'faceit_elo' THEN + RETURN QUERY SELECT * FROM _leaderboard_external_rank('faceit'); + + ELSIF _category = 'premier_rank' THEN + RETURN QUERY SELECT * FROM _leaderboard_external_rank('premier'); + ELSE - RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct, awards, best_rating, best_adr, best_kpr, best_kast, best_udr', _category; + RAISE EXCEPTION 'Invalid category: %. Must be one of: elo, best_kdr, best_win_rate, highest_hs_pct, awards, best_rating, best_adr, best_kpr, best_kast, best_udr, faceit_elo, premier_rank', _category; END IF; END; $$; @@ -135,6 +143,54 @@ $$; -- ELO leaderboard -- value = current ELO, secondary = ELO change, tertiary = win streak -- ============================================================ +-- FACEIT rating and Premier rank. +-- +-- These are the only categories that do not come from our own matches: they are +-- snapshots we cache on players from FACEIT and from parsed Premier demos. They +-- therefore take no window, season, match type or source - a player's FACEIT +-- rating is what it is whether or not they played here this week, and filtering +-- one by "last 7 days" would empty the board rather than narrow it. +CREATE OR REPLACE FUNCTION public._leaderboard_external_rank(_rating TEXT) +RETURNS SETOF public.leaderboard_entries +LANGUAGE plpgsql STABLE +AS $$ +BEGIN + IF _rating = 'faceit' THEN + RETURN QUERY + SELECT + p.steam_id::text, + p.name, + p.avatar_url, + p.country, + p.faceit_elo::float, + p.faceit_skill_level::float, + NULL::float, + 0, + p.custom_avatar_url + FROM public.players p + WHERE p.faceit_elo IS NOT NULL + ORDER BY p.faceit_elo DESC, p.name ASC; + ELSE + RETURN QUERY + SELECT + p.steam_id::text, + p.name, + p.avatar_url, + p.country, + p.premier_rank::float, + NULL::float, + NULL::float, + 0, + p.custom_avatar_url + FROM public.players p + -- The demo importer writes 0 for a player who has not placed this + -- season; ranked as a number that sorts as the worst rating in the game. + WHERE NULLIF(p.premier_rank, 0) IS NOT NULL + ORDER BY p.premier_rank DESC, p.name ASC; + END IF; +END; +$$; + CREATE OR REPLACE FUNCTION public._leaderboard_elo( _window_days INT, _match_type TEXT, diff --git a/src/faceit/faceit.service.spec.ts b/src/faceit/faceit.service.spec.ts new file mode 100644 index 000000000..d01e1e3fd --- /dev/null +++ b/src/faceit/faceit.service.spec.ts @@ -0,0 +1,93 @@ +import { Logger } from "@nestjs/common"; +import { FaceitService } from "./faceit.service"; + +// The leaderboard shows a cached FACEIT rating, so something has to keep the +// cache warm without hammering an API we do not own. +describe("FaceitService.refreshStaleRatings", () => { + let service: FaceitService; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let stalePlayers: Array<{ steam_id: string }>; + let refreshed: string[]; + let failFor: string[]; + + const build = (apiKey: string | null = "key") => { + hasura = { + query: jest.fn(async () => ({ players: stalePlayers })), + mutation: jest.fn(async () => ({})), + }; + + service = new FaceitService( + { get: () => apiKey } as any, + { has: jest.fn(), put: jest.fn() } as any, + hasura as any, + new Logger("FaceitTest"), + ); + + jest + .spyOn(service, "refreshPlayer") + .mockImplementation(async (steamId: string) => { + if (failFor.includes(steamId)) { + throw new Error("faceit is down"); + } + refreshed.push(steamId); + return true; + }); + }; + + beforeEach(() => { + stalePlayers = [ + { steam_id: "76561198000000001" }, + { steam_id: "76561198000000002" }, + ]; + refreshed = []; + failFor = []; + build(); + }); + + it("refreshes every stale player it is given", async () => { + const result = await service.refreshStaleRatings(); + + expect(refreshed.sort()).toEqual([ + "76561198000000001", + "76561198000000002", + ]); + expect(result.refreshed).toBe(2); + expect(result.failed).toBe(0); + }); + + it("only asks for players who already have a faceit account linked", async () => { + await service.refreshStaleRatings(); + + const where = hasura.query.mock.calls[0][0].players.__args.where; + + expect(where.faceit_player_id._is_null).toBe(false); + }); + + it("asks for the least recently refreshed first, in a bounded batch", async () => { + await service.refreshStaleRatings(); + + const args = hasura.query.mock.calls[0][0].players.__args; + + expect(args.order_by).toEqual([{ faceit_updated_at: "asc_nulls_first" }]); + expect(args.limit).toBeGreaterThan(0); + }); + + it("keeps going when one player fails", async () => { + failFor = ["76561198000000001"]; + + const result = await service.refreshStaleRatings(); + + expect(refreshed).toEqual(["76561198000000002"]); + expect(result.failed).toBe(1); + expect(result.refreshed).toBe(1); + }); + + it("does nothing at all without an api key", async () => { + build(null); + + const result = await service.refreshStaleRatings(); + + expect(hasura.query).not.toHaveBeenCalled(); + expect(result.refreshed).toBe(0); + }); +}); diff --git a/src/faceit/faceit.service.ts b/src/faceit/faceit.service.ts index 28e19546a..ba2bd49cd 100644 --- a/src/faceit/faceit.service.ts +++ b/src/faceit/faceit.service.ts @@ -16,6 +16,8 @@ export class FaceitService { private static readonly BASE_URL = "https://open.faceit.com/data/v4"; private static readonly REFRESH_INTERVAL_SECONDS = 60 * 60; private static readonly NO_ACCOUNT_TTL_SECONDS = 12 * 60 * 60; + private static readonly STALE_REFRESH_LIMIT = 100; + private static readonly STALE_REFRESH_CONCURRENCY = 2; private readonly apiKey: string; constructor( @@ -295,6 +297,66 @@ export class FaceitService { } } + // Keeps the ratings behind external_rank_leaderboard warm. Bounded and + // low-concurrency on purpose: this runs on a schedule against an API we do + // not own, and the leaderboard is a standings board, not a live readout. + public async refreshStaleRatings(): Promise<{ + refreshed: number; + failed: number; + }> { + if (!this.isEnabled()) { + return { refreshed: 0, failed: 0 }; + } + + const { players } = await this.hasura.query({ + players: { + __args: { + where: { + faceit_player_id: { + _is_null: false, + }, + }, + order_by: [{ faceit_updated_at: "asc_nulls_first" }], + limit: FaceitService.STALE_REFRESH_LIMIT, + }, + steam_id: true, + }, + }); + + let refreshed = 0; + let failed = 0; + + const queue = [...(players ?? [])]; + + const worker = async () => { + while (queue.length > 0) { + const player = queue.shift(); + + if (!player) { + return; + } + + try { + await this.refreshPlayer(player.steam_id); + refreshed++; + } catch (error) { + failed++; + this.logger.warn( + `faceit rating refresh failed for ${player.steam_id}: ${ + (error as Error)?.message ?? String(error) + }`, + ); + } + } + }; + + await Promise.all( + Array.from({ length: FaceitService.STALE_REFRESH_CONCURRENCY }, worker), + ); + + return { refreshed, failed }; + } + public async refreshPlayer(steamId: string, force = false): Promise { if (!this.isEnabled()) { return false; diff --git a/src/faceit/jobs/PollAllFaceitMatchHistory.ts b/src/faceit/jobs/PollAllFaceitMatchHistory.ts index 66a981774..eb5f74739 100644 --- a/src/faceit/jobs/PollAllFaceitMatchHistory.ts +++ b/src/faceit/jobs/PollAllFaceitMatchHistory.ts @@ -4,17 +4,39 @@ import { WorkerHost } from "@nestjs/bullmq"; import { UseQueue } from "src/utilities/QueueProcessors"; import { FaceitQueues } from "../enums/FaceitQueues"; import { FaceitMatchImportService } from "../faceit-match-import.service"; +import { FaceitService } from "../faceit.service"; @UseQueue("Faceit", FaceitQueues.PollAllFaceitMatchHistory) export class PollAllFaceitMatchHistory extends WorkerHost { constructor( private readonly logger: Logger, + private readonly faceit: FaceitService, private readonly faceitImport: FaceitMatchImportService, ) { super(); } async process(_job: Job): Promise { + // Piggybacks on the poll rather than carrying its own schedule. Ratings + // feed the leaderboard, and refreshPlayer keeps its own per-player hourly + // lock, so a busier poll does not mean more calls to faceit. + try { + const { refreshed, failed } = await this.faceit.refreshStaleRatings(); + + if (refreshed || failed) { + this.logger.log( + `faceit ratings refreshed=${refreshed} failed=${failed}`, + ); + } + } catch (error) { + // Never let the rating cache take the match import down with it. + this.logger.warn( + `faceit rating refresh pass failed: ${ + (error as Error)?.message ?? String(error) + }`, + ); + } + await this.faceitImport.pollAllActive(); } } diff --git a/test/external-rank-leaderboard.spec.ts b/test/external-rank-leaderboard.spec.ts new file mode 100644 index 000000000..03c190749 --- /dev/null +++ b/test/external-rank-leaderboard.spec.ts @@ -0,0 +1,152 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// The FACEIT and Premier leaderboard categories. Unlike every other category +// these are point-in-time ratings held on players rather than anything derived +// from our own matches, so the window, season and source filters do not apply +// to them - they must be ignored rather than silently emptying the board. +describe("external rank leaderboard (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("ExternalRankLeaderboardTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561196200000000n); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM players"); + }); + + const withRanks = async (ranks: { + name?: string; + faceitElo?: number | null; + faceitLevel?: number | null; + premierRank?: number | null; + }) => { + const steamId = await fx.player(ranks.name); + await postgres.query( + `UPDATE players + SET faceit_elo = $2, + faceit_skill_level = $3, + premier_rank = $4 + WHERE steam_id = $1::bigint`, + [ + steamId, + ranks.faceitElo ?? null, + ranks.faceitLevel ?? null, + ranks.premierRank ?? null, + ], + ); + return steamId; + }; + + type Entry = { + player_steam_id: string; + player_name: string; + value: number; + secondary_value: number | null; + matches_played: number | null; + }; + + const board = (category: string, windowDays = 0, seasonId?: string) => + postgres.query>( + `SELECT player_steam_id, player_name, value, secondary_value, matches_played + FROM get_leaderboard($1, $2, NULL, false, NULL, $3::uuid, 'overall')`, + [category, windowDays, seasonId ?? null], + ); + + describe("faceit_elo", () => { + it("ranks players by their faceit rating, highest first", async () => { + await withRanks({ faceitElo: 1200, faceitLevel: 5 }); + const best = await withRanks({ faceitElo: 3000, faceitLevel: 10 }); + await withRanks({ faceitElo: 2000, faceitLevel: 8 }); + + const rows = await board("faceit_elo"); + + expect(rows.map((row) => row.value)).toEqual([3000, 2000, 1200]); + expect(rows[0].player_steam_id).toBe(best); + }); + + it("carries the skill level as the second column", async () => { + await withRanks({ faceitElo: 2500, faceitLevel: 9 }); + + const [row] = await board("faceit_elo"); + + expect(row.secondary_value).toBe(9); + }); + + it("leaves out players with no faceit rating", async () => { + await fx.player(); + await withRanks({ premierRank: 15000 }); + await withRanks({ faceitElo: 1000 }); + + expect(await board("faceit_elo")).toHaveLength(1); + }); + + it("ignores the window, because a rating is not a match statistic", async () => { + await withRanks({ faceitElo: 1800 }); + + // a 7 day window would otherwise empty a board of players who have not + // played on 5stack this week + expect(await board("faceit_elo", 7)).toHaveLength(1); + }); + }); + + describe("premier_rank", () => { + it("ranks players by their premier rank, highest first", async () => { + await withRanks({ premierRank: 12000 }); + const best = await withRanks({ premierRank: 24000 }); + + const rows = await board("premier_rank"); + + expect(rows.map((row) => row.value)).toEqual([24000, 12000]); + expect(rows[0].player_steam_id).toBe(best); + }); + + it("treats an unplaced rank of zero as no rank at all", async () => { + // the demo importer writes 0 for a player who has not placed; ranked as + // a number it would sort as the worst rating in the game + await withRanks({ premierRank: 0 }); + + expect(await board("premier_rank")).toHaveLength(0); + }); + + it("leaves out players with no premier rank", async () => { + await fx.player(); + await withRanks({ faceitElo: 2000 }); + + expect(await board("premier_rank")).toHaveLength(0); + }); + + it("reports no match count, which it has no way to know", async () => { + await withRanks({ premierRank: 20000 }); + + const [row] = await board("premier_rank"); + + expect(row.matches_played).toBe(0); + }); + }); + + it("gives a player their rank on an external board", async () => { + await withRanks({ faceitElo: 3000 }); + const middle = await withRanks({ faceitElo: 2000 }); + await withRanks({ faceitElo: 1000 }); + + const [row] = await postgres.query>( + `SELECT rank, total + FROM get_player_leaderboard_rank('faceit_elo', 0, $1, NULL, false, NULL, 'overall')`, + [middle], + ); + + expect(row.rank).toBe(2); + expect(row.total).toBe(3); + }); +});