Skip to content

Commit 67c4aa9

Browse files
committed
feat: add leaderboard with time-windowed rankings and pagination
Add getLeaderboard Hasura action with 7 categories (highest ELO, most ELO gained, most kills, best KDR, best win rate, most matches, highest HS%), time period filters, match type filters, tournament exclusion, and server-side pagination with Redis caching. Also generate ELO ratings in fixture loader and add more fixture matches for testing.
1 parent 7fbf604 commit 67c4aa9

7 files changed

Lines changed: 886 additions & 1 deletion

File tree

hasura/fixtures/fixtures.sql

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
-- Dev Fixture Data
2-
-- Inserts ~40 players, 8 teams, ~100 matches with scores, map veto picks, utility/flash data, and 4 tournaments
2+
-- Inserts ~40 players, 8 teams, ~143 matches with scores, map veto picks, utility/flash data, and 4 tournaments
33
-- This file is idempotent: running cleanup.sql first removes prior fixture data
44

55
-- Disable triggers on affected tables (excluding hypertables which don't support this)
@@ -491,6 +491,182 @@ BEGIN
491491
END IF; -- finished/live
492492
END LOOP; -- matches
493493

494+
-- ==========================================
495+
-- 5b. ADDITIONAL STANDALONE MATCHES (~43 more finished, spread across last 30 days)
496+
-- ==========================================
497+
FOR match_idx IN 1..43 LOOP
498+
-- Pick two different teams with offset for variety
499+
team_1_idx := ((match_idx + 3) % 8) + 1;
500+
team_2_idx := ((match_idx + 6) % 8) + 1;
501+
IF team_1_idx = team_2_idx THEN
502+
team_2_idx := (team_2_idx % 8) + 1;
503+
END IF;
504+
505+
-- Spread across last 30 days (denser toward recent)
506+
match_date := now() - (interval '16 hours' * (43 - match_idx)) - interval '6 hours';
507+
508+
-- Create match options
509+
match_options_id := gen_random_uuid();
510+
INSERT INTO match_options (id, overtime, knife_round, mr, best_of, map_veto, type, map_pool_id, lobby_access, tv_delay)
511+
VALUES (match_options_id, true, true, 12, 1, true, 'Competitive', comp_map_pool_id, 'Private', 115);
512+
513+
-- Create lineups
514+
lineup_1_id := gen_random_uuid();
515+
lineup_2_id := gen_random_uuid();
516+
INSERT INTO match_lineups (id, team_id, team_name) VALUES (lineup_1_id, team_ids[team_1_idx], team_names[team_1_idx]);
517+
INSERT INTO match_lineups (id, team_id, team_name) VALUES (lineup_2_id, team_ids[team_2_idx], team_names[team_2_idx]);
518+
519+
-- Create match
520+
match_id := gen_random_uuid();
521+
INSERT INTO matches (id, status, match_options_id, lineup_1_id, lineup_2_id, created_at, scheduled_at,
522+
started_at, ended_at, winning_lineup_id)
523+
VALUES (match_id, 'Finished', match_options_id, lineup_1_id, lineup_2_id,
524+
match_date - interval '1 hour', match_date, match_date,
525+
match_date + interval '45 minutes',
526+
CASE WHEN match_idx % 2 = 0 THEN lineup_1_id ELSE lineup_2_id END);
527+
528+
-- Add lineup players (5 per team)
529+
FOR j IN 1..5 LOOP
530+
INSERT INTO match_lineup_players (match_lineup_id, steam_id, captain, checked_in)
531+
VALUES (lineup_1_id, p_steam_ids[(team_1_idx - 1) * 5 + j], j = 1, true);
532+
INSERT INTO match_lineup_players (match_lineup_id, steam_id, captain, checked_in)
533+
VALUES (lineup_2_id, p_steam_ids[(team_2_idx - 1) * 5 + j], j = 1, true);
534+
END LOOP;
535+
536+
-- Create match map (BO1)
537+
cur_map_id := map_ids[((match_idx + 3) % map_count) + 1];
538+
match_map_id := gen_random_uuid();
539+
INSERT INTO match_maps (id, match_id, map_id, "order", status, lineup_1_side, lineup_2_side, started_at, ended_at, winning_lineup_id)
540+
VALUES (match_map_id, match_id, cur_map_id, 1, 'Finished', 'CT', 'TERRORIST',
541+
match_date, match_date + interval '40 minutes',
542+
CASE WHEN match_idx % 2 = 0 THEN lineup_1_id ELSE lineup_2_id END);
543+
544+
-- Map veto picks
545+
DECLARE
546+
ban_n int := 0;
547+
BEGIN
548+
FOR j IN 1..map_count LOOP
549+
IF map_ids[j] != cur_map_id THEN
550+
ban_n := ban_n + 1;
551+
INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id, created_at)
552+
VALUES (match_id, 'Ban', CASE WHEN ban_n % 2 = 1 THEN lineup_1_id ELSE lineup_2_id END,
553+
map_ids[j], match_date - interval '10 minutes' + (interval '30 seconds' * ban_n));
554+
END IF;
555+
END LOOP;
556+
ban_n := ban_n + 1;
557+
INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id, created_at)
558+
VALUES (match_id, 'Decider', lineup_1_id, cur_map_id,
559+
match_date - interval '10 minutes' + (interval '30 seconds' * ban_n));
560+
END;
561+
562+
-- Determine score
563+
IF match_idx % 2 = 0 THEN
564+
l1_score := 13; l2_score := 5 + (match_idx % 8);
565+
ELSE
566+
l1_score := 5 + (match_idx % 8); l2_score := 13;
567+
END IF;
568+
total_rounds := l1_score + l2_score;
569+
570+
-- Generate rounds, kills, and utility
571+
v_t1_wins := 0; v_t2_wins := 0;
572+
FOR round_num IN 1..total_rounds LOOP
573+
IF v_t1_wins * total_rounds < round_num * l1_score AND v_t1_wins < l1_score THEN
574+
v_t1_wins := v_t1_wins + 1;
575+
IF round_num <= 12 THEN winning_side := 'CT'; ELSE winning_side := 'TERRORIST'; END IF;
576+
ELSE
577+
v_t2_wins := v_t2_wins + 1;
578+
IF round_num <= 12 THEN winning_side := 'TERRORIST'; ELSE winning_side := 'CT'; END IF;
579+
END IF;
580+
581+
INSERT INTO match_map_rounds (
582+
match_map_id, round, lineup_1_score, lineup_2_score,
583+
lineup_1_money, lineup_2_money, time,
584+
lineup_1_timeouts_available, lineup_2_timeouts_available,
585+
winning_side, lineup_1_side, lineup_2_side)
586+
VALUES (
587+
match_map_id, round_num, v_t1_wins, v_t2_wins,
588+
4100 + (round_num * 300), 4100 + (round_num * 300),
589+
match_date + (interval '2 minutes' * round_num), 2, 2, winning_side,
590+
CASE WHEN round_num <= 12 THEN 'CT' ELSE 'TERRORIST' END,
591+
CASE WHEN round_num <= 12 THEN 'TERRORIST' ELSE 'CT' END);
592+
593+
-- Kills (5-7 per round)
594+
FOR k IN 1..(5 + (round_num % 3)) LOOP
595+
IF k % 2 = 1 THEN
596+
attacker_idx := (team_1_idx - 1) * 5 + ((k - 1) % 5) + 1;
597+
attacked_idx := (team_2_idx - 1) * 5 + ((k + round_num) % 5) + 1;
598+
ELSE
599+
attacker_idx := (team_2_idx - 1) * 5 + ((k - 1) % 5) + 1;
600+
attacked_idx := (team_1_idx - 1) * 5 + ((k + round_num) % 5) + 1;
601+
END IF;
602+
603+
weapon_idx := ((match_idx + round_num + k + 50) % array_length(weapons, 1)) + 1;
604+
is_headshot := (match_idx + round_num + k + 1) % 3 = 0;
605+
kill_time := match_date + (interval '2 minutes' * round_num) + (interval '5 seconds' * k);
606+
607+
INSERT INTO player_kills (
608+
match_id, match_map_id, round,
609+
attacker_steam_id, attacker_team,
610+
attacked_steam_id, attacked_team, attacked_location,
611+
"with", hitgroup, headshot, time)
612+
VALUES (
613+
match_id, match_map_id, round_num,
614+
p_steam_ids[attacker_idx],
615+
CASE WHEN k % 2 = 1 THEN 'CT' ELSE 'TERRORIST' END,
616+
p_steam_ids[attacked_idx],
617+
CASE WHEN k % 2 = 1 THEN 'TERRORIST' ELSE 'CT' END,
618+
'BombsiteA', weapons[weapon_idx],
619+
CASE WHEN is_headshot THEN 'head' ELSE hitgroups[((k + round_num) % 6) + 2] END,
620+
is_headshot, kill_time)
621+
ON CONFLICT DO NOTHING;
622+
623+
-- Track stats
624+
kill_counts[attacker_idx] := kill_counts[attacker_idx] + 1;
625+
death_counts[attacked_idx] := death_counts[attacked_idx] + 1;
626+
IF is_headshot THEN
627+
headshot_counts[attacker_idx] := headshot_counts[attacker_idx] + 1;
628+
END IF;
629+
630+
DECLARE
631+
wkey text := p_steam_ids[attacker_idx]::text || ':' || weapons[weapon_idx];
632+
BEGIN
633+
IF weapon_kill_map ? wkey THEN
634+
weapon_kill_map := jsonb_set(weapon_kill_map, ARRAY[wkey], to_jsonb((weapon_kill_map->>wkey)::int + 1));
635+
ELSE
636+
weapon_kill_map := weapon_kill_map || jsonb_build_object(wkey, 1);
637+
END IF;
638+
END;
639+
END LOOP; -- kills per round
640+
641+
-- Utility events (flash + smoke per round)
642+
DECLARE
643+
ut timestamptz;
644+
fp1 int := (team_1_idx - 1) * 5 + ((round_num + 1) % 5) + 1;
645+
fp2 int := (team_2_idx - 1) * 5 + ((round_num + 2) % 5) + 1;
646+
fe1 int := (team_2_idx - 1) * 5 + ((round_num + 3) % 5) + 1;
647+
fe2 int := (team_1_idx - 1) * 5 + ((round_num + 4) % 5) + 1;
648+
BEGIN
649+
ut := match_date + (interval '2 minutes' * round_num) + interval '40 seconds';
650+
INSERT INTO player_utility (match_map_id, attacker_steam_id, time, match_id, round, type)
651+
VALUES (match_map_id, p_steam_ids[fp1], ut, match_id, round_num, 'Flash') ON CONFLICT DO NOTHING;
652+
INSERT INTO player_flashes (match_map_id, time, attacker_steam_id, attacked_steam_id, match_id, round, duration, team_flash)
653+
VALUES (match_map_id, ut + interval '1 second', p_steam_ids[fp1], p_steam_ids[fe1], match_id, round_num,
654+
1.5 + (round_num % 3)::numeric * 0.5, false) ON CONFLICT DO NOTHING;
655+
656+
ut := match_date + (interval '2 minutes' * round_num) + interval '50 seconds';
657+
INSERT INTO player_utility (match_map_id, attacker_steam_id, time, match_id, round, type)
658+
VALUES (match_map_id, p_steam_ids[fp2], ut, match_id, round_num, 'Flash') ON CONFLICT DO NOTHING;
659+
INSERT INTO player_flashes (match_map_id, time, attacker_steam_id, attacked_steam_id, match_id, round, duration, team_flash)
660+
VALUES (match_map_id, ut + interval '1 second', p_steam_ids[fp2], p_steam_ids[fe2], match_id, round_num,
661+
1.5 + ((round_num + 1) % 3)::numeric * 0.5, false) ON CONFLICT DO NOTHING;
662+
663+
ut := match_date + (interval '2 minutes' * round_num) + interval '35 seconds';
664+
INSERT INTO player_utility (match_map_id, attacker_steam_id, time, match_id, round, type)
665+
VALUES (match_map_id, p_steam_ids[(team_1_idx - 1) * 5 + ((round_num + 3) % 5) + 1], ut, match_id, round_num, 'Smoke') ON CONFLICT DO NOTHING;
666+
END;
667+
END LOOP; -- rounds
668+
END LOOP; -- additional matches
669+
494670
-- ==========================================
495671
-- 6. POPULATE AGGREGATE TABLES
496672
-- ==========================================

