Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions generated/schema.graphql

Large diffs are not rendered by default.

344 changes: 264 additions & 80 deletions generated/schema.ts

Large diffs are not rendered by default.

40,969 changes: 20,715 additions & 20,254 deletions generated/types.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions hasura/enums/notification-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ INSERT INTO e_notification_types ("value", "description") VALUES
('MatchSupport', 'MatchSupport'),
('GameNodeStatus', 'GameNodeStatus'),
('NameChangeRequest', 'NameChangeRequest'),
('MatchAbandoned', 'A player abandoned a match'),
('NameChangeApproved', 'Your name change request was approved'),
('NameChangeDenied', 'Your name change request was denied'),
('DedicatedServerStatus', 'DedicatedServerStatus'),
('DedicatedServerRconStatus', 'DedicatedServerRconStatus'),
('MatchStatusChange', 'Match Status Change Notification'),
Expand Down
3 changes: 2 additions & 1 deletion hasura/functions/events/get_event_leaderboard.sql
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ SELECT
END AS value,
a.kills AS secondary_value,
a.deaths AS tertiary_value,
a.matches_played
a.matches_played,
p.custom_avatar_url AS player_custom_avatar_url
FROM agg a
JOIN players p ON p.steam_id = a.steam_id
LEFT JOIN wins w ON w.steam_id = a.steam_id
Expand Down
381 changes: 344 additions & 37 deletions hasura/functions/leaderboard/get_leaderboard.sql

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ BEGIN
WHEN 'best_kast' THEN ROUND(a.rating::numeric, 2)
END)::float AS secondary_value,
a.rounds::float AS tertiary_value,
a.match_count AS matches_played
a.match_count AS matches_played,
p.custom_avatar_url AS player_custom_avatar_url
FROM agg a
JOIN players p ON p.steam_id = a.steam_id
ORDER BY value DESC NULLS LAST;
Expand Down Expand Up @@ -105,7 +106,8 @@ BEGIN
ROUND(a.udr, 1)::float AS value,
a.util_damage::float AS secondary_value,
a.rounds::float AS tertiary_value,
a.match_count AS matches_played
a.match_count AS matches_played,
p.custom_avatar_url AS player_custom_avatar_url
FROM agg a
JOIN players p ON p.steam_id = a.steam_id
ORDER BY value DESC NULLS LAST;
Expand Down
15 changes: 11 additions & 4 deletions hasura/functions/match/can_reassign_winner.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ RETURNS boolean
LANGUAGE plpgsql STABLE
AS $$
DECLARE
bracket_id uuid;
_parent_bracket_id uuid;
_loser_parent_bracket_id uuid;
blocking_downstream int;
BEGIN
IF NOT is_match_organizer(match, hasura_session) THEN
Expand All @@ -14,19 +15,25 @@ BEGIN
RETURN false;
END IF;

SELECT id INTO bracket_id
SELECT parent_bracket_id, loser_parent_bracket_id
INTO _parent_bracket_id, _loser_parent_bracket_id
FROM tournament_brackets
WHERE match_id = match.id
LIMIT 1;

IF bracket_id IS NULL THEN
IF NOT FOUND THEN
RETURN true;
END IF;

-- These point *forward*: they are the bracket(s) this match's winner and
-- loser advance into (see update_tournament_bracket.sql, which assigns the
-- winning team INTO parent_bracket_id). Matching on tb.parent_bracket_id =
-- this bracket would instead find the matches feeding IN to it, which are
-- always already played, and so never blocked anything.
SELECT count(*) INTO blocking_downstream
FROM tournament_brackets tb
LEFT JOIN matches m ON m.id = tb.match_id
WHERE (tb.parent_bracket_id = bracket_id OR tb.loser_parent_bracket_id = bracket_id)
WHERE tb.id IN (_parent_bracket_id, _loser_parent_bracket_id)
AND m.id IS NOT NULL
AND m.status NOT IN ('Scheduled', 'WaitingForCheckIn', 'Canceled');

Expand Down
61 changes: 28 additions & 33 deletions hasura/functions/match/map-veto/get_map_veto_pattern.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,71 @@ CREATE OR REPLACE FUNCTION public.get_map_veto_pattern(_match public.matches) RE
LANGUAGE plpgsql
AS $$
DECLARE
pool uuid[];
best_of int;
pattern TEXT[] := '{}';
base_pattern TEXT[] := '{}';
i INT;
pool_size INT;
surplus INT;
_type TEXT;
pattern_matched BOOLEAN := false;
BEGIN
SELECT mo.best_of INTO best_of
FROM matches m
INNER JOIN match_options mo ON mo.id = m.match_options_id
WHERE m.id = _match.id;

