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. - - 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/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..9941c07b --- /dev/null +++ b/hasura/functions/tournaments/advance_swiss_teams.sql @@ -0,0 +1,76 @@ +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 + 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; + + 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; + + 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; + + 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); + + 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); + + -- 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); + END IF; + 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; + + RAISE NOTICE '=== Swiss Advancement Complete ==='; +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..ea8db8ab --- /dev/null +++ b/hasura/functions/tournaments/assign_teams_to_swiss_pools.sql @@ -0,0 +1,132 @@ +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[]; + + 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; + + 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 + AND round = _round + AND "group" = pool_group + AND match_number = match_counter + LIMIT 1; + + IF bracket_record IS NULL THEN + 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 + SET tournament_team_id_1 = team_1_id, + 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; + END LOOP; + END; + END LOOP; + + RAISE NOTICE '=== Team Assignment Complete ==='; +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/check_swiss_round_complete.sql b/hasura/functions/tournaments/check_swiss_round_complete.sql new file mode 100644 index 00000000..15c48f49 --- /dev/null +++ b/hasura/functions/tournaments/check_swiss_round_complete.sql @@ -0,0 +1,23 @@ +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 + SELECT COUNT(*) INTO unfinished_count + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = _stage_id + AND tb.round = _round + AND tb.finished = false; + + SELECT COUNT(*) INTO total_matches + FROM tournament_brackets tb + WHERE tb.tournament_stage_id = _stage_id + AND tb.round = _round; + + RETURN unfinished_count = 0 AND total_matches > 0; +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/generate_swiss_bracket.sql b/hasura/functions/tournaments/generate_swiss_bracket.sql new file mode 100644 index 00000000..c062fdb6 --- /dev/null +++ b/hasura/functions/tournaments/generate_swiss_bracket.sql @@ -0,0 +1,228 @@ +CREATE OR REPLACE FUNCTION public.generate_swiss_bracket(_stage_id uuid, _team_count int) +RETURNS void +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; + pool_group numeric; + matches_needed int; + match_num int; + bracket_order int[]; + seed_1 int; + seed_2 int; + bracket_idx int; +BEGIN + -- 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; + + -- 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 + -- 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 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(wins_needed, round_num - 1) LOOP + losses := (round_num - 1) - wins; + + -- 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 (wins_needed wins, < wins_needed losses) + -- These teams won't play more matches + 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 (wins_needed losses) + -- These teams won't play more matches + IF losses = wins_needed THEN + RAISE NOTICE ' Skipping pool %-% (eliminated)', wins, losses; + CONTINUE; + END IF; + + -- Calculate pool group: wins * 100 + losses + pool_group := wins * 100 + losses; + + -- 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 + matches_calc numeric; + BEGIN + IF round_num <= 3 THEN + -- Use binomial distribution for early rounds + DECLARE + n int; + k int; + binomial_coefficient numeric; + BEGIN + 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; + 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 + -- All other pools in rounds 4+ are advanced/eliminated + matches_calc := 0; + END IF; + + -- 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; + + RAISE NOTICE ' Creating pool %-% (group %): % matches (calculated: ~%)', + wins, losses, pool_group, matches_needed, ROUND(matches_calc, 2); + 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/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/seed_stage.sql b/hasura/functions/tournaments/seed_stage.sql index 74af2426..e8beaa6f 100644 --- a/hasura/functions/tournaments/seed_stage.sql +++ b/hasura/functions/tournaments/seed_stage.sql @@ -29,7 +29,7 @@ BEGIN teams_assigned_count := 0; RAISE NOTICE '--- Processing Stage % (groups: %, type: %) ---', stage."order", stage.groups, stage.type; - + IF stage.type = 'RoundRobin' THEN -- Process all RoundRobin brackets which have seed positions set FOR bracket IN @@ -85,14 +85,20 @@ 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 *winners* brackets which have seed positions set (for elimination brackets) + -- 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 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."group" ASC, tb.match_number ASC LOOP team_1_id := NULL; @@ -100,7 +106,9 @@ 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 + -- For elimination brackets coming from RoundRobin/Swiss stages, use stage results + -- 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 @@ -142,11 +150,12 @@ 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 + -- Elimination brackets can have byes UPDATE tournament_brackets SET tournament_team_id_1 = team_1_id, tournament_team_id_2 = team_2_id, 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; +$$; + diff --git a/hasura/functions/tournaments/tournament_bracket_eta.sql b/hasura/functions/tournaments/tournament_bracket_eta.sql index bf759075..c6dad4c2 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; + 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 diff --git a/hasura/functions/tournaments/tournament_has_min_teams.sql b/hasura/functions/tournaments/tournament_has_min_teams.sql index 51dd70d7..1a70d389 100644 --- a/hasura/functions/tournaments/tournament_has_min_teams.sql +++ b/hasura/functions/tournaments/tournament_has_min_teams.sql @@ -33,4 +33,4 @@ BEGIN -- Return validation result RETURN tournament_min_teams <= total_teams; END; -$$; +$$; \ No newline at end of file diff --git a/hasura/functions/tournaments/update_tournament_bracket.sql b/hasura/functions/tournaments/update_tournament_bracket.sql index 8d4a500f..1097a805 100644 --- a/hasura/functions/tournaments/update_tournament_bracket.sql +++ b/hasura/functions/tournaments/update_tournament_bracket.sql @@ -52,6 +52,14 @@ 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 + 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; + + PERFORM advance_swiss_teams(bracket.tournament_stage_id); + + PERFORM assign_teams_to_swiss_pools(bracket.tournament_stage_id, bracket.round + 1); + 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..e7bd3e12 100644 --- a/hasura/functions/tournaments/update_tournament_stages.sql +++ b/hasura/functions/tournaments/update_tournament_stages.sql @@ -129,6 +129,20 @@ BEGIN CONTINUE; END IF; + -- For Swiss tournaments, generate entire bracket upfront with all rounds and pools + IF stage_type = 'Swiss' THEN + 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; + + PERFORM generate_swiss_bracket(stage.id, effective_teams); + + 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..2ccbfc53 100644 --- a/hasura/triggers/tournament_stages.sql +++ b/hasura/triggers/tournament_stages.sql @@ -1,3 +1,77 @@ +-- 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 @@ -27,13 +101,15 @@ BEGIN _min_teams := NEW.min_teams; - -- 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 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 @@ -81,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; @@ -181,56 +214,15 @@ BEGIN END IF; 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.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;