hasura/metadata/actions.graphql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,33 @@ type Mutation {
334334
): SuccessOutput
335335
}
336336

337+
type Query {
338+
getLeaderboard(
339+
category: String!
340+
window_days: Int!
341+
match_type: String
342+
limit: Int
343+
offset: Int
344+
exclude_tournaments: Boolean
345+
): LeaderboardResponse!
346+
}
347+
348+
type LeaderboardResponse {
349+
entries: [LeaderboardEntry!]!
350+
total: Int!
351+
}
352+
353+
type LeaderboardEntry {
354+
rank: Int!
355+
player_steam_id: String!
356+
player_name: String!
357+
player_avatar_url: String
358+
player_country: String
359+
value: Float!
360+
secondary_value: Float
361+
matches_played: Int
362+
}
363+
337364
input SampleInput {
338365
username: String!
339366
password: String!

hasura/metadata/actions.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,14 @@ actions:
443443
permissions:
444444
- role: administrator
445445
comment: Write content to file on game server
446+
- name: getLeaderboard
447+
definition:
448+
kind: ""
449+
handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}'
450+
forward_client_headers: true
451+
permissions:
452+
- role: guest
453+
comment: Get leaderboard rankings by category and time window
446454
custom_types:
447455
enums: []
448456
input_objects:
@@ -496,4 +504,6 @@ custom_types:
496504
- name: StorageStats
497505
- name: StorageSummary
498506
- name: TableSizeInfo
507+
- name: LeaderboardResponse
508+
- name: LeaderboardEntry
499509
scalars: []

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { K8sModule } from "./k8s/k8s.module";
4343
import { FileManagerModule } from "./file-manager/file-manager.module";
4444
import { BrandingModule } from "./branding/branding.module";
4545
import { FixturesModule } from "./fixtures/fixtures.module";
46+
import { LeaderboardModule } from "./leaderboard/leaderboard.module";
4647

