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
50 changes: 50 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: tests

on:
pull_request:
push:
branches:
- "main"
workflow_dispatch:

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
unit:
runs-on: ubuntu-24.04
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Typecheck test suites
run: yarn typecheck:tests
- name: Run unit tests
run: yarn test:unit

sql:
# Boots a throwaway TimescaleDB per suite via testcontainers and drives the
# real HasuraService.setup() migration pipeline, then exercises the
# triggers/functions. Docker is available on GitHub-hosted runners.
runs-on: ubuntu-24.04
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Pre-pull the database image
run: docker pull timescale/timescaledb:latest-pg17
- name: Run SQL tests
run: yarn test:sql
6 changes: 4 additions & 2 deletions hasura/fixtures/fixtures.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1951,7 +1951,9 @@ BEGIN
('Asia', 'Asia', false),
('Australia', 'Australia', false),
('Middle East', 'Middle East', false),
('Africa', 'Africa', false)
('Africa', 'Africa', false),
-- Same spelling as seeds/game-server-node-regions.sql; triggers match on 'Lan'.
('Lan', 'Lan', true)
ON CONFLICT (value) DO NOTHING;

INSERT INTO game_server_nodes (id, public_ip, lan_ip, start_port_range, end_port_range, region, status, enabled, label)
Expand All @@ -1965,7 +1967,7 @@ BEGIN
('fixture-node-australia', '198.51.100.7'::inet, NULL, 27015, 27025, 'Australia', 'Online', true, 'Fixture Australia'),
('fixture-node-middle-east', '198.51.100.8'::inet, NULL, 27015, 27025, 'Middle East', 'Online', true, 'Fixture Middle East'),
('fixture-node-africa', '198.51.100.9'::inet, NULL, 27015, 27025, 'Africa', 'Online', true, 'Fixture Africa'),
('fixture-node-lan', '198.51.100.10'::inet, '192.168.1.100'::inet, 27015, 27025, 'LAN', 'Online', true, 'Fixture LAN')
('fixture-node-lan', '198.51.100.10'::inet, '192.168.1.100'::inet, 27015, 27025, 'Lan', 'Online', true, 'Fixture LAN')
ON CONFLICT (id) DO UPDATE SET
public_ip = EXCLUDED.public_ip,
lan_ip = EXCLUDED.lan_ip,
Expand Down
7 changes: 7 additions & 0 deletions hasura/functions/match/map-veto/verify_map_veto_pick.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ BEGIN
-- Get map pool for the match
pickType := get_map_veto_type(_match);

-- No active step (match not in Veto, or map veto disabled): reject rather
-- than let the NULL propagate through the comparisons below, where every
-- guard would silently pass.
IF pickType IS NULL THEN
RAISE EXCEPTION 'No map veto in progress' USING ERRCODE = '22000';
END IF;

-- Check if the pickType matches the type of the match_map_veto_pick veto
IF match_map_veto_pick.type != pickType THEN
RAISE EXCEPTION 'Expected pick type of %', pickType USING ERRCODE = '22000';
Expand Down
9 changes: 8 additions & 1 deletion hasura/functions/tournaments/assign_team_to_bracket_slot.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ BEGIN

-- Determine the correct slot from feeder ordering:
-- 1. Loser drops (loser_parent_bracket_id) come before winner feeds (parent_bracket_id)
-- 2. Within same type, order by round then match_number
-- 2. Winner-bracket feeders before loser-bracket feeders: the grand final
-- is fed by the WB final and the LB final, which tie on round AND
-- match_number — without this key the ordering is non-deterministic,
-- both feeders resolve to the same slot, and the second finisher
-- overwrites the first (leaving the GF one team short, which then
-- resolves as a bye and crowns the wrong champion).
-- 3. Within same type, order by round then match_number
-- This matches the bracket generation layout.
IF _source_bracket_id IS NOT NULL THEN
SELECT pos INTO slot_position
Expand All @@ -32,6 +38,7 @@ BEGIN
row_number() OVER (
ORDER BY
CASE WHEN f.loser_parent_bracket_id = _target_bracket_id THEN 0 ELSE 1 END,
CASE WHEN f.path = 'LB' THEN 1 ELSE 0 END,
f.round,
f.match_number
) AS pos
Expand Down
84 changes: 61 additions & 23 deletions hasura/functions/tournaments/calculate_tournament_trophies.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,73 @@ BEGIN
RETURN;
END IF;

