From 37ced8e251c0d0ede64be7009ab238af997e8185 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sun, 28 Dec 2025 16:48:43 -0500 Subject: [PATCH 01/19] feature: swiss --- hasura/enums/tournament-stage-types.sql | 1 + .../tournaments/advance_swiss_teams.sql | 80 ++++++++++++++ .../check_swiss_round_complete.sql | 26 +++++ .../tournaments/create_next_swiss_round.sql | 72 +++++++++++++ .../tournaments/find_adjacent_swiss_team.sql | 74 +++++++++++++ .../tournaments/get_swiss_team_pools.sql | 27 +++++ .../tournaments/pair_swiss_teams.sql | 101 ++++++++++++++++++ hasura/functions/tournaments/seed_stage.sql | 62 ++++++++++- .../tournaments/tournament_has_min_teams.sql | 18 +++- .../tournaments/update_tournament_bracket.sql | 11 ++ .../tournaments/update_tournament_stages.sql | 63 +++++++++++ hasura/triggers/tournament_stages.sql | 24 ++++- 12 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 hasura/functions/tournaments/advance_swiss_teams.sql create mode 100644 hasura/functions/tournaments/check_swiss_round_complete.sql create mode 100644 hasura/functions/tournaments/create_next_swiss_round.sql create mode 100644 hasura/functions/tournaments/find_adjacent_swiss_team.sql create mode 100644 hasura/functions/tournaments/get_swiss_team_pools.sql create mode 100644 hasura/functions/tournaments/pair_swiss_teams.sql diff --git a/hasura/enums/tournament-stage-types.sql b/hasura/enums/tournament-stage-types.sql index a88a1d51..4b1c1470 100644 --- a/hasura/enums/tournament-stage-types.sql +++ b/hasura/enums/tournament-stage-types.sql @@ -1,4 +1,5 @@ insert into e_tournament_stage_types ("value", "description") values + ('Swiss', 'Swiss'), ('RoundRobin', 'Round Robin'), ('SingleElimination', 'Single Elimination'), ('DoubleElimination', 'Double Elimination') diff --git a/hasura/functions/tournaments/advance_swiss_teams.sql b/hasura/functions/tournaments/advance_swiss_teams.sql new file mode 100644 index 00000000..5704ce9f --- /dev/null +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -0,0 +1,80 @@ +CREATE OR REPLACE FUNCTION public.advance_swiss_teams(_stage_id uuid) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + stage_record RECORD; + next_stage_id uuid; + advanced_teams uuid[]; + eliminated_count int; +BEGIN + -- Get stage information + SELECT ts.tournament_id, ts."order" + INTO stage_record + FROM tournament_stages ts + WHERE ts.id = _stage_id; + + IF stage_record IS NULL THEN + RAISE EXCEPTION 'Stage % not found', _stage_id; + END IF; + + -- Get teams with 3 wins (should advance to next stage) + SELECT array_agg(vtsr.tournament_team_id) + INTO advanced_teams + FROM v_team_stage_results vtsr + WHERE vtsr.tournament_stage_id = _stage_id + AND vtsr.wins >= 3; + + -- Count teams with 3 losses (eliminated) + SELECT COUNT(*) + INTO eliminated_count + FROM v_team_stage_results vtsr + WHERE vtsr.tournament_stage_id = _stage_id + AND vtsr.losses >= 3; + + RAISE NOTICE '=== Processing Swiss Advancement ==='; + RAISE NOTICE 'Teams with 3+ wins: %', COALESCE(array_length(advanced_teams, 1), 0); + RAISE NOTICE 'Teams with 3+ losses: %', eliminated_count; + + -- Check if stage is complete (all teams have 3+ wins or 3+ losses) + DECLARE + remaining_teams int; + stage_complete boolean; + BEGIN + SELECT COUNT(*) + INTO remaining_teams + FROM v_team_stage_results vtsr + WHERE vtsr.tournament_stage_id = _stage_id + AND vtsr.wins < 3 + AND vtsr.losses < 3; + + stage_complete := (remaining_teams = 0); + + -- Advance teams to next stage if it exists + IF advanced_teams IS NOT NULL AND array_length(advanced_teams, 1) > 0 THEN + SELECT ts.id INTO next_stage_id + FROM tournament_stages ts + WHERE ts.tournament_id = stage_record.tournament_id + AND ts."order" = stage_record."order" + 1; + + IF next_stage_id IS NOT NULL THEN + RAISE NOTICE 'Advancing % teams to next stage', array_length(advanced_teams, 1); + + -- If stage is complete, seed the next stage + IF stage_complete THEN + RAISE NOTICE 'Swiss stage complete, seeding next stage'; + PERFORM seed_stage(next_stage_id); + END IF; + ELSE + RAISE NOTICE 'No next stage found - teams have won the tournament'; + END IF; + END IF; + + -- Teams with 3 losses are eliminated (they stop playing) + -- This is handled by filtering them out in get_swiss_team_pools + END; + + RAISE NOTICE '=== Swiss Advancement Complete ==='; +END; +$$; + diff --git a/hasura/functions/tournaments/check_swiss_round_complete.sql b/hasura/functions/tournaments/check_swiss_round_complete.sql new file mode 100644 index 00000000..ef0c4e1c --- /dev/null +++ b/hasura/functions/tournaments/check_swiss_round_complete.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION public.check_swiss_round_complete(_stage_id uuid, _round int) +RETURNS boolean +LANGUAGE plpgsql +AS $$ +DECLARE + unfinished_count int; + total_matches int; +BEGIN + -- Count unfinished matches in the specified round + SELECT COUNT(*) INTO unfinished_count + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = _stage_id + AND tb.round = _round + AND tb.finished = false; + + -- Count total matches in the round + SELECT COUNT(*) INTO total_matches + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = _stage_id + AND tb.round = _round; + + -- Round is complete if all matches are finished (or no matches exist) + RETURN unfinished_count = 0 AND total_matches > 0; +END; +$$; + diff --git a/hasura/functions/tournaments/create_next_swiss_round.sql b/hasura/functions/tournaments/create_next_swiss_round.sql new file mode 100644 index 00000000..0bd992c9 --- /dev/null +++ b/hasura/functions/tournaments/create_next_swiss_round.sql @@ -0,0 +1,72 @@ +CREATE OR REPLACE FUNCTION public.create_next_swiss_round(_stage_id uuid) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + current_round int; + next_round int; + pool_record RECORD; + total_teams int; +BEGIN + -- Get current maximum round + SELECT COALESCE(MAX(tb.round), 0) INTO current_round + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = _stage_id; + + next_round := current_round + 1; + + RAISE NOTICE '=== Creating Swiss Round % ===', next_round; + + -- Get teams grouped by W/L record + -- Process pools in order, handling odd numbers by pairing with adjacent pools + DECLARE + adjacent_team_id uuid; + used_teams uuid[]; + BEGIN + used_teams := ARRAY[]::uuid[]; + + FOR pool_record IN + SELECT * FROM get_swiss_team_pools(_stage_id, used_teams) + ORDER BY wins DESC, losses ASC + LOOP + total_teams := pool_record.team_count; + + -- Skip pools with 0 teams + IF total_teams = 0 THEN + CONTINUE; + END IF; + + RAISE NOTICE ' Pool: W:% L:% Teams:%', + pool_record.wins, pool_record.losses, total_teams; + + -- Handle odd number of teams by finding adjacent team + adjacent_team_id := NULL; + IF total_teams % 2 != 0 THEN + -- Find a team from an adjacent pool (excluding already used teams) + adjacent_team_id := find_adjacent_swiss_team(_stage_id, pool_record.wins, pool_record.losses, used_teams); + + IF adjacent_team_id IS NULL THEN + RAISE EXCEPTION 'Odd number of teams in pool (wins: %, losses: %, count: %) and no available adjacent team found', + pool_record.wins, pool_record.losses, total_teams; + END IF; + + -- Mark adjacent team as used + used_teams := used_teams || adjacent_team_id; + END IF; + + -- Pair teams within this pool (with adjacent team if needed) + PERFORM pair_swiss_teams( + _stage_id, + next_round, + pool_record.wins, + pool_record.losses, + pool_record.team_ids, + adjacent_team_id + ); + END LOOP; + END; + + RAISE NOTICE '=== Swiss Round % Created ===', next_round; +END; +$$; + diff --git a/hasura/functions/tournaments/find_adjacent_swiss_team.sql b/hasura/functions/tournaments/find_adjacent_swiss_team.sql new file mode 100644 index 00000000..74070068 --- /dev/null +++ b/hasura/functions/tournaments/find_adjacent_swiss_team.sql @@ -0,0 +1,74 @@ +CREATE OR REPLACE FUNCTION public.find_adjacent_swiss_team( + _stage_id uuid, + _wins int, + _losses int, + _exclude_team_ids uuid[] DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +AS $$ +DECLARE + adjacent_team_id uuid; + pool_record RECORD; + preferred_pool RECORD; + fallback_pool RECORD; +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 + SELECT * FROM get_swiss_team_pools(_stage_id, _exclude_team_ids) + WHERE (wins, losses) != (_wins, _losses) -- Different pool + AND team_count > 0 -- Has teams + LOOP + -- We want pools with exactly one win/loss difference + -- 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; + 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; + 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; + 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; + 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; + 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; +$$; + diff --git a/hasura/functions/tournaments/get_swiss_team_pools.sql b/hasura/functions/tournaments/get_swiss_team_pools.sql new file mode 100644 index 00000000..b8f135d4 --- /dev/null +++ b/hasura/functions/tournaments/get_swiss_team_pools.sql @@ -0,0 +1,27 @@ +CREATE OR REPLACE FUNCTION public.get_swiss_team_pools(_stage_id uuid, _exclude_team_ids uuid[] DEFAULT NULL) +RETURNS TABLE( + wins int, + losses int, + team_ids uuid[], + team_count int +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + vtsr.wins, + vtsr.losses, + array_agg(vtsr.tournament_team_id ORDER BY vtsr.tournament_team_id) as team_ids, + COUNT(*)::int as team_count + FROM v_team_stage_results vtsr + WHERE vtsr.tournament_stage_id = _stage_id + AND vtsr.wins < 3 -- Not yet advanced (3 wins = advance) + AND vtsr.losses < 3 -- Not yet eliminated (3 losses = eliminate) + AND (_exclude_team_ids IS NULL OR NOT (vtsr.tournament_team_id = ANY(_exclude_team_ids))) + GROUP BY vtsr.wins, vtsr.losses + HAVING COUNT(*) > 0 + ORDER BY vtsr.wins DESC, vtsr.losses ASC; +END; +$$; + diff --git a/hasura/functions/tournaments/pair_swiss_teams.sql b/hasura/functions/tournaments/pair_swiss_teams.sql new file mode 100644 index 00000000..a2add935 --- /dev/null +++ b/hasura/functions/tournaments/pair_swiss_teams.sql @@ -0,0 +1,101 @@ +CREATE OR REPLACE FUNCTION public.pair_swiss_teams( + _stage_id uuid, + _round int, + _wins int, + _losses int, + _team_ids uuid[], + _adjacent_team_id uuid DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + team_count int; + bracket_order int[]; + match_number int; + i int; + seed_1_idx int; + seed_2_idx int; + team_1_id uuid; + team_2_id uuid; + group_num int; + teams_to_pair uuid[]; + pairing_count int; +BEGIN + team_count := array_length(_team_ids, 1); + + -- Get group number from stage (Swiss uses single group, so should be 1) + SELECT COALESCE(ts.groups, 1) INTO group_num + FROM tournament_stages ts + WHERE ts.id = _stage_id; + + -- Ensure group_num is 1 for Swiss + IF group_num IS NULL OR group_num != 1 THEN + group_num := 1; + END IF; + + -- Handle odd number of teams by pairing with adjacent pool team + IF team_count % 2 != 0 THEN + IF _adjacent_team_id IS NULL THEN + -- Try to find an adjacent team + _adjacent_team_id := find_adjacent_swiss_team(_stage_id, _wins, _losses); + END IF; + + IF _adjacent_team_id IS NOT NULL THEN + -- Add adjacent team to the pool for pairing + teams_to_pair := _team_ids || _adjacent_team_id; + RAISE NOTICE ' Pool (W:% L:%) has odd number of teams (%), pairing with adjacent team %', + _wins, _losses, team_count, _adjacent_team_id; + ELSE + RAISE EXCEPTION 'Odd number of teams in pool (wins: %, losses: %, count: %) and no adjacent team found', + _wins, _losses, team_count; + END IF; + ELSE + teams_to_pair := _team_ids; + END IF; + + pairing_count := array_length(teams_to_pair, 1); + + -- Generate bracket order for pairing teams + bracket_order := generate_bracket_order(pairing_count); + + -- Pair teams using bracket order + match_number := 1; + FOR i IN 1..(pairing_count / 2) LOOP + -- Get indices from bracket order (1-based) + seed_1_idx := bracket_order[(i - 1) * 2 + 1]; + seed_2_idx := bracket_order[(i - 1) * 2 + 2]; + + -- Get team IDs (bracket_order uses 1-based indexing, array uses 1-based) + team_1_id := teams_to_pair[seed_1_idx]; + team_2_id := teams_to_pair[seed_2_idx]; + + -- Create match bracket + INSERT INTO tournament_brackets ( + round, + tournament_stage_id, + match_number, + "group", + tournament_team_id_1, + tournament_team_id_2, + path + ) + VALUES ( + _round, + _stage_id, + match_number, + group_num, + team_1_id, + team_2_id, + 'WB' + ); + + RAISE NOTICE ' Created match %: Team % vs Team % (W:% L:%)', + match_number, team_1_id, team_2_id, _wins, _losses; + + match_number := match_number + 1; + END LOOP; +END; +$$; + + diff --git a/hasura/functions/tournaments/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index 74af2426..0ae7d1bf 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -29,8 +29,64 @@ BEGIN teams_assigned_count := 0; RAISE NOTICE '--- Processing Stage % (groups: %, type: %) ---', stage."order", stage.groups, stage.type; - - IF stage.type = 'RoundRobin' THEN + + IF stage.type = 'Swiss' THEN + -- Process first round brackets which have seed positions set + FOR bracket IN + SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = stage.id + AND tb.round = 1 + AND COALESCE(tb.path, 'WB') = 'WB' + AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) + ORDER BY tb.match_number ASC + LOOP + team_1_id := NULL; + team_2_id := NULL; + team_1_seed_val := bracket.team_1_seed; + team_2_seed_val := bracket.team_2_seed; + + -- Find team with matching seed for position 1 + IF team_1_seed_val IS NOT NULL THEN + SELECT id INTO team_1_id + FROM tournament_teams + WHERE tournament_id = stage.tournament_id + AND eligible_at IS NOT NULL + AND seed = team_1_seed_val + LIMIT 1; + + IF team_1_id IS NOT NULL THEN + teams_assigned_count := teams_assigned_count + 1; + END IF; + END IF; + + -- Find team with matching seed for position 2 + IF team_2_seed_val IS NOT NULL THEN + SELECT id INTO team_2_id + FROM tournament_teams + WHERE tournament_id = stage.tournament_id + AND eligible_at IS NOT NULL + AND seed = team_2_seed_val + LIMIT 1; + + IF team_2_id IS NOT NULL THEN + teams_assigned_count := teams_assigned_count + 1; + END IF; + END IF; + + -- Update bracket with teams (Swiss should never have byes) + UPDATE tournament_brackets + SET tournament_team_id_1 = team_1_id, + tournament_team_id_2 = team_2_id, + bye = false + WHERE id = bracket.id; + + RAISE NOTICE ' Swiss Round 1 Match %: Seed % (team %) vs Seed % (team %)', + bracket.match_number, + team_1_seed_val, team_1_id, + team_2_seed_val, team_2_id; + END LOOP; + ELSIF stage.type = 'RoundRobin' THEN -- Process all RoundRobin brackets which have seed positions set FOR bracket IN SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed @@ -100,7 +156,7 @@ BEGIN team_1_seed_val := bracket.team_1_seed; team_2_seed_val := bracket.team_2_seed; - IF previous_stage.id IS NOT NULL AND previous_stage.type = 'RoundRobin' THEN + IF previous_stage.id IS NOT NULL AND (previous_stage.type = 'RoundRobin' OR previous_stage.type = 'Swiss') THEN IF team_1_seed_val IS NOT NULL THEN SELECT tournament_team_id INTO team_1_id FROM v_team_stage_results diff --git a/hasura/functions/tournaments/tournament_has_min_teams.sql b/hasura/functions/tournaments/tournament_has_min_teams.sql index 51dd70d7..2a2bff46 100644 --- a/hasura/functions/tournaments/tournament_has_min_teams.sql +++ b/hasura/functions/tournaments/tournament_has_min_teams.sql @@ -6,12 +6,20 @@ DECLARE total_teams int := 0; tournament_min_teams int := 0; tournament_status text; + first_stage_type text; BEGIN -- Get tournament status for context SELECT status INTO tournament_status FROM tournaments WHERE id = tournament.id; + -- Get first stage type + SELECT ts.type INTO first_stage_type + FROM tournament_stages ts + WHERE ts.tournament_id = tournament.id + AND ts.order = 1 + LIMIT 1; + -- Get minimum teams required for stage 1 SELECT SUM(ts.min_teams) into tournament_min_teams @@ -30,7 +38,15 @@ BEGIN RAISE NOTICE 'Tournament % (status: %): %/% teams (actual/required)', tournament.id, tournament_status, total_teams, tournament_min_teams; + -- Check minimum teams requirement + IF tournament_min_teams > total_teams THEN + RETURN false; + END IF; + + -- Note: Swiss tournaments can handle odd numbers by pairing with adjacent pools + -- No strict even number requirement needed + -- Return validation result - RETURN tournament_min_teams <= total_teams; + RETURN true; END; $$; diff --git a/hasura/functions/tournaments/update_tournament_bracket.sql b/hasura/functions/tournaments/update_tournament_bracket.sql index 8d4a500f..32c21180 100644 --- a/hasura/functions/tournaments/update_tournament_bracket.sql +++ b/hasura/functions/tournaments/update_tournament_bracket.sql @@ -52,6 +52,17 @@ BEGIN IF check_round_robin_stage_complete(bracket.tournament_stage_id) THEN PERFORM advance_round_robin_teams(bracket.tournament_stage_id); END IF; + ELSIF stage_type = 'Swiss' THEN + -- Check if current round is complete + IF check_swiss_round_complete(bracket.tournament_stage_id, bracket.round) THEN + RAISE NOTICE 'Swiss round % complete, creating next round', bracket.round; + + -- Advance/eliminate teams based on 3 wins/losses + PERFORM advance_swiss_teams(bracket.tournament_stage_id); + + -- Create next round matches + PERFORM create_next_swiss_round(bracket.tournament_stage_id); + END IF; END IF; PERFORM check_tournament_finished(tournament_id); diff --git a/hasura/functions/tournaments/update_tournament_stages.sql b/hasura/functions/tournaments/update_tournament_stages.sql index f919d074..a0902041 100644 --- a/hasura/functions/tournaments/update_tournament_stages.sql +++ b/hasura/functions/tournaments/update_tournament_stages.sql @@ -129,6 +129,69 @@ BEGIN CONTINUE; END IF; + -- For Swiss tournaments, only create first round (subsequent rounds created dynamically) + IF stage_type = 'Swiss' THEN + RAISE NOTICE 'Stage % : Swiss detected, creating first round only', stage."order"; + + -- First round requires even number (all teams start at 0-0, same pool) + IF effective_teams % 2 != 0 THEN + RAISE EXCEPTION 'Swiss tournament first round must have an even number of teams. Current: %', effective_teams; + END IF; + + -- Note: Odd numbers in later rounds are handled by pairing with adjacent pools + + -- Generate bracket order for first round pairing + bracket_order := generate_bracket_order(effective_teams); + RAISE NOTICE 'Generated bracket order for Swiss first round: %', bracket_order; + + -- Create first round matches using seed-based pairing + DECLARE + matches_in_round int; + match_idx int; + group_num int; + swiss_bracket_idx int; + BEGIN + -- Swiss uses single group (groups = 1) + group_num := 1; + matches_in_round := effective_teams / 2; + swiss_bracket_idx := 0; + + FOR match_idx IN 1..matches_in_round LOOP + -- Get seed positions from bracket order + IF swiss_bracket_idx * 2 + 1 <= array_length(bracket_order, 1) THEN + seed_1 := bracket_order[swiss_bracket_idx * 2 + 1]; + ELSE + seed_1 := NULL; + END IF; + + IF swiss_bracket_idx * 2 + 2 <= array_length(bracket_order, 1) THEN + seed_2 := bracket_order[swiss_bracket_idx * 2 + 2]; + ELSE + seed_2 := NULL; + END IF; + + -- Set to NULL if seed position is beyond effective_teams + IF seed_1 IS NOT NULL AND seed_1 > effective_teams THEN + seed_1 := NULL; + END IF; + IF seed_2 IS NOT NULL AND seed_2 > effective_teams THEN + seed_2 := NULL; + END IF; + + INSERT INTO tournament_brackets (round, tournament_stage_id, match_number, "group", team_1_seed, team_2_seed, path) + VALUES (1, stage.id, match_idx, group_num, seed_1, seed_2, 'WB') + RETURNING id INTO new_id; + + RAISE NOTICE ' => Created Swiss round 1 match %: id=%, seeds: % vs %', + match_idx, new_id, seed_1, seed_2; + + swiss_bracket_idx := swiss_bracket_idx + 1; + END LOOP; + END; + + CONTINUE; + END IF; + -- For double elimination, calculate rounds based on teams needed -- Standard double elim produces 2 teams (WB champion + LB champion) -- If we need more than 2, we stop earlier to get more teams from earlier rounds diff --git a/hasura/triggers/tournament_stages.sql b/hasura/triggers/tournament_stages.sql index a8ddef83..cb072f51 100644 --- a/hasura/triggers/tournament_stages.sql +++ b/hasura/triggers/tournament_stages.sql @@ -27,8 +27,18 @@ BEGIN _min_teams := NEW.min_teams; + -- Validate Swiss tournament requirements + IF NEW.type = 'Swiss' THEN + -- Swiss tournaments use single group + IF NEW.groups IS NOT NULL AND NEW.groups != 1 THEN + RAISE EXCEPTION 'Swiss tournaments must use a single group (groups = 1). Current: %', + NEW.groups USING ERRCODE = '22000'; + END IF; + -- Note: Odd numbers are handled by pairing with adjacent pools + END IF; + -- Validate first stage minimum teams (must be at least 4 * number of groups) - IF current_order = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 THEN + IF current_order = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 AND NEW.type != 'Swiss' THEN IF NEW.min_teams < 4 * NEW.groups THEN RAISE EXCEPTION 'First stage must have at least % teams given % groups (minimum 4 teams per group)', 4 * NEW.groups, NEW.groups USING ERRCODE = '22000'; @@ -181,8 +191,18 @@ BEGIN END IF; END IF; + -- Validate Swiss tournament requirements + IF NEW.type = 'Swiss' THEN + -- Swiss tournaments use single group + IF NEW.groups IS NOT NULL AND NEW.groups != 1 THEN + RAISE EXCEPTION 'Swiss tournaments must use a single group (groups = 1). Current: %', + NEW.groups USING ERRCODE = '22000'; + END IF; + -- Note: Odd numbers are handled by pairing with adjacent pools + END IF; + -- Validate first stage minimum teams (must be at least 4 * number of groups) - IF NEW."order" = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 THEN + IF NEW."order" = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 AND NEW.type != 'Swiss' THEN IF NEW.min_teams < 4 * NEW.groups THEN RAISE EXCEPTION 'First stage must have at least % teams given % groups (minimum 4 teams per group)', 4 * NEW.groups, NEW.groups USING ERRCODE = '22000'; From b977a8415b361c236f61dd91840233a78bba66bd Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sun, 28 Dec 2025 17:45:34 -0500 Subject: [PATCH 02/19] wip --- generated/schema.graphql | 3 + generated/schema.ts | 5 +- .../tournaments/advance_swiss_teams.sql | 6 +- .../advance_swiss_teams_to_next_stage.sql | 38 +++ .../assign_teams_to_swiss_pools.sql | 134 +++++++++ .../tournaments/generate_swiss_bracket.sql | 279 ++++++++++++++++++ hasura/functions/tournaments/seed_stage.sql | 6 +- .../tournaments/update_tournament_bracket.sql | 6 +- .../tournaments/update_tournament_stages.sql | 56 +--- 9 files changed, 471 insertions(+), 62 deletions(-) create mode 100644 hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql create mode 100644 hasura/functions/tournaments/assign_teams_to_swiss_pools.sql create mode 100644 hasura/functions/tournaments/generate_swiss_bracket.sql diff --git a/generated/schema.graphql b/generated/schema.graphql index 01da6ef0..67e0ac80 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -5365,6 +5365,9 @@ enum e_tournament_stage_types_enum { """Single Elimination""" SingleElimination + + """Swiss""" + Swiss } """ diff --git a/generated/schema.ts b/generated/schema.ts index 18fd5c18..a6a44689 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -1969,7 +1969,7 @@ export interface e_tournament_stage_types_aggregate_fields { /** unique or primary key constraints on table "e_tournament_stage_types" */ export type e_tournament_stage_types_constraint = 'e_tournament_stage_types_pkey' -export type e_tournament_stage_types_enum = 'DoubleElimination' | 'RoundRobin' | 'SingleElimination' +export type e_tournament_stage_types_enum = 'DoubleElimination' | 'RoundRobin' | 'SingleElimination' | 'Swiss' /** aggregate max on columns */ @@ -49208,7 +49208,8 @@ export const enumETournamentStageTypesConstraint = { export const enumETournamentStageTypesEnum = { DoubleElimination: 'DoubleElimination' as const, RoundRobin: 'RoundRobin' as const, - SingleElimination: 'SingleElimination' as const + SingleElimination: 'SingleElimination' as const, + Swiss: 'Swiss' as const } export const enumETournamentStageTypesSelectColumn = { diff --git a/hasura/functions/tournaments/advance_swiss_teams.sql b/hasura/functions/tournaments/advance_swiss_teams.sql index 5704ce9f..3f0988a3 100644 --- a/hasura/functions/tournaments/advance_swiss_teams.sql +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -60,10 +60,10 @@ BEGIN IF next_stage_id IS NOT NULL THEN RAISE NOTICE 'Advancing % teams to next stage', array_length(advanced_teams, 1); - -- If stage is complete, seed the next stage + -- If stage is complete, advance teams to next stage (similar to RoundRobin) IF stage_complete THEN - RAISE NOTICE 'Swiss stage complete, seeding next stage'; - PERFORM seed_stage(next_stage_id); + RAISE NOTICE 'Swiss stage complete, advancing teams to next stage'; + PERFORM advance_swiss_teams_to_next_stage(_stage_id); END IF; ELSE RAISE NOTICE 'No next stage found - teams have won the tournament'; diff --git a/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql b/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql new file mode 100644 index 00000000..89d02027 --- /dev/null +++ b/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql @@ -0,0 +1,38 @@ +CREATE OR REPLACE FUNCTION public.advance_swiss_teams_to_next_stage(_stage_id uuid) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + current_stage RECORD; + next_stage_id uuid; +BEGIN + -- Get current stage information + SELECT ts.tournament_id, ts."order", ts.groups, ts.max_teams + INTO current_stage + FROM tournament_stages ts + WHERE ts.id = _stage_id; + + IF current_stage IS NULL THEN + RAISE EXCEPTION 'Stage % not found', _stage_id; + END IF; + + -- Find next stage + SELECT ts.id, ts.max_teams + INTO next_stage_id + FROM tournament_stages ts + WHERE ts.tournament_id = current_stage.tournament_id + AND ts."order" = current_stage."order" + 1; + + IF next_stage_id IS NULL THEN + RAISE NOTICE 'No next stage found for Swiss stage %', _stage_id; + RETURN; + END IF; + + RAISE NOTICE 'Advancing teams from Swiss stage % to next stage %', _stage_id, next_stage_id; + + -- Seed the next stage (teams with 3+ wins will be selected from v_team_stage_results) + -- This works similarly to RoundRobin - teams are ordered by their results in v_team_stage_results + PERFORM seed_stage(next_stage_id); +END; +$$; + diff --git a/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql new file mode 100644 index 00000000..ca5ba13f --- /dev/null +++ b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql @@ -0,0 +1,134 @@ +CREATE OR REPLACE FUNCTION public.assign_teams_to_swiss_pools(_stage_id uuid, _round int) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + pool_record RECORD; + bracket_record RECORD; + team_count int; + matches_needed int; + match_counter int; + bracket_order int[]; + i int; + seed_1_idx int; + seed_2_idx int; + team_1_id uuid; + team_2_id uuid; + adjacent_team_id uuid; + used_teams uuid[]; + teams_to_pair uuid[]; +BEGIN + RAISE NOTICE '=== Assigning Teams to Swiss Pools for Round % ===', _round; + + used_teams := ARRAY[]::uuid[]; + + -- Get all pools for this round, ordered by wins DESC, losses ASC + FOR pool_record IN + SELECT * FROM get_swiss_team_pools(_stage_id, used_teams) + ORDER BY wins DESC, losses ASC + LOOP + team_count := pool_record.team_count; + + IF team_count = 0 THEN + CONTINUE; + END IF; + + -- Calculate pool group: wins * 100 + losses + DECLARE + pool_group numeric; + BEGIN + pool_group := pool_record.wins * 100 + pool_record.losses; + + RAISE NOTICE ' Pool %-% (group %): % teams', + pool_record.wins, pool_record.losses, pool_group, team_count; + + -- Handle odd number of teams + adjacent_team_id := NULL; + teams_to_pair := pool_record.team_ids; + + IF team_count % 2 != 0 THEN + -- Find a team from an adjacent pool + adjacent_team_id := find_adjacent_swiss_team(_stage_id, pool_record.wins, pool_record.losses, used_teams); + + IF adjacent_team_id IS NOT NULL THEN + teams_to_pair := teams_to_pair || adjacent_team_id; + used_teams := used_teams || adjacent_team_id; + RAISE NOTICE ' Borrowed team % from adjacent pool', adjacent_team_id; + ELSE + RAISE EXCEPTION 'Odd number of teams in pool %-% and no adjacent team found', + pool_record.wins, pool_record.losses; + END IF; + END IF; + + -- Calculate matches needed + matches_needed := array_length(teams_to_pair, 1) / 2; + + -- Generate bracket order for pairing + bracket_order := generate_bracket_order(array_length(teams_to_pair, 1)); + + -- Assign teams to brackets + match_counter := 1; + FOR i IN 1..matches_needed LOOP + -- Get indices from bracket order + seed_1_idx := bracket_order[(i - 1) * 2 + 1]; + seed_2_idx := bracket_order[(i - 1) * 2 + 2]; + + -- Get team IDs + team_1_id := teams_to_pair[seed_1_idx]; + team_2_id := teams_to_pair[seed_2_idx]; + + -- Find or create bracket for this match + SELECT id INTO bracket_record + FROM tournament_brackets + WHERE tournament_stage_id = _stage_id + AND round = _round + AND "group" = pool_group + AND match_number = match_counter + LIMIT 1; + + IF bracket_record IS NOT NULL THEN + -- Update existing bracket + UPDATE tournament_brackets + SET tournament_team_id_1 = team_1_id, + tournament_team_id_2 = team_2_id, + bye = false + WHERE id = bracket_record.id; + ELSE + -- Create new bracket if needed + INSERT INTO tournament_brackets ( + round, + tournament_stage_id, + match_number, + "group", + tournament_team_id_1, + tournament_team_id_2, + path + ) + VALUES ( + _round, + _stage_id, + match_counter, + pool_group, + team_1_id, + team_2_id, + 'WB' + ); + END IF; + + RAISE NOTICE ' Match %: Team % vs Team %', match_counter, team_1_id, team_2_id; + match_counter := match_counter + 1; + END LOOP; + + -- Remove unused brackets for this pool + DELETE FROM tournament_brackets + WHERE tournament_stage_id = _stage_id + AND round = _round + AND "group" = pool_group + AND match_number >= match_counter; + END; + END LOOP; + + RAISE NOTICE '=== Team Assignment Complete ==='; +END; +$$; + diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql new file mode 100644 index 00000000..f5f305ff --- /dev/null +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -0,0 +1,279 @@ +CREATE OR REPLACE FUNCTION public.generate_swiss_bracket(_stage_id uuid, _team_count int) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + max_rounds int; + round_num int; + wins int; + losses int; + pool_group numeric; + matches_needed int; + match_num int; + bracket_order int[]; + seed_1 int; + seed_2 int; + bracket_idx int; +BEGIN + -- Calculate maximum rounds needed + -- In Swiss, teams play until they reach 3 wins or 3 losses + -- Maximum rounds would be if teams alternate wins/losses: 6 rounds (0-0 -> 1-0 -> 1-1 -> 2-1 -> 2-2 -> 3-2 or 2-3) + -- But we'll generate up to 6 rounds to be safe + max_rounds := 6; + + RAISE NOTICE '=== Generating Swiss Bracket for % teams ===', _team_count; + RAISE NOTICE 'Will generate rounds 1 through %', max_rounds; + + -- Round 1: All teams start at 0-0 + round_num := 1; + pool_group := 0; -- 0 wins, 0 losses = group 0 (encoded as wins*100 + losses) + matches_needed := _team_count / 2; + + -- Generate bracket order for first round + bracket_order := generate_bracket_order(_team_count); + bracket_idx := 0; + + RAISE NOTICE 'Round %: Pool 0-0 (group %), % matches', round_num, pool_group, matches_needed; + + FOR match_num IN 1..matches_needed LOOP + -- Get seed positions from bracket order + IF bracket_idx * 2 + 1 <= array_length(bracket_order, 1) THEN + seed_1 := bracket_order[bracket_idx * 2 + 1]; + ELSE + seed_1 := NULL; + END IF; + + IF bracket_idx * 2 + 2 <= array_length(bracket_order, 1) THEN + seed_2 := bracket_order[bracket_idx * 2 + 2]; + ELSE + seed_2 := NULL; + END IF; + + -- Set to NULL if seed position is beyond team_count + IF seed_1 IS NOT NULL AND seed_1 > _team_count THEN + seed_1 := NULL; + END IF; + IF seed_2 IS NOT NULL AND seed_2 > _team_count THEN + seed_2 := NULL; + END IF; + + INSERT INTO tournament_brackets ( + round, + tournament_stage_id, + match_number, + "group", + team_1_seed, + team_2_seed, + path + ) + VALUES ( + round_num, + _stage_id, + match_num, + pool_group, + seed_1, + seed_2, + 'WB' + ); + + bracket_idx := bracket_idx + 1; + END LOOP; + + -- Generate subsequent rounds (2-6) + -- For each round, create pools for all possible W/L combinations + RAISE NOTICE 'Starting generation of rounds 2 through %', max_rounds; + RAISE NOTICE 'About to enter loop for rounds 2 to %', max_rounds; + + -- Explicitly ensure the loop runs + round_num := 2; + WHILE round_num <= max_rounds LOOP + RAISE NOTICE '=== Round %: Generating pools ===', round_num; + + -- Generate all possible W/L combinations for this round + -- Teams can have 0-3 wins and 0-3 losses, but total wins+losses = round_num - 1 + DECLARE + pools_created int := 0; + matches_created int := 0; + BEGIN + FOR wins IN 0..LEAST(3, round_num - 1) LOOP + losses := (round_num - 1) - wins; + + -- Skip if losses > 3 (team would be eliminated) + IF losses > 3 THEN + RAISE NOTICE ' Skipping pool %-% (losses > 3)', wins, losses; + CONTINUE; + END IF; + + -- Skip pools where teams would have advanced (3 wins, < 3 losses) + -- These teams won't play more matches + IF wins = 3 AND losses < 3 THEN + RAISE NOTICE ' Skipping pool %-% (advanced)', wins, losses; + CONTINUE; + END IF; + + -- Skip pools where teams would be eliminated (3 losses) + -- These teams won't play more matches + IF losses = 3 THEN + RAISE NOTICE ' Skipping pool %-% (eliminated)', wins, losses; + CONTINUE; + END IF; + + -- Calculate pool group: wins * 100 + losses + pool_group := wins * 100 + losses; + + -- Calculate expected number of teams in this pool using binomial distribution + -- For round N, with W wins and L losses (W+L = N-1): + -- Expected teams = team_count * C(N-1, W) / 2^(N-1) + DECLARE + n int; + k int; + expected_teams_in_pool numeric; + binomial_coefficient numeric; + BEGIN + n := round_num - 1; + k := wins; + + -- Calculate binomial coefficient C(n, k) = n! / (k! * (n-k)!) + -- For small n, we can use direct calculation + IF n = 0 THEN + binomial_coefficient := 1; + ELSIF n = 1 THEN + binomial_coefficient := 1; + ELSIF n = 2 THEN + IF k = 0 OR k = 2 THEN + binomial_coefficient := 1; + ELSE + binomial_coefficient := 2; + END IF; + ELSIF n = 3 THEN + IF k = 0 OR k = 3 THEN + binomial_coefficient := 1; + ELSIF k = 1 OR k = 2 THEN + binomial_coefficient := 3; + END IF; + ELSIF n = 4 THEN + IF k = 0 OR k = 4 THEN + binomial_coefficient := 1; + ELSIF k = 1 OR k = 3 THEN + binomial_coefficient := 4; + ELSIF k = 2 THEN + binomial_coefficient := 6; + END IF; + ELSIF n = 5 THEN + IF k = 0 OR k = 5 THEN + binomial_coefficient := 1; + ELSIF k = 1 OR k = 4 THEN + binomial_coefficient := 5; + ELSIF k = 2 OR k = 3 THEN + binomial_coefficient := 10; + END IF; + ELSE + binomial_coefficient := 1; + END IF; + + -- Expected teams = team_count * C(n, k) / 2^n + expected_teams_in_pool := _team_count::numeric * binomial_coefficient / POWER(2, n); + + -- Adjust for teams that may have advanced (3 wins) or been eliminated (3 losses) in previous rounds + -- After round 3, teams with 3-0 advance, teams with 0-3 are eliminated + -- After round 4, teams with 3-1 advance, teams with 1-3 are eliminated + -- So we need to reduce expected teams for later rounds + IF round_num >= 4 THEN + -- Rough estimate: by round 4, ~25% of teams may have advanced/eliminated + -- Adjust based on round + DECLARE + reduction_factor numeric; + BEGIN + IF round_num = 4 THEN + reduction_factor := 0.75; -- ~25% advanced/eliminated + ELSIF round_num = 5 THEN + reduction_factor := 0.5; -- ~50% advanced/eliminated + ELSE + reduction_factor := 0.25; -- ~75% advanced/eliminated + END IF; + expected_teams_in_pool := expected_teams_in_pool * reduction_factor; + END; + END IF; + + -- Round to nearest integer, but ensure at least 2 for a match + IF expected_teams_in_pool < 2 THEN + matches_needed := 0; + ELSE + matches_needed := CEIL(expected_teams_in_pool / 2.0)::int; + END IF; + + -- Cap at reasonable maximum (half of team count per pool) + IF matches_needed > _team_count / 2 THEN + matches_needed := _team_count / 2; + END IF; + + -- Ensure minimum of 1 match if we expect teams (for odd numbers handled later) + IF matches_needed = 0 AND expected_teams_in_pool >= 1.5 THEN + matches_needed := 1; + END IF; + + RAISE NOTICE ' Creating pool %-% (group %): % matches (expected ~% teams, binomial C(%,%)=%)', + wins, losses, pool_group, matches_needed, + ROUND(expected_teams_in_pool, 1), n, k, binomial_coefficient; + END; + + -- Create placeholder matches for this pool + -- Each pool gets its own match_number sequence starting from 1 + FOR match_num IN 1..matches_needed LOOP + INSERT INTO tournament_brackets ( + round, + tournament_stage_id, + match_number, + "group", + path + ) + VALUES ( + round_num, + _stage_id, + match_num, + pool_group, + 'WB' + ); + matches_created := matches_created + 1; + END LOOP; + + pools_created := pools_created + 1; + END LOOP; + + RAISE NOTICE 'Round % complete: % pools, % matches created', round_num, pools_created, matches_created; + END; + + round_num := round_num + 1; + END LOOP; + + RAISE NOTICE 'Finished generating all rounds 2 through %', max_rounds; + + -- Summary: Count total brackets created + DECLARE + total_brackets int; + brackets_by_round RECORD; + BEGIN + SELECT COUNT(*) INTO total_brackets + FROM tournament_brackets + WHERE tournament_stage_id = _stage_id; + + RAISE NOTICE '=== Swiss Bracket Generation Complete ==='; + RAISE NOTICE 'Total brackets created: %', total_brackets; + + -- Show breakdown by round + FOR brackets_by_round IN + SELECT round, COUNT(*) as bracket_count, COUNT(DISTINCT "group") as pool_count + FROM tournament_brackets + WHERE tournament_stage_id = _stage_id + GROUP BY round + ORDER BY round + LOOP + RAISE NOTICE 'Round %: % brackets across % pools', + brackets_by_round.round, + brackets_by_round.bracket_count, + brackets_by_round.pool_count; + END LOOP; + END; +END; +$$; + diff --git a/hasura/functions/tournaments/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index 0ae7d1bf..f7a0c7c0 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -31,12 +31,14 @@ BEGIN RAISE NOTICE '--- Processing Stage % (groups: %, type: %) ---', stage."order", stage.groups, stage.type; IF stage.type = 'Swiss' THEN - -- Process first round brackets which have seed positions set + -- For Swiss, assign teams to first round (round 1, pool 0-0) + -- The brackets are already created by generate_swiss_bracket, just need to assign teams by seed FOR bracket IN SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed FROM tournament_brackets tb WHERE tb.tournament_stage_id = stage.id AND tb.round = 1 + AND tb."group" = 0 -- 0-0 pool AND COALESCE(tb.path, 'WB') = 'WB' AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) ORDER BY tb.match_number ASC @@ -81,7 +83,7 @@ BEGIN bye = false WHERE id = bracket.id; - RAISE NOTICE ' Swiss Round 1 Match %: Seed % (team %) vs Seed % (team %)', + RAISE NOTICE ' Swiss Round 1 Pool 0-0 Match %: Seed % (team %) vs Seed % (team %)', bracket.match_number, team_1_seed_val, team_1_id, team_2_seed_val, team_2_id; diff --git a/hasura/functions/tournaments/update_tournament_bracket.sql b/hasura/functions/tournaments/update_tournament_bracket.sql index 32c21180..ec2aae63 100644 --- a/hasura/functions/tournaments/update_tournament_bracket.sql +++ b/hasura/functions/tournaments/update_tournament_bracket.sql @@ -55,13 +55,13 @@ BEGIN ELSIF stage_type = 'Swiss' THEN -- Check if current round is complete IF check_swiss_round_complete(bracket.tournament_stage_id, bracket.round) THEN - RAISE NOTICE 'Swiss round % complete, creating next round', bracket.round; + RAISE NOTICE 'Swiss round % complete, assigning teams to next round pools', bracket.round; -- Advance/eliminate teams based on 3 wins/losses PERFORM advance_swiss_teams(bracket.tournament_stage_id); - -- Create next round matches - PERFORM create_next_swiss_round(bracket.tournament_stage_id); + -- Assign teams to next round pools (brackets already exist, just need to assign teams) + PERFORM assign_teams_to_swiss_pools(bracket.tournament_stage_id, bracket.round + 1); END IF; END IF; diff --git a/hasura/functions/tournaments/update_tournament_stages.sql b/hasura/functions/tournaments/update_tournament_stages.sql index a0902041..6d9dabbf 100644 --- a/hasura/functions/tournaments/update_tournament_stages.sql +++ b/hasura/functions/tournaments/update_tournament_stages.sql @@ -129,65 +129,17 @@ BEGIN CONTINUE; END IF; - -- For Swiss tournaments, only create first round (subsequent rounds created dynamically) + -- For Swiss tournaments, generate entire bracket upfront with all rounds and pools IF stage_type = 'Swiss' THEN - RAISE NOTICE 'Stage % : Swiss detected, creating first round only', stage."order"; + RAISE NOTICE 'Stage % : Swiss detected, generating entire bracket', stage."order"; -- First round requires even number (all teams start at 0-0, same pool) IF effective_teams % 2 != 0 THEN RAISE EXCEPTION 'Swiss tournament first round must have an even number of teams. Current: %', effective_teams; END IF; - -- Note: Odd numbers in later rounds are handled by pairing with adjacent pools - - -- Generate bracket order for first round pairing - bracket_order := generate_bracket_order(effective_teams); - RAISE NOTICE 'Generated bracket order for Swiss first round: %', bracket_order; - - -- Create first round matches using seed-based pairing - DECLARE - matches_in_round int; - match_idx int; - group_num int; - swiss_bracket_idx int; - BEGIN - -- Swiss uses single group (groups = 1) - group_num := 1; - matches_in_round := effective_teams / 2; - swiss_bracket_idx := 0; - - FOR match_idx IN 1..matches_in_round LOOP - -- Get seed positions from bracket order - IF swiss_bracket_idx * 2 + 1 <= array_length(bracket_order, 1) THEN - seed_1 := bracket_order[swiss_bracket_idx * 2 + 1]; - ELSE - seed_1 := NULL; - END IF; - - IF swiss_bracket_idx * 2 + 2 <= array_length(bracket_order, 1) THEN - seed_2 := bracket_order[swiss_bracket_idx * 2 + 2]; - ELSE - seed_2 := NULL; - END IF; - - -- Set to NULL if seed position is beyond effective_teams - IF seed_1 IS NOT NULL AND seed_1 > effective_teams THEN - seed_1 := NULL; - END IF; - IF seed_2 IS NOT NULL AND seed_2 > effective_teams THEN - seed_2 := NULL; - END IF; - - INSERT INTO tournament_brackets (round, tournament_stage_id, match_number, "group", team_1_seed, team_2_seed, path) - VALUES (1, stage.id, match_idx, group_num, seed_1, seed_2, 'WB') - RETURNING id INTO new_id; - - RAISE NOTICE ' => Created Swiss round 1 match %: id=%, seeds: % vs %', - match_idx, new_id, seed_1, seed_2; - - swiss_bracket_idx := swiss_bracket_idx + 1; - END LOOP; - END; + -- Generate entire Swiss bracket with all rounds and pools + PERFORM generate_swiss_bracket(stage.id, effective_teams); CONTINUE; END IF; From 131ab1ea8e7513b56a77cf4a1b6def1d3e277314 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:23:30 -0500 Subject: [PATCH 03/19] wip --- hasura/functions/tournaments/advance_swiss_teams.sql | 8 -------- 1 file changed, 8 deletions(-) diff --git a/hasura/functions/tournaments/advance_swiss_teams.sql b/hasura/functions/tournaments/advance_swiss_teams.sql index 3f0988a3..f5f10aaf 100644 --- a/hasura/functions/tournaments/advance_swiss_teams.sql +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -8,7 +8,6 @@ DECLARE advanced_teams uuid[]; eliminated_count int; BEGIN - -- Get stage information SELECT ts.tournament_id, ts."order" INTO stage_record FROM tournament_stages ts @@ -18,14 +17,12 @@ BEGIN RAISE EXCEPTION 'Stage % not found', _stage_id; END IF; - -- Get teams with 3 wins (should advance to next stage) SELECT array_agg(vtsr.tournament_team_id) INTO advanced_teams FROM v_team_stage_results vtsr WHERE vtsr.tournament_stage_id = _stage_id AND vtsr.wins >= 3; - -- Count teams with 3 losses (eliminated) SELECT COUNT(*) INTO eliminated_count FROM v_team_stage_results vtsr @@ -36,7 +33,6 @@ BEGIN RAISE NOTICE 'Teams with 3+ wins: %', COALESCE(array_length(advanced_teams, 1), 0); RAISE NOTICE 'Teams with 3+ losses: %', eliminated_count; - -- Check if stage is complete (all teams have 3+ wins or 3+ losses) DECLARE remaining_teams int; stage_complete boolean; @@ -50,7 +46,6 @@ BEGIN stage_complete := (remaining_teams = 0); - -- Advance teams to next stage if it exists IF advanced_teams IS NOT NULL AND array_length(advanced_teams, 1) > 0 THEN SELECT ts.id INTO next_stage_id FROM tournament_stages ts @@ -60,7 +55,6 @@ BEGIN IF next_stage_id IS NOT NULL THEN RAISE NOTICE 'Advancing % teams to next stage', array_length(advanced_teams, 1); - -- If stage is complete, advance teams to next stage (similar to RoundRobin) IF stage_complete THEN RAISE NOTICE 'Swiss stage complete, advancing teams to next stage'; PERFORM advance_swiss_teams_to_next_stage(_stage_id); @@ -70,8 +64,6 @@ BEGIN END IF; END IF; - -- Teams with 3 losses are eliminated (they stop playing) - -- This is handled by filtering them out in get_swiss_team_pools END; RAISE NOTICE '=== Swiss Advancement Complete ==='; From 207a3d07ca42f36e7672c971b03010f0f6789300 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:24:48 -0500 Subject: [PATCH 04/19] wip --- .../tournaments/advance_swiss_teams.sql | 2 +- .../advance_swiss_teams_to_next_stage.sql | 38 ------------------- 2 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql diff --git a/hasura/functions/tournaments/advance_swiss_teams.sql b/hasura/functions/tournaments/advance_swiss_teams.sql index f5f10aaf..a8703dea 100644 --- a/hasura/functions/tournaments/advance_swiss_teams.sql +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -57,7 +57,7 @@ BEGIN IF stage_complete THEN RAISE NOTICE 'Swiss stage complete, advancing teams to next stage'; - PERFORM advance_swiss_teams_to_next_stage(_stage_id); + PERFORM seed_stage(next_stage_id); END IF; ELSE RAISE NOTICE 'No next stage found - teams have won the tournament'; diff --git a/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql b/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql deleted file mode 100644 index 89d02027..00000000 --- a/hasura/functions/tournaments/advance_swiss_teams_to_next_stage.sql +++ /dev/null @@ -1,38 +0,0 @@ -CREATE OR REPLACE FUNCTION public.advance_swiss_teams_to_next_stage(_stage_id uuid) -RETURNS void -LANGUAGE plpgsql -AS $$ -DECLARE - current_stage RECORD; - next_stage_id uuid; -BEGIN - -- Get current stage information - SELECT ts.tournament_id, ts."order", ts.groups, ts.max_teams - INTO current_stage - FROM tournament_stages ts - WHERE ts.id = _stage_id; - - IF current_stage IS NULL THEN - RAISE EXCEPTION 'Stage % not found', _stage_id; - END IF; - - -- Find next stage - SELECT ts.id, ts.max_teams - INTO next_stage_id - FROM tournament_stages ts - WHERE ts.tournament_id = current_stage.tournament_id - AND ts."order" = current_stage."order" + 1; - - IF next_stage_id IS NULL THEN - RAISE NOTICE 'No next stage found for Swiss stage %', _stage_id; - RETURN; - END IF; - - RAISE NOTICE 'Advancing teams from Swiss stage % to next stage %', _stage_id, next_stage_id; - - -- Seed the next stage (teams with 3+ wins will be selected from v_team_stage_results) - -- This works similarly to RoundRobin - teams are ordered by their results in v_team_stage_results - PERFORM seed_stage(next_stage_id); -END; -$$; - From b57ec1ccc193ee902edd391bf801187dddecdcf6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:29:27 -0500 Subject: [PATCH 05/19] wip --- .../assign_teams_to_swiss_pools.sql | 57 ++++--------------- 1 file changed, 12 insertions(+), 45 deletions(-) diff --git a/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql index ca5ba13f..337cb299 100644 --- a/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql +++ b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql @@ -22,7 +22,6 @@ BEGIN used_teams := ARRAY[]::uuid[]; - -- Get all pools for this round, ordered by wins DESC, losses ASC FOR pool_record IN SELECT * FROM get_swiss_team_pools(_stage_id, used_teams) ORDER BY wins DESC, losses ASC @@ -60,71 +59,39 @@ BEGIN END IF; END IF; - -- Calculate matches needed matches_needed := array_length(teams_to_pair, 1) / 2; - -- Generate bracket order for pairing bracket_order := generate_bracket_order(array_length(teams_to_pair, 1)); - -- Assign teams to brackets match_counter := 1; FOR i IN 1..matches_needed LOOP - -- Get indices from bracket order seed_1_idx := bracket_order[(i - 1) * 2 + 1]; seed_2_idx := bracket_order[(i - 1) * 2 + 2]; - -- Get team IDs team_1_id := teams_to_pair[seed_1_idx]; team_2_id := teams_to_pair[seed_2_idx]; - -- Find or create bracket for this match SELECT id INTO bracket_record - FROM tournament_brackets - WHERE tournament_stage_id = _stage_id - AND round = _round - AND "group" = pool_group - AND match_number = match_counter - LIMIT 1; + FROM tournament_brackets + WHERE tournament_stage_id = _stage_id + AND round = _round + AND "group" = pool_group + AND match_number = match_counter + LIMIT 1; - IF bracket_record IS NOT NULL THEN - -- Update existing bracket - UPDATE tournament_brackets + IF bracket_record IS NULL THEN + RAISE EXCEPTION 'Bracket record not found for match %', match_counter; + END IF; + + UPDATE tournament_brackets SET tournament_team_id_1 = team_1_id, tournament_team_id_2 = team_2_id, bye = false WHERE id = bracket_record.id; - ELSE - -- Create new bracket if needed - INSERT INTO tournament_brackets ( - round, - tournament_stage_id, - match_number, - "group", - tournament_team_id_1, - tournament_team_id_2, - path - ) - VALUES ( - _round, - _stage_id, - match_counter, - pool_group, - team_1_id, - team_2_id, - 'WB' - ); - END IF; - + RAISE NOTICE ' Match %: Team % vs Team %', match_counter, team_1_id, team_2_id; match_counter := match_counter + 1; END LOOP; - - -- Remove unused brackets for this pool - DELETE FROM tournament_brackets - WHERE tournament_stage_id = _stage_id - AND round = _round - AND "group" = pool_group - AND match_number >= match_counter; END; END LOOP; From beeae1c4711280e6401767befd1cc3c50107c020 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:29:44 -0500 Subject: [PATCH 06/19] wip --- hasura/functions/tournaments/check_swiss_round_complete.sql | 3 --- 1 file changed, 3 deletions(-) diff --git a/hasura/functions/tournaments/check_swiss_round_complete.sql b/hasura/functions/tournaments/check_swiss_round_complete.sql index ef0c4e1c..15c48f49 100644 --- a/hasura/functions/tournaments/check_swiss_round_complete.sql +++ b/hasura/functions/tournaments/check_swiss_round_complete.sql @@ -6,20 +6,17 @@ DECLARE unfinished_count int; total_matches int; BEGIN - -- Count unfinished matches in the specified round SELECT COUNT(*) INTO unfinished_count FROM tournament_brackets tb WHERE tb.tournament_stage_id = _stage_id AND tb.round = _round AND tb.finished = false; - -- Count total matches in the round SELECT COUNT(*) INTO total_matches FROM tournament_brackets tb WHERE tb.tournament_stage_id = _stage_id AND tb.round = _round; - -- Round is complete if all matches are finished (or no matches exist) RETURN unfinished_count = 0 AND total_matches > 0; END; $$; From 82996e2f4b2d6eec098a0a2009065e16256ececa Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:32:03 -0500 Subject: [PATCH 07/19] wip --- .../tournaments/create_next_swiss_round.sql | 72 ------------- .../tournaments/pair_swiss_teams.sql | 101 ------------------ 2 files changed, 173 deletions(-) delete mode 100644 hasura/functions/tournaments/create_next_swiss_round.sql delete mode 100644 hasura/functions/tournaments/pair_swiss_teams.sql diff --git a/hasura/functions/tournaments/create_next_swiss_round.sql b/hasura/functions/tournaments/create_next_swiss_round.sql deleted file mode 100644 index 0bd992c9..00000000 --- a/hasura/functions/tournaments/create_next_swiss_round.sql +++ /dev/null @@ -1,72 +0,0 @@ -CREATE OR REPLACE FUNCTION public.create_next_swiss_round(_stage_id uuid) -RETURNS void -LANGUAGE plpgsql -AS $$ -DECLARE - current_round int; - next_round int; - pool_record RECORD; - total_teams int; -BEGIN - -- Get current maximum round - SELECT COALESCE(MAX(tb.round), 0) INTO current_round - FROM tournament_brackets tb - WHERE tb.tournament_stage_id = _stage_id; - - next_round := current_round + 1; - - RAISE NOTICE '=== Creating Swiss Round % ===', next_round; - - -- Get teams grouped by W/L record - -- Process pools in order, handling odd numbers by pairing with adjacent pools - DECLARE - adjacent_team_id uuid; - used_teams uuid[]; - BEGIN - used_teams := ARRAY[]::uuid[]; - - FOR pool_record IN - SELECT * FROM get_swiss_team_pools(_stage_id, used_teams) - ORDER BY wins DESC, losses ASC - LOOP - total_teams := pool_record.team_count; - - -- Skip pools with 0 teams - IF total_teams = 0 THEN - CONTINUE; - END IF; - - RAISE NOTICE ' Pool: W:% L:% Teams:%', - pool_record.wins, pool_record.losses, total_teams; - - -- Handle odd number of teams by finding adjacent team - adjacent_team_id := NULL; - IF total_teams % 2 != 0 THEN - -- Find a team from an adjacent pool (excluding already used teams) - adjacent_team_id := find_adjacent_swiss_team(_stage_id, pool_record.wins, pool_record.losses, used_teams); - - IF adjacent_team_id IS NULL THEN - RAISE EXCEPTION 'Odd number of teams in pool (wins: %, losses: %, count: %) and no available adjacent team found', - pool_record.wins, pool_record.losses, total_teams; - END IF; - - -- Mark adjacent team as used - used_teams := used_teams || adjacent_team_id; - END IF; - - -- Pair teams within this pool (with adjacent team if needed) - PERFORM pair_swiss_teams( - _stage_id, - next_round, - pool_record.wins, - pool_record.losses, - pool_record.team_ids, - adjacent_team_id - ); - END LOOP; - END; - - RAISE NOTICE '=== Swiss Round % Created ===', next_round; -END; -$$; - diff --git a/hasura/functions/tournaments/pair_swiss_teams.sql b/hasura/functions/tournaments/pair_swiss_teams.sql deleted file mode 100644 index a2add935..00000000 --- a/hasura/functions/tournaments/pair_swiss_teams.sql +++ /dev/null @@ -1,101 +0,0 @@ -CREATE OR REPLACE FUNCTION public.pair_swiss_teams( - _stage_id uuid, - _round int, - _wins int, - _losses int, - _team_ids uuid[], - _adjacent_team_id uuid DEFAULT NULL -) -RETURNS void -LANGUAGE plpgsql -AS $$ -DECLARE - team_count int; - bracket_order int[]; - match_number int; - i int; - seed_1_idx int; - seed_2_idx int; - team_1_id uuid; - team_2_id uuid; - group_num int; - teams_to_pair uuid[]; - pairing_count int; -BEGIN - team_count := array_length(_team_ids, 1); - - -- Get group number from stage (Swiss uses single group, so should be 1) - SELECT COALESCE(ts.groups, 1) INTO group_num - FROM tournament_stages ts - WHERE ts.id = _stage_id; - - -- Ensure group_num is 1 for Swiss - IF group_num IS NULL OR group_num != 1 THEN - group_num := 1; - END IF; - - -- Handle odd number of teams by pairing with adjacent pool team - IF team_count % 2 != 0 THEN - IF _adjacent_team_id IS NULL THEN - -- Try to find an adjacent team - _adjacent_team_id := find_adjacent_swiss_team(_stage_id, _wins, _losses); - END IF; - - IF _adjacent_team_id IS NOT NULL THEN - -- Add adjacent team to the pool for pairing - teams_to_pair := _team_ids || _adjacent_team_id; - RAISE NOTICE ' Pool (W:% L:%) has odd number of teams (%), pairing with adjacent team %', - _wins, _losses, team_count, _adjacent_team_id; - ELSE - RAISE EXCEPTION 'Odd number of teams in pool (wins: %, losses: %, count: %) and no adjacent team found', - _wins, _losses, team_count; - END IF; - ELSE - teams_to_pair := _team_ids; - END IF; - - pairing_count := array_length(teams_to_pair, 1); - - -- Generate bracket order for pairing teams - bracket_order := generate_bracket_order(pairing_count); - - -- Pair teams using bracket order - match_number := 1; - FOR i IN 1..(pairing_count / 2) LOOP - -- Get indices from bracket order (1-based) - seed_1_idx := bracket_order[(i - 1) * 2 + 1]; - seed_2_idx := bracket_order[(i - 1) * 2 + 2]; - - -- Get team IDs (bracket_order uses 1-based indexing, array uses 1-based) - team_1_id := teams_to_pair[seed_1_idx]; - team_2_id := teams_to_pair[seed_2_idx]; - - -- Create match bracket - INSERT INTO tournament_brackets ( - round, - tournament_stage_id, - match_number, - "group", - tournament_team_id_1, - tournament_team_id_2, - path - ) - VALUES ( - _round, - _stage_id, - match_number, - group_num, - team_1_id, - team_2_id, - 'WB' - ); - - RAISE NOTICE ' Created match %: Team % vs Team % (W:% L:%)', - match_number, team_1_id, team_2_id, _wins, _losses; - - match_number := match_number + 1; - END LOOP; -END; -$$; - - From 5741ba51f6c6e32f3d877679710b7a5572d4e2a5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:37:33 -0500 Subject: [PATCH 08/19] wip --- hasura/functions/tournaments/generate_swiss_bracket.sql | 1 + hasura/functions/tournaments/seed_stage.sql | 1 + hasura/functions/tournaments/tournament_has_min_teams.sql | 1 + hasura/functions/tournaments/update_tournament_bracket.sql | 3 --- hasura/functions/tournaments/update_tournament_stages.sql | 1 - hasura/triggers/tournament_stages.sql | 1 + 6 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql index f5f305ff..34482e31 100644 --- a/hasura/functions/tournaments/generate_swiss_bracket.sql +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -19,6 +19,7 @@ BEGIN -- In Swiss, teams play until they reach 3 wins or 3 losses -- Maximum rounds would be if teams alternate wins/losses: 6 rounds (0-0 -> 1-0 -> 1-1 -> 2-1 -> 2-2 -> 3-2 or 2-3) -- But we'll generate up to 6 rounds to be safe + -- TODO - this is bad max_rounds := 6; RAISE NOTICE '=== Generating Swiss Bracket for % teams ===', _team_count; diff --git a/hasura/functions/tournaments/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index f7a0c7c0..22e4a4dc 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -31,6 +31,7 @@ BEGIN RAISE NOTICE '--- Processing Stage % (groups: %, type: %) ---', stage."order", stage.groups, stage.type; IF stage.type = 'Swiss' THEN + -- TODO - i think this is the same as single / double elimination seeding -- For Swiss, assign teams to first round (round 1, pool 0-0) -- The brackets are already created by generate_swiss_bracket, just need to assign teams by seed FOR bracket IN diff --git a/hasura/functions/tournaments/tournament_has_min_teams.sql b/hasura/functions/tournaments/tournament_has_min_teams.sql index 2a2bff46..8fab78f5 100644 --- a/hasura/functions/tournaments/tournament_has_min_teams.sql +++ b/hasura/functions/tournaments/tournament_has_min_teams.sql @@ -1,3 +1,4 @@ +-- TODO CREATE OR REPLACE FUNCTION public.tournament_has_min_teams(tournament public.tournaments) RETURNS boolean LANGUAGE plpgsql STABLE diff --git a/hasura/functions/tournaments/update_tournament_bracket.sql b/hasura/functions/tournaments/update_tournament_bracket.sql index ec2aae63..1097a805 100644 --- a/hasura/functions/tournaments/update_tournament_bracket.sql +++ b/hasura/functions/tournaments/update_tournament_bracket.sql @@ -53,14 +53,11 @@ BEGIN PERFORM advance_round_robin_teams(bracket.tournament_stage_id); END IF; ELSIF stage_type = 'Swiss' THEN - -- Check if current round is complete IF check_swiss_round_complete(bracket.tournament_stage_id, bracket.round) THEN RAISE NOTICE 'Swiss round % complete, assigning teams to next round pools', bracket.round; - -- Advance/eliminate teams based on 3 wins/losses PERFORM advance_swiss_teams(bracket.tournament_stage_id); - -- Assign teams to next round pools (brackets already exist, just need to assign teams) PERFORM assign_teams_to_swiss_pools(bracket.tournament_stage_id, bracket.round + 1); END IF; END IF; diff --git a/hasura/functions/tournaments/update_tournament_stages.sql b/hasura/functions/tournaments/update_tournament_stages.sql index 6d9dabbf..e7bd3e12 100644 --- a/hasura/functions/tournaments/update_tournament_stages.sql +++ b/hasura/functions/tournaments/update_tournament_stages.sql @@ -138,7 +138,6 @@ BEGIN RAISE EXCEPTION 'Swiss tournament first round must have an even number of teams. Current: %', effective_teams; END IF; - -- Generate entire Swiss bracket with all rounds and pools PERFORM generate_swiss_bracket(stage.id, effective_teams); CONTINUE; diff --git a/hasura/triggers/tournament_stages.sql b/hasura/triggers/tournament_stages.sql index cb072f51..13087634 100644 --- a/hasura/triggers/tournament_stages.sql +++ b/hasura/triggers/tournament_stages.sql @@ -1,3 +1,4 @@ +-- TODO - is this duplicated code? CREATE OR REPLACE FUNCTION public.taiu_tournament_stages() RETURNS TRIGGER LANGUAGE plpgsql From 7b6e01699dd58348cc941f4440c2a4098c9fb77e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 08:47:44 -0500 Subject: [PATCH 09/19] wip --- .../tournaments/generate_swiss_bracket.sql | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql index 34482e31..83a7e921 100644 --- a/hasura/functions/tournaments/generate_swiss_bracket.sql +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -4,6 +4,7 @@ LANGUAGE plpgsql AS $$ DECLARE max_rounds int; + wins_needed int; -- Number of wins needed to advance (Valve-style: 3) round_num int; wins int; losses int; @@ -15,12 +16,11 @@ DECLARE seed_2 int; bracket_idx int; BEGIN - -- Calculate maximum rounds needed - -- In Swiss, teams play until they reach 3 wins or 3 losses - -- Maximum rounds would be if teams alternate wins/losses: 6 rounds (0-0 -> 1-0 -> 1-1 -> 2-1 -> 2-2 -> 3-2 or 2-3) - -- But we'll generate up to 6 rounds to be safe - -- TODO - this is bad - max_rounds := 6; + -- Valve-style Swiss system: teams need 3 wins to advance or 3 losses to be eliminated + -- Max rounds formula: 2 × wins_needed - 1 + -- This ensures all teams will either advance or be eliminated + wins_needed := 3; + max_rounds := 2 * wins_needed - 1; -- For 3 wins: 2 × 3 - 1 = 5 rounds RAISE NOTICE '=== Generating Swiss Bracket for % teams ===', _team_count; RAISE NOTICE 'Will generate rounds 1 through %', max_rounds; @@ -80,7 +80,7 @@ BEGIN bracket_idx := bracket_idx + 1; END LOOP; - -- Generate subsequent rounds (2-6) + -- Generate subsequent rounds -- For each round, create pools for all possible W/L combinations RAISE NOTICE 'Starting generation of rounds 2 through %', max_rounds; RAISE NOTICE 'About to enter loop for rounds 2 to %', max_rounds; @@ -91,30 +91,30 @@ BEGIN RAISE NOTICE '=== Round %: Generating pools ===', round_num; -- Generate all possible W/L combinations for this round - -- Teams can have 0-3 wins and 0-3 losses, but total wins+losses = round_num - 1 + -- Teams can have 0 to wins_needed wins and 0 to wins_needed losses, but total wins+losses = round_num - 1 DECLARE pools_created int := 0; matches_created int := 0; BEGIN - FOR wins IN 0..LEAST(3, round_num - 1) LOOP + FOR wins IN 0..LEAST(wins_needed, round_num - 1) LOOP losses := (round_num - 1) - wins; - -- Skip if losses > 3 (team would be eliminated) - IF losses > 3 THEN - RAISE NOTICE ' Skipping pool %-% (losses > 3)', wins, losses; + -- Skip if losses > wins_needed (team would be eliminated) + IF losses > wins_needed THEN + RAISE NOTICE ' Skipping pool %-% (losses > %)', wins, losses, wins_needed; CONTINUE; END IF; - -- Skip pools where teams would have advanced (3 wins, < 3 losses) + -- Skip pools where teams would have advanced (wins_needed wins, < wins_needed losses) -- These teams won't play more matches - IF wins = 3 AND losses < 3 THEN + IF wins = wins_needed AND losses < wins_needed THEN RAISE NOTICE ' Skipping pool %-% (advanced)', wins, losses; CONTINUE; END IF; - -- Skip pools where teams would be eliminated (3 losses) + -- Skip pools where teams would be eliminated (wins_needed losses) -- These teams won't play more matches - IF losses = 3 THEN + IF losses = wins_needed THEN RAISE NOTICE ' Skipping pool %-% (eliminated)', wins, losses; CONTINUE; END IF; @@ -175,11 +175,11 @@ BEGIN -- Expected teams = team_count * C(n, k) / 2^n expected_teams_in_pool := _team_count::numeric * binomial_coefficient / POWER(2, n); - -- Adjust for teams that may have advanced (3 wins) or been eliminated (3 losses) in previous rounds - -- After round 3, teams with 3-0 advance, teams with 0-3 are eliminated - -- After round 4, teams with 3-1 advance, teams with 1-3 are eliminated + -- Adjust for teams that may have advanced (wins_needed wins) or been eliminated (wins_needed losses) in previous rounds + -- After round wins_needed, teams with wins_needed-0 advance, teams with 0-wins_needed are eliminated + -- After round wins_needed+1, teams with wins_needed-1 advance, teams with 1-wins_needed are eliminated -- So we need to reduce expected teams for later rounds - IF round_num >= 4 THEN + IF round_num >= wins_needed + 1 THEN -- Rough estimate: by round 4, ~25% of teams may have advanced/eliminated -- Adjust based on round DECLARE From 8c9bb076eae0f3702bdd23b8e3dac5d2f42747d6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 10:53:56 -0500 Subject: [PATCH 10/19] wip --- hasura/functions/tournaments/seed_stage.sql | 95 ++++++--------------- 1 file changed, 26 insertions(+), 69 deletions(-) diff --git a/hasura/functions/tournaments/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index 22e4a4dc..430f9195 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -30,66 +30,7 @@ BEGIN RAISE NOTICE '--- Processing Stage % (groups: %, type: %) ---', stage."order", stage.groups, stage.type; - IF stage.type = 'Swiss' THEN - -- TODO - i think this is the same as single / double elimination seeding - -- For Swiss, assign teams to first round (round 1, pool 0-0) - -- The brackets are already created by generate_swiss_bracket, just need to assign teams by seed - FOR bracket IN - SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed - FROM tournament_brackets tb - WHERE tb.tournament_stage_id = stage.id - AND tb.round = 1 - AND tb."group" = 0 -- 0-0 pool - AND COALESCE(tb.path, 'WB') = 'WB' - AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) - ORDER BY tb.match_number ASC - LOOP - team_1_id := NULL; - team_2_id := NULL; - team_1_seed_val := bracket.team_1_seed; - team_2_seed_val := bracket.team_2_seed; - - -- Find team with matching seed for position 1 - IF team_1_seed_val IS NOT NULL THEN - SELECT id INTO team_1_id - FROM tournament_teams - WHERE tournament_id = stage.tournament_id - AND eligible_at IS NOT NULL - AND seed = team_1_seed_val - LIMIT 1; - - IF team_1_id IS NOT NULL THEN - teams_assigned_count := teams_assigned_count + 1; - END IF; - END IF; - - -- Find team with matching seed for position 2 - IF team_2_seed_val IS NOT NULL THEN - SELECT id INTO team_2_id - FROM tournament_teams - WHERE tournament_id = stage.tournament_id - AND eligible_at IS NOT NULL - AND seed = team_2_seed_val - LIMIT 1; - - IF team_2_id IS NOT NULL THEN - teams_assigned_count := teams_assigned_count + 1; - END IF; - END IF; - - -- Update bracket with teams (Swiss should never have byes) - UPDATE tournament_brackets - SET tournament_team_id_1 = team_1_id, - tournament_team_id_2 = team_2_id, - bye = false - WHERE id = bracket.id; - - RAISE NOTICE ' Swiss Round 1 Pool 0-0 Match %: Seed % (team %) vs Seed % (team %)', - bracket.match_number, - team_1_seed_val, team_1_id, - team_2_seed_val, team_2_id; - END LOOP; - ELSIF stage.type = 'RoundRobin' THEN + IF stage.type = 'RoundRobin' THEN -- Process all RoundRobin brackets which have seed positions set FOR bracket IN SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed @@ -145,21 +86,29 @@ BEGIN team_2_seed_val, team_2_id; END LOOP; ELSE - -- Process first-round *winners* brackets which have seed positions set (for elimination brackets) + -- Process first-round brackets for Swiss and elimination tournaments + -- For Swiss: assign teams to first round (round 1, pool 0-0) + -- For elimination: process first-round winners brackets FOR bracket IN SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed FROM tournament_brackets tb WHERE tb.tournament_stage_id = stage.id AND tb.round = 1 + AND (stage.type != 'Swiss' OR tb."group" = 0) -- Swiss filters to group 0 (0-0 pool) AND COALESCE(tb.path, 'WB') = 'WB' -- never seed or mark byes on loser brackets - ORDER BY tb."group" ASC, tb.match_number ASC + AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) + ORDER BY + CASE WHEN stage.type = 'Swiss' THEN tb.match_number ELSE tb."group" END ASC, + CASE WHEN stage.type = 'Swiss' THEN 0 ELSE tb.match_number END ASC LOOP team_1_id := NULL; team_2_id := NULL; team_1_seed_val := bracket.team_1_seed; team_2_seed_val := bracket.team_2_seed; - IF previous_stage.id IS NOT NULL AND (previous_stage.type = 'RoundRobin' OR previous_stage.type = 'Swiss') THEN + -- For elimination brackets coming from RoundRobin/Swiss stages, use stage results + -- Otherwise (including Swiss first round), lookup teams by seed + IF stage.type != 'Swiss' AND previous_stage.id IS NOT NULL AND (previous_stage.type = 'RoundRobin' OR previous_stage.type = 'Swiss') THEN IF team_1_seed_val IS NOT NULL THEN SELECT tournament_team_id INTO team_1_id FROM v_team_stage_results @@ -201,21 +150,29 @@ BEGIN teams_assigned_count := teams_assigned_count + 1; END IF; - IF team_2_id IS NOT NULL THEN + IF team_2_id IS NOT NULL THEN teams_assigned_count := teams_assigned_count + 1; END IF; -- Update bracket with teams + -- Swiss should never have byes, elimination brackets can have byes UPDATE tournament_brackets SET tournament_team_id_1 = team_1_id, tournament_team_id_2 = team_2_id, - bye = (team_1_id IS NULL OR team_2_id IS NULL) + bye = CASE WHEN stage.type = 'Swiss' THEN false ELSE (team_1_id IS NULL OR team_2_id IS NULL) END WHERE id = bracket.id; - RAISE NOTICE ' Bracket %: Seed % (team %) vs Seed % (team %)', - bracket.match_number, - team_1_seed_val, team_1_id, - team_2_seed_val, team_2_id; + IF stage.type = 'Swiss' THEN + RAISE NOTICE ' Swiss Round 1 Pool 0-0 Match %: Seed % (team %) vs Seed % (team %)', + bracket.match_number, + team_1_seed_val, team_1_id, + team_2_seed_val, team_2_id; + ELSE + RAISE NOTICE ' Bracket %: Seed % (team %) vs Seed % (team %)', + bracket.match_number, + team_1_seed_val, team_1_id, + team_2_seed_val, team_2_id; + END IF; END LOOP; END IF; From efa23bcbe19288226cc7156aba151b7f11919342 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 10:57:07 -0500 Subject: [PATCH 11/19] wip --- UNUSED_FUNCTIONS_ANALYSIS.md | 56 ------------------------------------ 1 file changed, 56 deletions(-) delete mode 100644 UNUSED_FUNCTIONS_ANALYSIS.md diff --git a/UNUSED_FUNCTIONS_ANALYSIS.md b/UNUSED_FUNCTIONS_ANALYSIS.md deleted file mode 100644 index fe2a5167..00000000 --- a/UNUSED_FUNCTIONS_ANALYSIS.md +++ /dev/null @@ -1,56 +0,0 @@ -# Function Usage Analysis - -## Summary -All tournament functions appear to be **USED**. No unused functions found. - -## Function Usage Details - -### ✅ All Functions Are Used - -#### Core Tournament Functions -- `advance_round_robin_teams` - Used in `update_tournament_bracket.sql` -- `advance_byes_for_tournament` - Used in `seed_stage.sql` -- `assign_seeds_to_teams` - Used in `triggers/tournaments.sql` -- `assign_team_to_bracket_slot` - Used in `update_tournament_bracket.sql` and `schedule_tournament_match.sql` -- `check_round_robin_stage_complete` - Used in `update_tournament_bracket.sql` -- `check_team_eligibility` - Used in `triggers/tournament_team_roster.sql` -- `check_tournament_finished` - Used in `update_tournament_bracket.sql` -- `create_round_robin_matches` - Used in `update_tournament_stages.sql` -- `delete_tournament_brackets_and_matches` - Used in `triggers/tournaments.sql` and `update_tournament_stages.sql` -- `generate_bracket_order` - Used in `update_tournament_stages.sql` -- `get_stage_team_counts` - Used in `update_tournament_stages.sql` -- `get_team_next_round_bracket_id` - Used in `schedule_next_round_robin_matches.sql` -- `is_tournament_match` - Used in `triggers/matches.sql` and Hasura metadata -- `is_tournament_organizer` - Used in Hasura metadata (computed field) -- `link_round_group_matches` - Used in `link_tournament_stage_matches.sql` -- `link_stage_brackets` - Used in `link_tournament_stages.sql` -- `link_tournament_stage_matches` - Used in `update_tournament_stages.sql` -- `link_tournament_stages` - Used in `update_tournament_stages.sql` -- `opponent_finished_previous_round` - Used in `schedule_next_round_robin_matches.sql` -- `schedule_next_round_robin_matches` - Used in `update_tournament_bracket.sql` -- `schedule_tournament_match` - Used in `update_tournament_bracket.sql` and `schedule_next_round_robin_matches.sql` -- `seed_stage` - Used in `advance_round_robin_teams.sql` -- `tournament_bracket_eta` - Used as computed field in Hasura metadata -- `tournament_has_min_teams` - Used in `can_start_tournament.sql`, `can_close_tournament_registration.sql`, `triggers/tournaments.sql`, and Hasura metadata -- `tournament_max_players_per_lineup` - Used in `check_team_eligibility.sql` and Hasura metadata -- `tournament_min_players_per_lineup` - Used in `check_team_eligibility.sql` and Hasura metadata -- `update_tournament_bracket` - Used in `triggers/matches.sql` -- `update_tournament_stages` - Used in `triggers/tournaments.sql` -- `calculate_tournament_bracket_start_times` - Used in `update_tournament_stages.sql` and `schedule_tournament_match.sql` - -#### Permission Functions (All Used in Hasura Metadata) -- `can_cancel_tournament` - Used in `triggers/tournaments.sql` and Hasura metadata -- `can_close_tournament_registration` - Used in `triggers/tournaments.sql` and Hasura metadata -- `can_join_tournament` - Used in Hasura metadata -- `can_open_tournament_registration` - Used in `triggers/tournaments.sql` and Hasura metadata -- `can_start_tournament` - Used in Hasura metadata - -## Notes -- All functions are either: - 1. Called by other functions/triggers - 2. Used as computed fields in Hasura GraphQL schema - 3. Used in permission checks via triggers - -- No orphaned or unused functions detected in the tournaments directory. - - From 8b30570ad58077ae57988654c6d9b5f978e021c7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 11:00:11 -0500 Subject: [PATCH 12/19] wip --- hasura/triggers/tournament_stages.sql | 213 +++++++++++--------------- 1 file changed, 92 insertions(+), 121 deletions(-) diff --git a/hasura/triggers/tournament_stages.sql b/hasura/triggers/tournament_stages.sql index 13087634..2ccbfc53 100644 --- a/hasura/triggers/tournament_stages.sql +++ b/hasura/triggers/tournament_stages.sql @@ -1,4 +1,77 @@ --- TODO - is this duplicated code? +-- Shared validation function for tournament stages +CREATE OR REPLACE FUNCTION public.validate_tournament_stage( + p_stage_type text, + p_groups integer, + p_min_teams integer, + p_stage_order integer, + p_tournament_id uuid, + p_stage_id uuid +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + prev_stage_record RECORD; + max_teams_advancing int; + last_round_matches int; +BEGIN + -- Validate Swiss tournament requirements + IF p_stage_type = 'Swiss' THEN + -- Swiss tournaments use single group + IF p_groups IS NOT NULL AND p_groups != 1 THEN + RAISE EXCEPTION 'Swiss tournaments must use a single group (groups = 1). Current: %', + p_groups USING ERRCODE = '22000'; + END IF; + -- Note: Odd numbers are handled by pairing with adjacent pools + END IF; + + -- Validate first stage minimum teams (must be at least 4 * number of groups) + IF p_stage_order = 1 AND p_groups IS NOT NULL AND p_groups > 0 AND p_stage_type != 'Swiss' THEN + IF p_min_teams < 4 * p_groups THEN + RAISE EXCEPTION 'First stage must have at least % teams given % groups (minimum 4 teams per group)', + 4 * p_groups, p_groups USING ERRCODE = '22000'; + END IF; + END IF; + + -- Validate that this stage can accommodate teams advancing from the previous stage + IF p_stage_order > 1 THEN + -- Get the previous stage + SELECT * INTO prev_stage_record + FROM tournament_stages + WHERE tournament_id = p_tournament_id AND "order" = p_stage_order - 1; + + IF prev_stage_record.id IS NOT NULL THEN + -- Calculate max teams that can advance from previous stage + -- Count matches in the last round of the previous stage (each match produces 1 winner) + SELECT COUNT(*) INTO last_round_matches + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = prev_stage_record.id + AND tb.round = ( + SELECT MAX(tb2.round) + FROM tournament_brackets tb2 + WHERE tb2.tournament_stage_id = prev_stage_record.id + ); + + -- If brackets haven't been created yet, fall back to calculation based on min_teams + IF last_round_matches = 0 THEN + -- Fallback: estimate based on min_teams and groups + max_teams_advancing := (prev_stage_record.min_teams / prev_stage_record.groups) / 2; + ELSE + -- Use actual number of matches in last round (each match = 1 advancing team) + max_teams_advancing := last_round_matches; + END IF; + + -- This stage must be able to accommodate the advancing teams + IF p_min_teams < max_teams_advancing THEN + RAISE EXCEPTION 'Stage % cannot accommodate % teams advancing from stage % (min_teams: %)', + p_stage_order, max_teams_advancing, p_stage_order - 1, p_min_teams + USING ERRCODE = '22000'; + END IF; + END IF; + END IF; +END; +$$; + CREATE OR REPLACE FUNCTION public.taiu_tournament_stages() RETURNS TRIGGER LANGUAGE plpgsql @@ -28,23 +101,15 @@ BEGIN _min_teams := NEW.min_teams; - -- Validate Swiss tournament requirements - IF NEW.type = 'Swiss' THEN - -- Swiss tournaments use single group - IF NEW.groups IS NOT NULL AND NEW.groups != 1 THEN - RAISE EXCEPTION 'Swiss tournaments must use a single group (groups = 1). Current: %', - NEW.groups USING ERRCODE = '22000'; - END IF; - -- Note: Odd numbers are handled by pairing with adjacent pools - END IF; - - -- Validate first stage minimum teams (must be at least 4 * number of groups) - IF current_order = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 AND NEW.type != 'Swiss' THEN - IF NEW.min_teams < 4 * NEW.groups THEN - RAISE EXCEPTION 'First stage must have at least % teams given % groups (minimum 4 teams per group)', - 4 * NEW.groups, NEW.groups USING ERRCODE = '22000'; - END IF; - END IF; + -- Validate stage using shared validation function + PERFORM validate_tournament_stage( + NEW.type, + NEW.groups, + NEW.min_teams, + current_order, + NEW.tournament_id, + NEW.id + ); FOR stage_record IN SELECT * FROM tournament_stages @@ -92,49 +157,6 @@ BEGIN END IF; END IF; - -- Validate that this stage can accommodate teams advancing from the previous stage - IF current_order > 1 THEN - DECLARE - prev_stage_record RECORD; - max_teams_advancing int; - last_round_matches int; - BEGIN - -- Get the previous stage - SELECT * INTO prev_stage_record - FROM tournament_stages - WHERE tournament_id = NEW.tournament_id AND "order" = current_order - 1; - - IF prev_stage_record.id IS NOT NULL THEN - -- Calculate max teams that can advance from previous stage - -- Count matches in the last round of the previous stage (each match produces 1 winner) - SELECT COUNT(*) INTO last_round_matches - FROM tournament_brackets tb - WHERE tb.tournament_stage_id = prev_stage_record.id - AND tb.round = ( - SELECT MAX(tb2.round) - FROM tournament_brackets tb2 - WHERE tb2.tournament_stage_id = prev_stage_record.id - ); - - -- If brackets haven't been created yet, fall back to calculation based on min_teams - IF last_round_matches = 0 THEN - -- Fallback: estimate based on min_teams and groups - max_teams_advancing := (prev_stage_record.min_teams / prev_stage_record.groups) / 2; - ELSE - -- Use actual number of matches in last round (each match = 1 advancing team) - max_teams_advancing := last_round_matches; - END IF; - - -- This stage must be able to accommodate the advancing teams - IF NEW.min_teams < max_teams_advancing THEN - RAISE EXCEPTION 'Stage % cannot accommodate % teams advancing from stage % (min_teams: %)', - current_order, max_teams_advancing, current_order - 1, NEW.min_teams - USING ERRCODE = '22000'; - END IF; - END IF; - END; - END IF; - -- Check if we're creating a decider stage (skip regeneration in that case) BEGIN PERFORM 1 FROM pg_temp.creating_decider_stage WHERE stage_id = NEW.id; @@ -192,66 +214,15 @@ BEGIN END IF; END IF; - -- Validate Swiss tournament requirements - IF NEW.type = 'Swiss' THEN - -- Swiss tournaments use single group - IF NEW.groups IS NOT NULL AND NEW.groups != 1 THEN - RAISE EXCEPTION 'Swiss tournaments must use a single group (groups = 1). Current: %', - NEW.groups USING ERRCODE = '22000'; - END IF; - -- Note: Odd numbers are handled by pairing with adjacent pools - END IF; - - -- Validate first stage minimum teams (must be at least 4 * number of groups) - IF NEW."order" = 1 AND NEW.groups IS NOT NULL AND NEW.groups > 0 AND NEW.type != 'Swiss' THEN - IF NEW.min_teams < 4 * NEW.groups THEN - RAISE EXCEPTION 'First stage must have at least % teams given % groups (minimum 4 teams per group)', - 4 * NEW.groups, NEW.groups USING ERRCODE = '22000'; - END IF; - END IF; - - -- Validate that this stage can accommodate teams advancing from the previous stage - IF NEW."order" > 1 THEN - DECLARE - prev_stage_record RECORD; - max_teams_advancing int; - last_round_matches int; - BEGIN - -- Get the previous stage - SELECT * INTO prev_stage_record - FROM tournament_stages - WHERE tournament_id = NEW.tournament_id AND "order" = NEW."order" - 1; - - IF prev_stage_record.id IS NOT NULL THEN - -- Calculate max teams that can advance from previous stage - -- Count matches in the last round of the previous stage (each match produces 1 winner) - SELECT COUNT(*) INTO last_round_matches - FROM tournament_brackets tb - WHERE tb.tournament_stage_id = prev_stage_record.id - AND tb.round = ( - SELECT MAX(tb2.round) - FROM tournament_brackets tb2 - WHERE tb2.tournament_stage_id = prev_stage_record.id - ); - - -- If brackets haven't been created yet, fall back to calculation based on min_teams - IF last_round_matches = 0 THEN - -- Fallback: estimate based on min_teams and groups - max_teams_advancing := (prev_stage_record.min_teams / prev_stage_record.groups) / 2; - ELSE - -- Use actual number of matches in last round (each match = 1 advancing team) - max_teams_advancing := last_round_matches; - END IF; - - -- This stage must be able to accommodate the advancing teams - IF NEW.min_teams < max_teams_advancing THEN - RAISE EXCEPTION 'Stage % cannot accommodate % teams advancing from stage % (min_teams: %)', - NEW."order", max_teams_advancing, NEW."order" - 1, NEW.min_teams - USING ERRCODE = '22000'; - END IF; - END IF; - END; - END IF; + -- Validate stage using shared validation function + PERFORM validate_tournament_stage( + NEW.type, + NEW.groups, + NEW.min_teams, + NEW."order", + NEW.tournament_id, + NEW.id + ); RETURN NEW; END; From 73b225bc6bd4ec9ceebc2225a2752145f6214c84 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 11:06:24 -0500 Subject: [PATCH 13/19] wip --- .../tournaments/tournament_has_min_teams.sql | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/hasura/functions/tournaments/tournament_has_min_teams.sql b/hasura/functions/tournaments/tournament_has_min_teams.sql index 8fab78f5..1a70d389 100644 --- a/hasura/functions/tournaments/tournament_has_min_teams.sql +++ b/hasura/functions/tournaments/tournament_has_min_teams.sql @@ -1,4 +1,3 @@ --- TODO CREATE OR REPLACE FUNCTION public.tournament_has_min_teams(tournament public.tournaments) RETURNS boolean LANGUAGE plpgsql STABLE @@ -7,20 +6,12 @@ DECLARE total_teams int := 0; tournament_min_teams int := 0; tournament_status text; - first_stage_type text; BEGIN -- Get tournament status for context SELECT status INTO tournament_status FROM tournaments WHERE id = tournament.id; - -- Get first stage type - SELECT ts.type INTO first_stage_type - FROM tournament_stages ts - WHERE ts.tournament_id = tournament.id - AND ts.order = 1 - LIMIT 1; - -- Get minimum teams required for stage 1 SELECT SUM(ts.min_teams) into tournament_min_teams @@ -39,15 +30,7 @@ BEGIN RAISE NOTICE 'Tournament % (status: %): %/% teams (actual/required)', tournament.id, tournament_status, total_teams, tournament_min_teams; - -- Check minimum teams requirement - IF tournament_min_teams > total_teams THEN - RETURN false; - END IF; - - -- Note: Swiss tournaments can handle odd numbers by pairing with adjacent pools - -- No strict even number requirement needed - -- Return validation result - RETURN true; + RETURN tournament_min_teams <= total_teams; END; -$$; +$$; \ No newline at end of file From 3dbfb19e3232ecb2d51880b98b4c5a5b87c62394 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 11:21:52 -0500 Subject: [PATCH 14/19] wip --- .../tournaments/advance_swiss_teams.sql | 4 + .../tournaments/binomial_coefficient.sql | 35 ++++++ .../tournaments/generate_swiss_bracket.sql | 114 +++++++++--------- hasura/functions/tournaments/seed_stage.sql | 35 +++--- .../tournaments/seed_swiss_stage.sql | 95 +++++++++++++++ 5 files changed, 208 insertions(+), 75 deletions(-) create mode 100644 hasura/functions/tournaments/binomial_coefficient.sql create mode 100644 hasura/functions/tournaments/seed_swiss_stage.sql diff --git a/hasura/functions/tournaments/advance_swiss_teams.sql b/hasura/functions/tournaments/advance_swiss_teams.sql index a8703dea..9941c07b 100644 --- a/hasura/functions/tournaments/advance_swiss_teams.sql +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -55,6 +55,7 @@ BEGIN IF next_stage_id IS NOT NULL THEN RAISE NOTICE 'Advancing % teams to next stage', array_length(advanced_teams, 1); + -- Only seed the next stage if the current stage is complete AND we have teams to advance IF stage_complete THEN RAISE NOTICE 'Swiss stage complete, advancing teams to next stage'; PERFORM seed_stage(next_stage_id); @@ -62,6 +63,9 @@ BEGIN ELSE RAISE NOTICE 'No next stage found - teams have won the tournament'; END IF; + ELSIF stage_complete THEN + -- Stage is complete but no teams advanced (shouldn't happen in normal Swiss, but handle gracefully) + RAISE NOTICE 'Swiss stage complete but no teams advanced'; END IF; END; diff --git a/hasura/functions/tournaments/binomial_coefficient.sql b/hasura/functions/tournaments/binomial_coefficient.sql new file mode 100644 index 00000000..f6f0e8b6 --- /dev/null +++ b/hasura/functions/tournaments/binomial_coefficient.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE FUNCTION public.binomial_coefficient(n int, k int) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + result numeric; + i int; +BEGIN + -- Validate inputs + IF n < 0 OR k < 0 OR k > n THEN + RETURN 0; + END IF; + + -- C(n, 0) = C(n, n) = 1 + IF k = 0 OR k = n THEN + RETURN 1; + END IF; + + -- Use symmetry: C(n, k) = C(n, n-k) + -- Choose the smaller k for efficiency + IF k > n - k THEN + k := n - k; + END IF; + + -- Calculate iteratively: C(n, k) = (n * (n-1) * ... * (n-k+1)) / (k * (k-1) * ... * 1) + result := 1; + FOR i IN 1..k LOOP + result := result * (n - k + i) / i; + END LOOP; + + RETURN result; +END; +$$; + diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql index 83a7e921..2cb1205a 100644 --- a/hasura/functions/tournaments/generate_swiss_bracket.sql +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -130,70 +130,76 @@ BEGIN k int; expected_teams_in_pool numeric; binomial_coefficient numeric; + reduction_factor numeric; + teams_advanced_eliminated numeric; + total_expected_remaining numeric; BEGIN n := round_num - 1; k := wins; - -- Calculate binomial coefficient C(n, k) = n! / (k! * (n-k)!) - -- For small n, we can use direct calculation - IF n = 0 THEN - binomial_coefficient := 1; - ELSIF n = 1 THEN - binomial_coefficient := 1; - ELSIF n = 2 THEN - IF k = 0 OR k = 2 THEN - binomial_coefficient := 1; - ELSE - binomial_coefficient := 2; - END IF; - ELSIF n = 3 THEN - IF k = 0 OR k = 3 THEN - binomial_coefficient := 1; - ELSIF k = 1 OR k = 2 THEN - binomial_coefficient := 3; - END IF; - ELSIF n = 4 THEN - IF k = 0 OR k = 4 THEN - binomial_coefficient := 1; - ELSIF k = 1 OR k = 3 THEN - binomial_coefficient := 4; - ELSIF k = 2 THEN - binomial_coefficient := 6; - END IF; - ELSIF n = 5 THEN - IF k = 0 OR k = 5 THEN - binomial_coefficient := 1; - ELSIF k = 1 OR k = 4 THEN - binomial_coefficient := 5; - ELSIF k = 2 OR k = 3 THEN - binomial_coefficient := 10; - END IF; - ELSE - binomial_coefficient := 1; - END IF; + -- Calculate binomial coefficient using helper function + binomial_coefficient := public.binomial_coefficient(n, k); -- Expected teams = team_count * C(n, k) / 2^n expected_teams_in_pool := _team_count::numeric * binomial_coefficient / POWER(2, n); -- Adjust for teams that may have advanced (wins_needed wins) or been eliminated (wins_needed losses) in previous rounds - -- After round wins_needed, teams with wins_needed-0 advance, teams with 0-wins_needed are eliminated - -- After round wins_needed+1, teams with wins_needed-1 advance, teams with 1-wins_needed are eliminated - -- So we need to reduce expected teams for later rounds + -- Calculate reduction factor based on expected teams that have advanced/eliminated by this round IF round_num >= wins_needed + 1 THEN - -- Rough estimate: by round 4, ~25% of teams may have advanced/eliminated - -- Adjust based on round - DECLARE - reduction_factor numeric; - BEGIN - IF round_num = 4 THEN - reduction_factor := 0.75; -- ~25% advanced/eliminated - ELSIF round_num = 5 THEN - reduction_factor := 0.5; -- ~50% advanced/eliminated - ELSE - reduction_factor := 0.25; -- ~75% advanced/eliminated - END IF; - expected_teams_in_pool := expected_teams_in_pool * reduction_factor; - END; + -- Calculate expected number of teams that have advanced or been eliminated by this round + -- Teams advance with wins_needed wins and < wins_needed losses + -- Teams are eliminated with wins_needed losses and < wins_needed wins + teams_advanced_eliminated := 0; + + -- Count teams that advanced (wins_needed wins, 0 to wins_needed-1 losses) + -- For each possible loss count i from 0 to wins_needed-1, calculate expected teams + FOR i IN 0..(wins_needed - 1) LOOP + DECLARE + rounds_for_advance int; + binomial_adv numeric; + BEGIN + rounds_for_advance := wins_needed + i; + IF n >= rounds_for_advance THEN + -- Teams with wins_needed wins and i losses after rounds_for_advance rounds + binomial_adv := public.binomial_coefficient(rounds_for_advance, wins_needed); + teams_advanced_eliminated := teams_advanced_eliminated + + (_team_count::numeric * binomial_adv / POWER(2, rounds_for_advance)); + END IF; + END; + END LOOP; + + -- Count teams that were eliminated (wins_needed losses, 0 to wins_needed-1 wins) + -- For each possible win count i from 0 to wins_needed-1, calculate expected teams + FOR i IN 0..(wins_needed - 1) LOOP + DECLARE + rounds_for_elim int; + binomial_elim numeric; + BEGIN + rounds_for_elim := wins_needed + i; + IF n >= rounds_for_elim THEN + -- Teams with i wins and wins_needed losses after rounds_for_elim rounds + binomial_elim := public.binomial_coefficient(rounds_for_elim, i); + teams_advanced_eliminated := teams_advanced_eliminated + + (_team_count::numeric * binomial_elim / POWER(2, rounds_for_elim)); + END IF; + END; + END LOOP; + + -- Calculate reduction factor based on remaining teams + -- Cap the advanced/eliminated count to not exceed total teams + teams_advanced_eliminated := LEAST(teams_advanced_eliminated, _team_count::numeric * 0.95); + total_expected_remaining := _team_count::numeric - teams_advanced_eliminated; + + IF total_expected_remaining > 0 AND _team_count > 0 THEN + -- Scale down expected teams proportionally to remaining teams + reduction_factor := total_expected_remaining / _team_count::numeric; + ELSE + -- Fallback: use conservative estimate based on round progression + -- Each round after wins_needed removes approximately 25% more teams + reduction_factor := GREATEST(0.1, 1.0 - (round_num - wins_needed) * 0.25); + END IF; + + expected_teams_in_pool := expected_teams_in_pool * reduction_factor; END IF; -- Round to nearest integer, but ensure at least 2 for a match diff --git a/hasura/functions/tournaments/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index 430f9195..e8beaa6f 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -85,21 +85,21 @@ BEGIN team_1_seed_val, team_1_id, team_2_seed_val, team_2_id; END LOOP; + ELSIF stage.type = 'Swiss' THEN + -- Delegate Swiss seeding to dedicated function + PERFORM public.seed_swiss_stage(stage_id); + RETURN; ELSE - -- Process first-round brackets for Swiss and elimination tournaments - -- For Swiss: assign teams to first round (round 1, pool 0-0) + -- Process first-round brackets for elimination tournaments -- For elimination: process first-round winners brackets FOR bracket IN SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed FROM tournament_brackets tb WHERE tb.tournament_stage_id = stage.id AND tb.round = 1 - AND (stage.type != 'Swiss' OR tb."group" = 0) -- Swiss filters to group 0 (0-0 pool) AND COALESCE(tb.path, 'WB') = 'WB' -- never seed or mark byes on loser brackets AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) - ORDER BY - CASE WHEN stage.type = 'Swiss' THEN tb.match_number ELSE tb."group" END ASC, - CASE WHEN stage.type = 'Swiss' THEN 0 ELSE tb.match_number END ASC + ORDER BY tb."group" ASC, tb.match_number ASC LOOP team_1_id := NULL; team_2_id := NULL; @@ -107,8 +107,8 @@ BEGIN team_2_seed_val := bracket.team_2_seed; -- For elimination brackets coming from RoundRobin/Swiss stages, use stage results - -- Otherwise (including Swiss first round), lookup teams by seed - IF stage.type != 'Swiss' AND previous_stage.id IS NOT NULL AND (previous_stage.type = 'RoundRobin' OR previous_stage.type = 'Swiss') THEN + -- Otherwise, lookup teams by seed + IF previous_stage.id IS NOT NULL AND (previous_stage.type = 'RoundRobin' OR previous_stage.type = 'Swiss') THEN IF team_1_seed_val IS NOT NULL THEN SELECT tournament_team_id INTO team_1_id FROM v_team_stage_results @@ -155,24 +155,17 @@ BEGIN END IF; -- Update bracket with teams - -- Swiss should never have byes, elimination brackets can have byes + -- Elimination brackets can have byes UPDATE tournament_brackets SET tournament_team_id_1 = team_1_id, tournament_team_id_2 = team_2_id, - bye = CASE WHEN stage.type = 'Swiss' THEN false ELSE (team_1_id IS NULL OR team_2_id IS NULL) END + bye = (team_1_id IS NULL OR team_2_id IS NULL) WHERE id = bracket.id; - IF stage.type = 'Swiss' THEN - RAISE NOTICE ' Swiss Round 1 Pool 0-0 Match %: Seed % (team %) vs Seed % (team %)', - bracket.match_number, - team_1_seed_val, team_1_id, - team_2_seed_val, team_2_id; - ELSE - RAISE NOTICE ' Bracket %: Seed % (team %) vs Seed % (team %)', - bracket.match_number, - team_1_seed_val, team_1_id, - team_2_seed_val, team_2_id; - END IF; + RAISE NOTICE ' Bracket %: Seed % (team %) vs Seed % (team %)', + bracket.match_number, + team_1_seed_val, team_1_id, + team_2_seed_val, team_2_id; END LOOP; END IF; diff --git a/hasura/functions/tournaments/seed_swiss_stage.sql b/hasura/functions/tournaments/seed_swiss_stage.sql new file mode 100644 index 00000000..656ff511 --- /dev/null +++ b/hasura/functions/tournaments/seed_swiss_stage.sql @@ -0,0 +1,95 @@ +CREATE OR REPLACE FUNCTION public.seed_swiss_stage(stage_id uuid) RETURNS VOID + LANGUAGE plpgsql + AS $$ +DECLARE + stage record; + bracket record; + team_1_id uuid; + team_2_id uuid; + team_1_seed_val int; + team_2_seed_val int; + teams_assigned_count int; +BEGIN + RAISE NOTICE '=== STARTING SWISS STAGE SEEDING ==='; + RAISE NOTICE 'Stage ID: %', stage_id; + + SELECT * INTO stage FROM tournament_stages WHERE id = stage_id; + + IF stage IS NULL THEN + RAISE EXCEPTION 'Stage % not found', stage_id; + END IF; + + IF stage.type != 'Swiss' THEN + RAISE EXCEPTION 'seed_swiss_stage can only be used for Swiss tournament stages'; + END IF; + + teams_assigned_count := 0; + + RAISE NOTICE '--- Processing Swiss Stage % (groups: %) ---', stage."order", stage.groups; + + -- Process first-round brackets for Swiss tournaments + -- For Swiss: assign teams to first round (round 1, pool 0-0) + FOR bracket IN + SELECT tb.id, tb.round, tb."group", tb.match_number, tb.team_1_seed, tb.team_2_seed + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = stage.id + AND tb.round = 1 + AND tb."group" = 0 -- Swiss filters to group 0 (0-0 pool) + AND COALESCE(tb.path, 'WB') = 'WB' -- never seed or mark byes on loser brackets + AND (tb.team_1_seed IS NOT NULL OR tb.team_2_seed IS NOT NULL) + ORDER BY tb.match_number ASC + LOOP + team_1_id := NULL; + team_2_id := NULL; + team_1_seed_val := bracket.team_1_seed; + team_2_seed_val := bracket.team_2_seed; + + -- Find team with matching seed for position 1 + IF team_1_seed_val IS NOT NULL THEN + SELECT id INTO team_1_id + FROM tournament_teams + WHERE tournament_id = stage.tournament_id + AND eligible_at IS NOT NULL + AND seed = team_1_seed_val + LIMIT 1; + END IF; + + -- Find team with matching seed for position 2 + IF team_2_seed_val IS NOT NULL THEN + SELECT id INTO team_2_id + FROM tournament_teams + WHERE tournament_id = stage.tournament_id + AND eligible_at IS NOT NULL + AND seed = team_2_seed_val + LIMIT 1; + END IF; + + IF team_1_id IS NOT NULL THEN + teams_assigned_count := teams_assigned_count + 1; + END IF; + + IF team_2_id IS NOT NULL THEN + teams_assigned_count := teams_assigned_count + 1; + END IF; + + -- Update bracket with teams + -- Swiss should never have byes + UPDATE tournament_brackets + SET tournament_team_id_1 = team_1_id, + tournament_team_id_2 = team_2_id, + bye = false + WHERE id = bracket.id; + + RAISE NOTICE ' Swiss Round 1 Pool 0-0 Match %: Seed % (team %) vs Seed % (team %)', + bracket.match_number, + team_1_seed_val, team_1_id, + team_2_seed_val, team_2_id; + END LOOP; + + RAISE NOTICE '=== SWISS STAGE SEEDING COMPLETE ==='; + RAISE NOTICE 'Total teams assigned: %', teams_assigned_count; + + RETURN; +END; +$$; + From f16d49a5b9872a7ac9deeb4ab14e1b7c390cb13b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 14:10:49 -0500 Subject: [PATCH 15/19] wip --- .../tournaments/generate_swiss_bracket.sql | 75 +++++-------------- 1 file changed, 18 insertions(+), 57 deletions(-) diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql index 2cb1205a..c6b545a6 100644 --- a/hasura/functions/tournaments/generate_swiss_bracket.sql +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -141,65 +141,26 @@ BEGIN binomial_coefficient := public.binomial_coefficient(n, k); -- Expected teams = team_count * C(n, k) / 2^n + -- This gives the theoretical expected number of teams in this W/L pool expected_teams_in_pool := _team_count::numeric * binomial_coefficient / POWER(2, n); - -- Adjust for teams that may have advanced (wins_needed wins) or been eliminated (wins_needed losses) in previous rounds - -- Calculate reduction factor based on expected teams that have advanced/eliminated by this round - IF round_num >= wins_needed + 1 THEN - -- Calculate expected number of teams that have advanced or been eliminated by this round - -- Teams advance with wins_needed wins and < wins_needed losses - -- Teams are eliminated with wins_needed losses and < wins_needed wins - teams_advanced_eliminated := 0; - - -- Count teams that advanced (wins_needed wins, 0 to wins_needed-1 losses) - -- For each possible loss count i from 0 to wins_needed-1, calculate expected teams - FOR i IN 0..(wins_needed - 1) LOOP - DECLARE - rounds_for_advance int; - binomial_adv numeric; - BEGIN - rounds_for_advance := wins_needed + i; - IF n >= rounds_for_advance THEN - -- Teams with wins_needed wins and i losses after rounds_for_advance rounds - binomial_adv := public.binomial_coefficient(rounds_for_advance, wins_needed); - teams_advanced_eliminated := teams_advanced_eliminated + - (_team_count::numeric * binomial_adv / POWER(2, rounds_for_advance)); - END IF; - END; - END LOOP; - - -- Count teams that were eliminated (wins_needed losses, 0 to wins_needed-1 wins) - -- For each possible win count i from 0 to wins_needed-1, calculate expected teams - FOR i IN 0..(wins_needed - 1) LOOP - DECLARE - rounds_for_elim int; - binomial_elim numeric; - BEGIN - rounds_for_elim := wins_needed + i; - IF n >= rounds_for_elim THEN - -- Teams with i wins and wins_needed losses after rounds_for_elim rounds - binomial_elim := public.binomial_coefficient(rounds_for_elim, i); - teams_advanced_eliminated := teams_advanced_eliminated + - (_team_count::numeric * binomial_elim / POWER(2, rounds_for_elim)); - END IF; - END; - END LOOP; - - -- Calculate reduction factor based on remaining teams - -- Cap the advanced/eliminated count to not exceed total teams - teams_advanced_eliminated := LEAST(teams_advanced_eliminated, _team_count::numeric * 0.95); - total_expected_remaining := _team_count::numeric - teams_advanced_eliminated; - - IF total_expected_remaining > 0 AND _team_count > 0 THEN - -- Scale down expected teams proportionally to remaining teams - reduction_factor := total_expected_remaining / _team_count::numeric; - ELSE - -- Fallback: use conservative estimate based on round progression - -- Each round after wins_needed removes approximately 25% more teams - reduction_factor := GREATEST(0.1, 1.0 - (round_num - wins_needed) * 0.25); - END IF; - - expected_teams_in_pool := expected_teams_in_pool * reduction_factor; + -- For later rounds, we need to account for teams that have already advanced or been eliminated + -- However, the binomial distribution already accounts for the natural distribution + -- We only apply a light adjustment for very late rounds to account for edge cases + -- Note: Round (wins_needed + 2) is the final round, so we don't apply reduction there + IF round_num > wins_needed + 2 THEN + -- For rounds beyond wins_needed + 2, apply a conservative reduction + -- This accounts for the fact that some teams have advanced/eliminated + -- But we use a much lighter touch to avoid over-reduction + DECLARE + rounds_past_threshold int; + light_reduction numeric; + BEGIN + rounds_past_threshold := round_num - (wins_needed + 2); + -- Apply a very light reduction: 5% per round past threshold, max 20% + light_reduction := GREATEST(0.8, 1.0 - (rounds_past_threshold * 0.05)); + expected_teams_in_pool := expected_teams_in_pool * light_reduction; + END; END IF; -- Round to nearest integer, but ensure at least 2 for a match From c2c9a7a7eedfcb7264dc962e358a83e900c12440 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 14:27:54 -0500 Subject: [PATCH 16/19] wip --- .../assign_teams_to_swiss_pools.sql | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql index 337cb299..ea8db8ab 100644 --- a/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql +++ b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql @@ -61,16 +61,43 @@ BEGIN matches_needed := array_length(teams_to_pair, 1) / 2; + -- For Swiss tournaments, use bracket order for pairing + -- Filter bracket_order to only include valid seed positions (1 to teams_to_pair.length) bracket_order := generate_bracket_order(array_length(teams_to_pair, 1)); + DECLARE + filtered_order int[]; + valid_seed int; + BEGIN + filtered_order := ARRAY[]::int[]; + FOREACH valid_seed IN ARRAY bracket_order LOOP + IF valid_seed >= 1 AND valid_seed <= array_length(teams_to_pair, 1) THEN + filtered_order := filtered_order || valid_seed; + END IF; + END LOOP; + bracket_order := filtered_order; + END; + + -- Validate we have enough valid seed positions + IF array_length(bracket_order, 1) < matches_needed * 2 THEN + RAISE EXCEPTION 'Not enough valid seed positions in bracket order for pool %-% (needed: %, got: %)', + pool_record.wins, pool_record.losses, matches_needed * 2, array_length(bracket_order, 1); + END IF; match_counter := 1; FOR i IN 1..matches_needed LOOP + -- Get seed positions from filtered bracket order seed_1_idx := bracket_order[(i - 1) * 2 + 1]; seed_2_idx := bracket_order[(i - 1) * 2 + 2]; team_1_id := teams_to_pair[seed_1_idx]; team_2_id := teams_to_pair[seed_2_idx]; + -- Validate that teams are not NULL + IF team_1_id IS NULL OR team_2_id IS NULL THEN + RAISE EXCEPTION 'NULL team found in pool %-% at match % (seed_1_idx: %, seed_2_idx: %, teams_to_pair length: %)', + pool_record.wins, pool_record.losses, match_counter, seed_1_idx, seed_2_idx, array_length(teams_to_pair, 1); + END IF; + SELECT id INTO bracket_record FROM tournament_brackets WHERE tournament_stage_id = _stage_id @@ -80,7 +107,8 @@ BEGIN LIMIT 1; IF bracket_record IS NULL THEN - RAISE EXCEPTION 'Bracket record not found for match %', match_counter; + RAISE EXCEPTION 'Bracket record not found for match % in pool %-% (group %)', + match_counter, pool_record.wins, pool_record.losses, pool_group; END IF; UPDATE tournament_brackets @@ -88,6 +116,9 @@ BEGIN tournament_team_id_2 = team_2_id, bye = false WHERE id = bracket_record.id; + + -- Mark both teams as used to prevent double-assignment + used_teams := used_teams || team_1_id || team_2_id; RAISE NOTICE ' Match %: Team % vs Team %', match_counter, team_1_id, team_2_id; match_counter := match_counter + 1; From 38ecf3cc40b532e0350163809e23be28a55069ab Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 14:56:23 -0500 Subject: [PATCH 17/19] wip --- .../tournaments/generate_swiss_bracket.sql | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/hasura/functions/tournaments/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql index c6b545a6..c062fdb6 100644 --- a/hasura/functions/tournaments/generate_swiss_bracket.sql +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -122,67 +122,48 @@ BEGIN -- Calculate pool group: wins * 100 + losses pool_group := wins * 100 + losses; - -- Calculate expected number of teams in this pool using binomial distribution - -- For round N, with W wins and L losses (W+L = N-1): - -- Expected teams = team_count * C(N-1, W) / 2^(N-1) + -- Use the unified formula for matches: matches(N, r, w, l) + -- If r <= 3: matches = (1/2) * C(r-1, w) * (N / 2^(r-1)) + -- If r = 4 AND (w,l) ∈ {(2,1), (1,2)}: matches = 3N / 16 + -- If r = 5 AND (w,l) = (2,2): matches = 3N / 16 + -- Otherwise: 0 (advanced/eliminated pools) DECLARE - n int; - k int; - expected_teams_in_pool numeric; - binomial_coefficient numeric; - reduction_factor numeric; - teams_advanced_eliminated numeric; - total_expected_remaining numeric; + matches_calc numeric; BEGIN - n := round_num - 1; - k := wins; - - -- Calculate binomial coefficient using helper function - binomial_coefficient := public.binomial_coefficient(n, k); - - -- Expected teams = team_count * C(n, k) / 2^n - -- This gives the theoretical expected number of teams in this W/L pool - expected_teams_in_pool := _team_count::numeric * binomial_coefficient / POWER(2, n); - - -- For later rounds, we need to account for teams that have already advanced or been eliminated - -- However, the binomial distribution already accounts for the natural distribution - -- We only apply a light adjustment for very late rounds to account for edge cases - -- Note: Round (wins_needed + 2) is the final round, so we don't apply reduction there - IF round_num > wins_needed + 2 THEN - -- For rounds beyond wins_needed + 2, apply a conservative reduction - -- This accounts for the fact that some teams have advanced/eliminated - -- But we use a much lighter touch to avoid over-reduction + IF round_num <= 3 THEN + -- Use binomial distribution for early rounds DECLARE - rounds_past_threshold int; - light_reduction numeric; + n int; + k int; + binomial_coefficient numeric; BEGIN - rounds_past_threshold := round_num - (wins_needed + 2); - -- Apply a very light reduction: 5% per round past threshold, max 20% - light_reduction := GREATEST(0.8, 1.0 - (rounds_past_threshold * 0.05)); - expected_teams_in_pool := expected_teams_in_pool * light_reduction; + n := round_num - 1; + k := wins; + binomial_coefficient := public.binomial_coefficient(n, k); + -- Formula: (1/2) * C(r-1, w) * (N / 2^(r-1)) + matches_calc := (1.0 / 2.0) * binomial_coefficient * (_team_count::numeric / POWER(2, n)); END; - END IF; - - -- Round to nearest integer, but ensure at least 2 for a match - IF expected_teams_in_pool < 2 THEN - matches_needed := 0; + ELSIF round_num = 4 AND ((wins = 2 AND losses = 1) OR (wins = 1 AND losses = 2)) THEN + -- Round 4: pools (2,1) and (1,2) get 3N/16 matches + matches_calc := 3.0 * _team_count::numeric / 16.0; + ELSIF round_num = 5 AND wins = 2 AND losses = 2 THEN + -- Round 5: pool (2,2) gets 3N/16 matches + matches_calc := 3.0 * _team_count::numeric / 16.0; ELSE - matches_needed := CEIL(expected_teams_in_pool / 2.0)::int; + -- All other pools in rounds 4+ are advanced/eliminated + matches_calc := 0; END IF; - -- Cap at reasonable maximum (half of team count per pool) + -- Round up to get integer matches + matches_needed := CEIL(matches_calc)::int; + + -- Ensure we don't exceed reasonable bounds IF matches_needed > _team_count / 2 THEN matches_needed := _team_count / 2; END IF; - -- Ensure minimum of 1 match if we expect teams (for odd numbers handled later) - IF matches_needed = 0 AND expected_teams_in_pool >= 1.5 THEN - matches_needed := 1; - END IF; - - RAISE NOTICE ' Creating pool %-% (group %): % matches (expected ~% teams, binomial C(%,%)=%)', - wins, losses, pool_group, matches_needed, - ROUND(expected_teams_in_pool, 1), n, k, binomial_coefficient; + RAISE NOTICE ' Creating pool %-% (group %): % matches (calculated: ~%)', + wins, losses, pool_group, matches_needed, ROUND(matches_calc, 2); END; -- Create placeholder matches for this pool From afed07fadb6c0600dc6a2b34522150b0c58afa33 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 15:14:10 -0500 Subject: [PATCH 18/19] wi --- .../tournaments/tournament_bracket_eta.sql | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/hasura/functions/tournaments/tournament_bracket_eta.sql b/hasura/functions/tournaments/tournament_bracket_eta.sql index bf759075..f6340abb 100644 --- a/hasura/functions/tournaments/tournament_bracket_eta.sql +++ b/hasura/functions/tournaments/tournament_bracket_eta.sql @@ -61,6 +61,79 @@ BEGIN AND round = round_record.round; END; END LOOP; + ELSIF stage_record.stage_type = 'Swiss' THEN + -- For Swiss stages: find latest finished round, then calculate based on round difference + DECLARE + latest_finished_round int; + latest_finished_round_time timestamptz; + swiss_base_start_time timestamptz; + BEGIN + -- Find the latest round that has finished + SELECT MAX(tb.round) + INTO latest_finished_round + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = stage_record.tournament_stage_id + AND tb.finished = true; + + -- Get the finish time of the latest finished round + IF latest_finished_round IS NOT NULL THEN + SELECT MAX(COALESCE(m.ended_at, m.started_at + interval '1 hour')) + INTO latest_finished_round_time + FROM tournament_brackets tb + INNER JOIN matches m ON m.id = tb.match_id + WHERE tb.tournament_stage_id = stage_record.tournament_stage_id + AND tb.round = latest_finished_round + AND m.id IS NOT NULL; + END IF; + + -- Get base start time (earliest match start or tournament start) + SELECT MIN(COALESCE(m.started_at, m.scheduled_at)) + INTO swiss_base_start_time + FROM tournament_brackets tb + INNER JOIN matches m ON m.id = tb.match_id + WHERE tb.tournament_stage_id = stage_record.tournament_stage_id + AND (m.started_at IS NOT NULL OR (m.scheduled_at IS NOT NULL AND m.scheduled_at <= now())); + + IF swiss_base_start_time IS NULL THEN + swiss_base_start_time := base_start_time; + END IF; + + FOR round_record IN + SELECT DISTINCT tb.round + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = stage_record.tournament_stage_id + ORDER BY tb.round + LOOP + DECLARE + round_start_time timestamptz; + round_diff int; + BEGIN + -- If we have a finished round, calculate from its finish time + IF latest_finished_round IS NOT NULL AND latest_finished_round_time IS NOT NULL AND round_record.round > latest_finished_round THEN + round_diff := round_record.round - latest_finished_round - 1; + round_start_time := latest_finished_round_time + (round_diff * interval '1 hour'); + ELSE + -- No finished rounds yet, use base calculation (round * 1 hour) + round_start_time := swiss_base_start_time + (round_record.round * interval '1 hour'); + END IF; + + -- Update all brackets in this round + UPDATE tournament_brackets + SET scheduled_eta = CASE + -- If bracket has a match, use its actual start time + WHEN match_id IS NOT NULL THEN ( + SELECT COALESCE(m.started_at, m.scheduled_at) + FROM matches m + WHERE m.id = tournament_brackets.match_id + ) + -- Otherwise use the calculated round start time + ELSE round_start_time + END + WHERE tournament_stage_id = stage_record.tournament_stage_id + AND round = round_record.round; + END; + END LOOP; + END; ELSE -- For elimination brackets, use the existing logic (parent bracket based) FOR round_record IN From 31ef00316524d1ecd936d65e961ce4ee47654ae7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 29 Dec 2025 15:14:53 -0500 Subject: [PATCH 19/19] wi --- hasura/functions/tournaments/tournament_bracket_eta.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hasura/functions/tournaments/tournament_bracket_eta.sql b/hasura/functions/tournaments/tournament_bracket_eta.sql index f6340abb..c6dad4c2 100644 --- a/hasura/functions/tournaments/tournament_bracket_eta.sql +++ b/hasura/functions/tournaments/tournament_bracket_eta.sql @@ -110,7 +110,7 @@ BEGIN BEGIN -- If we have a finished round, calculate from its finish time IF latest_finished_round IS NOT NULL AND latest_finished_round_time IS NOT NULL AND round_record.round > latest_finished_round THEN - round_diff := round_record.round - latest_finished_round - 1; + round_diff := round_record.round - latest_finished_round; round_start_time := latest_finished_round_time + (round_diff * interval '1 hour'); ELSE -- No finished rounds yet, use base calculation (round * 1 hour)