4748
@Module({
4849
imports: [
@@ -126,6 +127,7 @@ import { FixturesModule } from "./fixtures/fixtures.module";
126127
FileManagerModule,
127128
BrandingModule,
128129
FixturesModule,
130+
LeaderboardModule,
129131
],
130132
providers: [loggerFactory()],
131133
controllers: [AppController, QuickConnectController],

src/fixtures/fixtures.controller.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ export class FixturesController {
4343
this.logger.log("Fixtures: Loading fixture data...");
4444
await this.postgres.query(fixturesSql);
4545

46+
this.logger.log("Fixtures: Generating player ELO ratings...");
47+
await this.postgres.query(`
48+
DO $$
49+
DECLARE
50+
m RECORD;
51+
BEGIN
52+
DELETE FROM player_elo
53+
WHERE match_id IN (
54+
SELECT id FROM matches
55+
WHERE ended_at IS NOT NULL AND winning_lineup_id IS NOT NULL
56+
);
57+
FOR m IN
58+
SELECT id FROM matches
59+
WHERE ended_at IS NOT NULL
60+
AND winning_lineup_id IS NOT NULL
61+
ORDER BY created_at ASC
62+
LOOP
63+
PERFORM generate_player_elo_for_match(m.id);
64+
END LOOP;
65+
END $$;
66+
`);
67+
4668
this.logger.log("Fixtures: Refreshing Typesense player index...");
4769
await this.typesenseQueue.add(RefreshAllPlayersJob.name, {});
4870

0 commit comments

Comments
 (0)