SELECT array_agg(mp.map_id) INTO pool
-- count() over the LEFT JOIN: an empty pool yields a row with a NULL
-- map_id, which array_agg would report as a pool of one.
SELECT count(mp.map_id) INTO pool_size
FROM matches m
INNER JOIN match_options mo ON mo.id = m.match_options_id
LEFT JOIN _map_pool mp ON mp.map_pool_id = mo.map_pool_id
WHERE m.id = _match.id;

pool_size := coalesce(array_length(pool, 1), 0);

IF(best_of > pool_size) THEN
RAISE EXCEPTION 'Not enough maps in the pool for the best of %', best_of USING ERRCODE = '22000';
END IF;

-- https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/major-supplemental-rulebook.md#map-pick-ban

IF best_of = 1 THEN
FOR i IN 1..(pool_size - 1) LOOP
base_pattern := array_append(base_pattern, 'Ban');
END LOOP;
pattern_matched := true;
ELSIF pool_size = best_of THEN
FOR i IN 1..(pool_size - 1) LOOP
base_pattern := array_append(base_pattern, 'Pick');
END LOOP;
pattern_matched := true;
ELSIF best_of = 3 THEN
IF best_of = 3 AND pool_size >= 4 THEN
IF pool_size = 4 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick'];
ELSIF pool_size = 5 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Ban'];
-- Both bans open the veto, as in the 6- and 7-map patterns below and
-- in the linked rulebook. Ban/Pick/Pick/Ban let a map be picked
-- before either team had finished banning, and it was the only BO3
-- pool that did not lead with two bans -- which is also what the
-- turn-swap in get_map_veto_picking_lineup_id assumes.
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick'];
ELSIF pool_size = 6 THEN
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban'];
ELSE
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Ban', 'Ban'];
END IF;
pattern_matched := true;
ELSIF best_of = 5 THEN
if pool_size = 6 THEN
ELSIF best_of = 5 AND pool_size >= 6 THEN
IF pool_size = 6 THEN
base_pattern := ARRAY['Ban', 'Pick', 'Pick', 'Pick', 'Pick'];
ELSE
base_pattern := ARRAY['Ban', 'Ban', 'Pick', 'Pick', 'Pick', 'Pick'];
END IF;
pattern_matched := true;
ELSE
-- Everything the rulebook doesn't cover (BO1, a pool only big enough to
-- pick from, any other best of): pick the maps that get played and let
-- the bans below account for the rest. Without this arm an unsupported
-- best of returned a pattern of NULLs and the veto could never finish.
base_pattern := array_fill('Pick'::text, ARRAY[best_of - 1]);
END IF;

-- Maps the pattern above doesn't account for become extra Bans, and they
-- MUST land before the Decider. get_map_veto_type reads this array
-- positionally, and the Decider is only ever auto-inserted by
-- create_match_map_from_veto once exactly one map is left, so any step
-- sitting after it can never be satisfied and the veto deadlocks.
IF pattern_matched THEN
FOR i IN 1..(pool_size - 1 - coalesce(array_length(base_pattern, 1), 0)) LOOP
base_pattern := array_append(base_pattern, 'Ban');
END LOOP;

base_pattern := array_append(base_pattern, 'Decider');
END IF;
-- Maps the pattern doesn't account for are banned up front, trimming the
-- pool to the rulebook shape before the picks. The Decider closes: it is
-- only ever auto-inserted by create_match_map_from_veto once one map is
-- left, so any step sitting after it can never be satisfied.
surplus := pool_size - 1 - coalesce(array_length(base_pattern, 1), 0);
base_pattern := array_append(
array_fill('Ban'::text, ARRAY[surplus]) || base_pattern,
'Decider'
);

FOR i IN 1..(pool_size) LOOP
_type := base_pattern[i];
Expand Down
14 changes: 14 additions & 0 deletions hasura/functions/match/match_player_elo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ BEGIN
-- ELO change formula: New Rating = Old Rating + K * (Actual Score - Expected Score) * Performance Multiplier * Series Multiplier
_elo_change := ROUND(_k_factor * (_actual_score - _expected_score) * _performance_multiplier * _series_multiplier);

-- Abandoning the match overrides the result entirely: a fixed loss, applied
-- whichever way the match ended. Scoring an abandon through the normal
-- formula produces an inconsistent and often trivial penalty -- it scales
-- with the rating gap, so leaving a match you were losing can cost almost
-- nothing. Set leaver_elo_penalty to 0 to disable.
IF EXISTS (
SELECT 1
FROM abandoned_matches am
WHERE am.match_id = match_record.id
AND am.steam_id = player_record.steam_id
) THEN
_elo_change := -1 * get_int_setting('leaver_elo_penalty', 150);
END IF;