-- Placement comes from v_team_stage_results, which is the single source of
-- truth for tournament ordering (DE uses elimination round, RR/Swiss/SE
-- When the last stage played a third-place decider, the brackets order the
-- medals directly: final winner/loser take gold/silver, the decider's
-- winner takes bronze. The standings view cannot do this — the runner-up
-- and the third-place winner both finish 1-1, so its wins-based
-- tiebreakers (round ratio, team KDR, finally team id) pick silver and
-- bronze arbitrarily.
SELECT
CASE WHEN fm.winning_lineup_id = fm.lineup_1_id
THEN fb.tournament_team_id_1 ELSE fb.tournament_team_id_2 END,
CASE WHEN fm.winning_lineup_id = fm.lineup_1_id
THEN fb.tournament_team_id_2 ELSE fb.tournament_team_id_1 END,
CASE WHEN tm.winning_lineup_id = tm.lineup_1_id
THEN tb3.tournament_team_id_1 ELSE tb3.tournament_team_id_2 END
INTO _winning_team_id, _runner_up_team_id, _third_team_id
FROM public.tournament_stages ts
JOIN public.tournament_brackets fb
ON fb.tournament_stage_id = ts.id
JOIN public.matches fm ON fm.id = fb.match_id
JOIN public.tournament_brackets tb3
ON tb3.tournament_stage_id = ts.id
AND tb3.round = fb.round
AND tb3.match_number = 2
AND tb3.path = 'WB'
JOIN public.matches tm ON tm.id = tb3.match_id
WHERE ts.id = _final_stage_id
AND ts.type = 'SingleElimination'
AND ts.third_place_match = true
AND fb.path = 'WB'
AND fb.match_number = 1
AND fb.round = (
SELECT MAX(round) FROM public.tournament_brackets
WHERE tournament_stage_id = _final_stage_id AND path = 'WB'
)
AND fm.winning_lineup_id IS NOT NULL
AND tm.winning_lineup_id IS NOT NULL;

-- Otherwise placement comes from v_team_stage_results, the single source
-- of truth for tournament ordering (DE uses elimination round, RR/Swiss/SE
-- use wins-based tiebreakers). The view's `placement` column shares ranks
-- on ties, which lets us suppress the bronze when 3rd is contested
-- (e.g. SingleElim with no third_place_match → both SF losers tied).
-- Separate scalar selects keep this off `min(uuid)`, which Postgres lacks.
SELECT tournament_team_id INTO _winning_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 1
ORDER BY tournament_team_id::text
LIMIT 1;
IF _winning_team_id IS NULL THEN
SELECT tournament_team_id INTO _winning_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 1
ORDER BY tournament_team_id::text
LIMIT 1;

SELECT tournament_team_id INTO _runner_up_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 2
ORDER BY tournament_team_id::text
LIMIT 1;
SELECT tournament_team_id INTO _runner_up_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 2
ORDER BY tournament_team_id::text
LIMIT 1;

SELECT tournament_team_id INTO _third_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 3
AND (
SELECT COUNT(*) FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id AND placement = 3
) = 1
LIMIT 1;
SELECT tournament_team_id INTO _third_team_id
FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id
AND placement = 3
AND (
SELECT COUNT(*) FROM public.v_team_stage_results
WHERE tournament_stage_id = _final_stage_id AND placement = 3
) = 1
LIMIT 1;
END IF;

_award_third := _third_team_id IS NOT NULL;

Expand Down
59 changes: 34 additions & 25 deletions hasura/functions/tournaments/find_adjacent_swiss_team.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@ AS $$
DECLARE
adjacent_team_id uuid;
pool_record RECORD;
preferred_pool RECORD;
fallback_pool RECORD;
-- Track candidate pools with scalars: a RECORD assigned NULL stays
-- "not assigned" in plpgsql, and IS [NOT] NULL on it raises
-- 'record is not assigned yet' the first time an odd pool needs pairing.
preferred_team_ids uuid[];
preferred_wins int;
preferred_losses int;
fallback_team_ids uuid[];
fallback_wins int;
fallback_losses int;
BEGIN
-- Strategy: When pairing with adjacent pool, prefer:
-- 1. Worst result with same wins (more losses) - e.g., if we have (1W, 1L), prefer (1W, 2L)
-- 2. Best result with same losses (more wins) - e.g., if we have (1W, 1L), prefer (2W, 1L)
-- This pairs worst 1-win teams with best 1-loss teams

preferred_pool := NULL;
fallback_pool := NULL;


-- Check all pools for adjacent ones
FOR pool_record IN
FOR pool_record IN
SELECT * FROM get_swiss_team_pools(_stage_id, _exclude_team_ids)
WHERE (wins, losses) != (_wins, _losses) -- Different pool
AND team_count > 0 -- Has teams
Expand All @@ -31,44 +35,49 @@ BEGIN
-- This means: (wins_diff = 1 AND losses_diff = 0) OR (wins_diff = 0 AND losses_diff = 1)
IF (ABS(pool_record.wins - _wins) = 1 AND pool_record.losses = _losses) OR
(pool_record.wins = _wins AND ABS(pool_record.losses - _losses) = 1) THEN

-- Priority 1: Same wins, more losses (worst result with same wins)
-- e.g., if we have (1W, 1L), prefer (1W, 2L)
IF pool_record.wins = _wins AND pool_record.losses > _losses THEN
IF preferred_pool IS NULL OR pool_record.losses > preferred_pool.losses THEN
preferred_pool := pool_record;
IF preferred_team_ids IS NULL OR pool_record.losses > preferred_losses THEN
preferred_team_ids := pool_record.team_ids;
preferred_wins := pool_record.wins;
preferred_losses := pool_record.losses;
END IF;
-- Priority 2: Same losses, more wins (best result with same losses)
-- e.g., if we have (1W, 1L), prefer (2W, 1L)
ELSIF pool_record.losses = _losses AND pool_record.wins > _wins THEN
IF preferred_pool IS NULL OR pool_record.wins > preferred_pool.wins THEN
preferred_pool := pool_record;
IF preferred_team_ids IS NULL OR pool_record.wins > preferred_wins THEN
preferred_team_ids := pool_record.team_ids;
preferred_wins := pool_record.wins;
preferred_losses := pool_record.losses;
END IF;
-- Fallback: Other adjacent pools (same wins fewer losses, or same losses fewer wins)
ELSE
IF fallback_pool IS NULL THEN
fallback_pool := pool_record;
IF fallback_team_ids IS NULL THEN
fallback_team_ids := pool_record.team_ids;
fallback_wins := pool_record.wins;
fallback_losses := pool_record.losses;
END IF;
END IF;
END IF;
END LOOP;

-- Use preferred pool if available, otherwise fallback
IF preferred_pool IS NOT NULL AND array_length(preferred_pool.team_ids, 1) > 0 THEN
adjacent_team_id := preferred_pool.team_ids[1];
RAISE NOTICE ' Found preferred adjacent team % from pool (W:% L:%) for pool (W:% L:%)',
adjacent_team_id, preferred_pool.wins, preferred_pool.losses, _wins, _losses;
IF preferred_team_ids IS NOT NULL AND array_length(preferred_team_ids, 1) > 0 THEN
adjacent_team_id := preferred_team_ids[1];
RAISE NOTICE ' Found preferred adjacent team % from pool (W:% L:%) for pool (W:% L:%)',
adjacent_team_id, preferred_wins, preferred_losses, _wins, _losses;
RETURN adjacent_team_id;
ELSIF fallback_pool IS NOT NULL AND array_length(fallback_pool.team_ids, 1) > 0 THEN
adjacent_team_id := fallback_pool.team_ids[1];
RAISE NOTICE ' Found fallback adjacent team % from pool (W:% L:%) for pool (W:% L:%)',
adjacent_team_id, fallback_pool.wins, fallback_pool.losses, _wins, _losses;
ELSIF fallback_team_ids IS NOT NULL AND array_length(fallback_team_ids, 1) > 0 THEN
adjacent_team_id := fallback_team_ids[1];
RAISE NOTICE ' Found fallback adjacent team % from pool (W:% L:%) for pool (W:% L:%)',
adjacent_team_id, fallback_wins, fallback_losses, _wins, _losses;
RETURN adjacent_team_id;
END IF;

-- If no adjacent pool found, return NULL (shouldn't happen in practice)
RAISE WARNING 'No adjacent pool found for (W:% L:%)', _wins, _losses;
RETURN NULL;
END;
$$;

3 changes: 3 additions & 0 deletions hasura/functions/tournaments/reset_tournament_match.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ AS $$
DECLARE
slot_position int;
BEGIN
-- Same feeder ordering as assign_team_to_bracket_slot, including the
-- WB-before-LB key that disambiguates the grand final's two feeders.
SELECT ranked.pos INTO slot_position
FROM (
SELECT f.id,
row_number() OVER (
ORDER BY
CASE WHEN f.loser_parent_bracket_id = _target_bracket_id THEN 0 ELSE 1 END,
CASE WHEN f.path = 'LB' THEN 1 ELSE 0 END,
f.round,
f.match_number
) AS pos
Expand Down
9 changes: 9 additions & 0 deletions hasura/functions/tournaments/update_tournament_bracket.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ BEGIN
RETURN;
END IF;

-- Serialize result processing per stage. Two matches reported at the same
-- moment otherwise interleave row locks in opposite orders (own bracket ->
-- shared parent -> standings cache -> start-time sweep) and deadlock;
-- Postgres then aborts one transaction and that match result is lost.
-- Locking the stage row first gives every reporter the same lock order.
PERFORM 1 FROM tournament_stages
WHERE id = bracket.tournament_stage_id
FOR UPDATE;

IF match.winning_lineup_id = match.lineup_1_id THEN
winning_team_id = bracket.tournament_team_id_1;
losing_team_id = bracket.tournament_team_id_2;
Expand Down
14 changes: 9 additions & 5 deletions hasura/triggers/player_kills.sql
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ BEGIN
SET kill_count = player_kills_by_weapon.kill_count + 1;


-- attacker: kills + headshots
INSERT INTO player_stats (player_steam_id, kills, headshots)
-- attacker: kills + headshots. The percentage must also be set on the
-- very first kill (the insert path), not just on conflict, or a player's
-- opening headshot reads as 0% until their second kill.
INSERT INTO player_stats (player_steam_id, kills, headshots, headshot_percentage)
VALUES (
NEW.attacker_steam_id,
1,
CASE WHEN NEW.headshot THEN 1 ELSE 0 END
CASE WHEN NEW.headshot THEN 1 ELSE 0 END,
CASE WHEN NEW.headshot THEN 1 ELSE 0 END::float
)
ON CONFLICT (player_steam_id)
DO UPDATE SET
Expand All @@ -43,12 +46,13 @@ BEGIN
FROM matches m WHERE m.id = NEW.match_id;

IF _season_id IS NOT NULL THEN
INSERT INTO player_season_stats (player_steam_id, season_id, kills, headshots)
INSERT INTO player_season_stats (player_steam_id, season_id, kills, headshots, headshot_percentage)
VALUES (
NEW.attacker_steam_id,
_season_id,
1,
CASE WHEN NEW.headshot THEN 1 ELSE 0 END
CASE WHEN NEW.headshot THEN 1 ELSE 0 END,
CASE WHEN NEW.headshot THEN 1 ELSE 0 END::float
)
ON CONFLICT (player_steam_id, season_id)
DO UPDATE SET
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:unit": "jest --testPathPatterns 'src/.*\\.spec\\.ts$' --runInBand",
"test:sql": "jest --testPathPatterns 'test/.*\\.spec\\.ts$' --runInBand",
"typecheck:tests": "tsc -p tsconfig.spec.json",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
Expand Down
2 changes: 2 additions & 0 deletions src/postgres/postgres.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ export class PostgresService {
bindings?: Array<
| string
| number
| boolean
| Date
| bigint
| Buffer
| null
| Array<string>
| Array<number>
| Array<Date>
Expand Down
Loading
Loading