-- Return the elo change as JSON with detailed information
RETURN jsonb_build_object(
'current_elo', _current_player_elo, -- The current ELO rating of the player (including base ELO)
Expand Down
25 changes: 21 additions & 4 deletions hasura/functions/match/options/cleanup_orphaned_match_options.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,29 @@ BEGIN
RETURN;
END IF;

IF NOT EXISTS (SELECT 1 FROM public.matches WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournaments WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_stages WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_brackets WHERE match_options_id = target_id)
-- Every table that points at match_options has to be checked. draft_games,
-- league_seasons and team_scrim_requests are ON DELETE SET NULL, so deleting
-- a row still in use silently drops the settings they read back rather than
-- raising.
IF NOT EXISTS (SELECT 1 FROM public.matches WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournaments WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_stages WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.tournament_brackets WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.draft_games WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.league_seasons WHERE match_options_id = target_id)
AND NOT EXISTS (SELECT 1 FROM public.team_scrim_requests WHERE match_options_id = target_id)
THEN
DELETE FROM public.match_options WHERE id = target_id;
END IF;
END;
$$;

-- Shared AFTER DELETE trigger for every table that owns a match_options row.
CREATE OR REPLACE FUNCTION public.tad_cleanup_match_options() RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM public.cleanup_orphaned_match_options(OLD.match_options_id);
RETURN OLD;
END;
$$;
31 changes: 31 additions & 0 deletions hasura/functions/players/banned_until.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- When a player's active ban(s) actually lift, so the UI can say "banned until
-- <date>" instead of a bare "you are banned" with no indication of when it ends.
--
-- NULL means either not banned at all, or banned with no end date. Callers check
-- is_banned() to tell those apart. A permanent ban deliberately wins over any
-- timed one that happens to be active at the same time -- returning the timed
-- date there would tell the player they are free at a point when they are not.
CREATE OR REPLACE FUNCTION public.banned_until(player public.players)
RETURNS timestamptz
LANGUAGE sql
STABLE
AS $$
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM player_sanctions ps
WHERE ps.player_steam_id = player.steam_id
AND ps.type = 'ban'
AND ps.deleted_at IS NULL
AND ps.remove_sanction_date IS NULL
) THEN NULL
ELSE (
SELECT MAX(ps.remove_sanction_date)
FROM player_sanctions ps
WHERE ps.player_steam_id = player.steam_id
AND ps.type = 'ban'
AND ps.deleted_at IS NULL
AND ps.remove_sanction_date > now()
)
END;
$$;
9 changes: 8 additions & 1 deletion hasura/functions/players/get_player_matchmaking_cooldown.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ BEGIN
RETURN NULL;
END IF;

-- Windowed rather than counting every row ever. The escalation used to be
-- forgiven by CleanAbandonedMatches deleting the rows, which made the
-- record too short-lived to recompute elo from -- a leaver penalty applied
-- at match time silently vanished from any later recompute. The window
-- keeps the same forgiveness while the rows stay put.
SELECT COUNT(*) INTO abandoned_count
FROM abandoned_matches
WHERE steam_id = player.steam_id;
WHERE steam_id = player.steam_id
AND abandoned_at > NOW() - INTERVAL '7 days';

IF abandoned_count > 0 THEN
SELECT abandoned_at
INTO last_abandoned_time
FROM abandoned_matches
WHERE steam_id = player.steam_id
AND abandoned_at > NOW() - INTERVAL '7 days'
ORDER BY abandoned_at DESC
LIMIT 1;

Expand Down
21 changes: 21 additions & 0 deletions hasura/functions/players/is_admin_sanctioned.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Whether a player is banned by a real admin, as opposed to a ban issued
-- automatically by the system (which leaves sanctioned_by_steam_id null).
--
-- Same shape as is_banned() and safe to expose to the same roles: a plain
-- boolean, carrying none of the detail on player_sanctions (reason, who issued
-- it, dates) that a select permission on that table would leak.
CREATE OR REPLACE FUNCTION public.is_admin_sanctioned(player public.players)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM player_sanctions ps
WHERE ps.player_steam_id = player.steam_id
AND ps.type = 'ban'
AND ps.deleted_at IS NULL
AND ps.sanctioned_by_steam_id IS NOT NULL
AND (ps.remove_sanction_date IS NULL OR ps.remove_sanction_date > now())
);
$$;
14 changes: 14 additions & 0 deletions hasura/functions/players/is_registered.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Whether this player has ever signed in here, as opposed to a Steam account we
-- only know about (a shadow row created from an imported match or someone
-- else's friend list).
--
-- A plain boolean rather than exposing last_sign_in_at itself, which is only
-- readable by match_organizer -- when a player last signed in is nobody else's
-- business, but whether they have an account here is what the UI needs.
CREATE OR REPLACE FUNCTION public.is_registered(player public.players)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT player.last_sign_in_at IS NOT NULL;
$$;
Loading
Loading