Skip to content

Commit 07c4d21

Browse files
authored
bug: auto-complete bye matches in losers bracket (#133)
Co-authored-by: Flegma <Flegma@users.noreply.github.com>
1 parent 4ea14ad commit 07c4d21

8 files changed

Lines changed: 239 additions & 33 deletions

hasura/functions/tournaments/advance_byes_for_tournament.sql

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,46 @@ BEGIN
4646
WHERE id = v_parent_bracket_id;
4747
END IF;
4848

49+
-- Mark the bye bracket as finished so downstream bye resolution
50+
-- (e.g. LB R1 brackets) can detect that no loser will arrive
51+
UPDATE tournament_brackets
52+
SET finished = true
53+
WHERE id = bracket.id;
54+
4955
RAISE NOTICE ' Advanced team % from bracket % to parent %', winner_id, bracket.id, v_parent_bracket_id;
5056
END LOOP;
5157

58+
-- After all WB byes are resolved, check for dead LB brackets:
59+
-- brackets with 0 teams where all feeders are finished (e.g. both WB feeders were byes).
60+
-- Process by round so cascading dead byes propagate correctly.
61+
DECLARE
62+
dead_bracket tournament_brackets%ROWTYPE;
63+
BEGIN
64+
FOR dead_bracket IN
65+
SELECT tb.*
66+
FROM tournament_brackets tb
67+
JOIN tournament_stages ts ON tb.tournament_stage_id = ts.id
68+
WHERE ts.tournament_id = p_tournament_id
69+
AND tb.match_id IS NULL
70+
AND tb.finished = false
71+
AND tb.tournament_team_id_1 IS NULL
72+
AND tb.tournament_team_id_2 IS NULL
73+
AND NOT EXISTS (
74+
SELECT 1 FROM tournament_brackets child
75+
WHERE (child.parent_bracket_id = tb.id OR child.loser_parent_bracket_id = tb.id)
76+
AND child.finished = false
77+
)
78+
AND EXISTS (
79+
SELECT 1 FROM tournament_brackets child
80+
WHERE child.parent_bracket_id = tb.id OR child.loser_parent_bracket_id = tb.id
81+
)
82+
ORDER BY tb.round, tb.match_number
83+
LOOP
84+
RAISE NOTICE ' Resolving dead bracket % (% R%M%)', dead_bracket.id, dead_bracket.path, dead_bracket.round, dead_bracket.match_number;
85+
PERFORM resolve_bracket_bye(dead_bracket);
86+
END LOOP;
87+
END;
88+
5289
RETURN;
5390
END;
5491
$$;
Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
CREATE OR REPLACE FUNCTION public.assign_team_to_bracket_slot(
22
_target_bracket_id uuid,
3-
_team_id uuid
3+
_team_id uuid,
4+
_source_bracket_id uuid DEFAULT NULL
45
) RETURNS VOID
56
LANGUAGE plpgsql
67
AS $$
78
DECLARE
89
target_bracket tournament_brackets%ROWTYPE;
10+
slot_position int;
911
BEGIN
1012
SELECT * INTO target_bracket
1113
FROM tournament_brackets
@@ -16,14 +18,48 @@ BEGIN
1618
RETURN;
1719
END IF;
1820

19-
IF target_bracket.tournament_team_id_1 IS NULL THEN
21+
-- Determine the correct slot from feeder ordering:
22+
-- 1. Loser drops (loser_parent_bracket_id) come before winner feeds (parent_bracket_id)
23+
-- 2. Within same type, order by round then match_number
24+
-- This matches the bracket generation layout.
25+
IF _source_bracket_id IS NOT NULL THEN
26+
SELECT pos INTO slot_position
27+
FROM (
28+
SELECT f.id,
29+
row_number() OVER (
30+
ORDER BY
31+
CASE WHEN f.loser_parent_bracket_id = _target_bracket_id THEN 0 ELSE 1 END,
32+
f.round,
33+
f.match_number
34+
) AS pos
35+
FROM tournament_brackets f
36+
WHERE f.parent_bracket_id = _target_bracket_id
37+
OR f.loser_parent_bracket_id = _target_bracket_id
38+
) ranked
39+
WHERE ranked.id = _source_bracket_id;
40+
END IF;
41+
42+
IF slot_position = 1 THEN
2043
UPDATE tournament_brackets
2144
SET tournament_team_id_1 = _team_id
22-
WHERE id = _target_bracket_id;
23-
ELSIF target_bracket.tournament_team_id_2 IS NULL THEN
45+
WHERE id = _target_bracket_id
46+
AND tournament_team_id_1 IS NULL;
47+
ELSIF slot_position = 2 THEN
2448
UPDATE tournament_brackets
2549
SET tournament_team_id_2 = _team_id
26-
WHERE id = _target_bracket_id;
50+
WHERE id = _target_bracket_id
51+
AND tournament_team_id_2 IS NULL;
52+
ELSE
53+
-- Fallback: first empty slot (for callers without source bracket)
54+
IF target_bracket.tournament_team_id_1 IS NULL THEN
55+
UPDATE tournament_brackets
56+
SET tournament_team_id_1 = _team_id
57+
WHERE id = _target_bracket_id;
58+
ELSIF target_bracket.tournament_team_id_2 IS NULL THEN
59+
UPDATE tournament_brackets
60+
SET tournament_team_id_2 = _team_id
61+
WHERE id = _target_bracket_id;
62+
END IF;
2763
END IF;
2864
END;
2965
$$;

hasura/functions/tournaments/create_round_robin_matches.sql

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ DECLARE
1414
team_count int;
1515
round_count int;
1616
matches_per_round int;
17-
match_counter int;
17+
1818
round_num int;
1919
i int;
2020
k int;
@@ -58,8 +58,6 @@ BEGIN
5858
RAISE NOTICE 'Creating round robin matches for % teams: % rounds, % matches per round, starting at round %',
5959
team_count, round_count, matches_per_round, _start_round;
6060

61-
match_counter := 0;
62-
6361
-- Generate round robin matches using rotating algorithm
6462
FOR round_num IN 1..round_count LOOP
6563
IF use_team_ids THEN
@@ -110,37 +108,35 @@ BEGIN
110108
team_2_id := NULL;
111109
END IF;
112110

113-
match_counter := match_counter + 1;
114-
115111
INSERT INTO tournament_brackets (
116-
round,
117-
tournament_stage_id,
118-
match_number,
119-
"group",
120-
team_1_seed,
121-
team_2_seed,
112+
round,
113+
tournament_stage_id,
114+
match_number,
115+
"group",
116+
team_1_seed,
117+
team_2_seed,
122118
path,
123119
tournament_team_id_1,
124120
tournament_team_id_2
125121
)
126122
VALUES (
127-
_start_round + round_num - 1,
128-
_stage_id,
129-
match_counter,
130-
_group,
131-
team_1_seed,
132-
team_2_seed,
123+
_start_round + round_num - 1,
124+
_stage_id,
125+
i,
126+
_group,
127+
team_1_seed,
128+
team_2_seed,
133129
'WB',
134130
team_1_id,
135131
team_2_id
136132
);
137-
133+
138134
IF use_team_ids THEN
139-
RAISE NOTICE 'Created match %: round %, team % vs team %',
140-
match_counter, _start_round + round_num - 1, team_1_id, team_2_id;
135+
RAISE NOTICE 'Created match %: round %, team % vs team %',
136+
i, _start_round + round_num - 1, team_1_id, team_2_id;
141137
ELSE
142-
RAISE NOTICE 'Created match %: round %, seed % vs seed %',
143-
match_counter, _start_round + round_num - 1, team_1_seed, team_2_seed;
138+
RAISE NOTICE 'Created match %: round %, seed % vs seed %',
139+
i, _start_round + round_num - 1, team_1_seed, team_2_seed;
144140
END IF;
145141
END LOOP;
146142
END LOOP;
@@ -161,7 +157,7 @@ BEGIN
161157
END LOOP;
162158
END IF;
163159

164-
RAISE NOTICE 'Created % round robin matches starting at round %', match_counter, _start_round;
160+
RAISE NOTICE 'Created % round robin matches starting at round %', round_count * matches_per_round, _start_round;
165161
END;
166162
$$;
167163

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
-- Detects and resolves "runtime byes" in elimination brackets.
2+
-- A runtime bye occurs when a bracket receives exactly one team but has
3+
-- no remaining feeder brackets that could provide the second team.
4+
-- This happens in double-elimination losers brackets after LB R1 pruning:
5+
-- the pruned LB R1 match can no longer feed its parent LB R2 bracket,
6+
-- so the WB loser that drops into LB R2 has no opponent.
7+
CREATE OR REPLACE FUNCTION public.resolve_bracket_bye(
8+
_bracket tournament_brackets
9+
) RETURNS boolean
10+
LANGUAGE plpgsql
11+
AS $$
12+
DECLARE
13+
current_bracket tournament_brackets%ROWTYPE;
14+
pending_feeders int;
15+
lone_team_id uuid;
16+
tournament_id uuid;
17+
BEGIN
18+
-- Re-read from disk: the passed-in row may be stale when called from
19+
-- a resume loop where earlier iterations cascaded and already resolved this bracket.
20+
SELECT * INTO current_bracket
21+
FROM tournament_brackets WHERE id = _bracket.id;
22+
23+
IF current_bracket IS NULL THEN
24+
RETURN false;
25+
END IF;
26+
27+
-- Both teams present: nothing to resolve
28+
IF (current_bracket.tournament_team_id_1 IS NOT NULL AND current_bracket.tournament_team_id_2 IS NOT NULL) THEN
29+
RETURN false;
30+
END IF;
31+
32+
IF current_bracket.finished = true OR current_bracket.match_id IS NOT NULL THEN
33+
RETURN false;
34+
END IF;
35+
36+
-- Check if any feeders can still provide a team.
37+
SELECT COUNT(*) INTO pending_feeders
38+
FROM tournament_brackets child
39+
WHERE (child.parent_bracket_id = current_bracket.id
40+
OR child.loser_parent_bracket_id = current_bracket.id)
41+
AND child.finished = false;
42+
43+
IF pending_feeders > 0 THEN
44+
RETURN false;
45+
END IF;
46+
47+
lone_team_id := COALESCE(current_bracket.tournament_team_id_1, current_bracket.tournament_team_id_2);
48+
49+
-- Mark as bye and finished
50+
UPDATE tournament_brackets
51+
SET bye = true, finished = true
52+
WHERE id = current_bracket.id;
53+
54+
IF lone_team_id IS NOT NULL THEN
55+
-- Runtime bye: one team, no pending feeders → advance
56+
RAISE NOTICE 'Resolving runtime bye: bracket %, team % advanced to parent %',
57+
current_bracket.id, lone_team_id, current_bracket.parent_bracket_id;
58+
59+
IF current_bracket.parent_bracket_id IS NOT NULL THEN
60+
PERFORM public.assign_team_to_bracket_slot(current_bracket.parent_bracket_id, lone_team_id, current_bracket.id);
61+
END IF;
62+
63+
-- A bye produces no loser. Check if the loser_parent bracket is now
64+
-- a dead bracket (0 teams, all feeders finished).
65+
IF current_bracket.loser_parent_bracket_id IS NOT NULL THEN
66+
DECLARE
67+
loser_target tournament_brackets%ROWTYPE;
68+
BEGIN
69+
SELECT * INTO loser_target
70+
FROM tournament_brackets WHERE id = current_bracket.loser_parent_bracket_id;
71+
IF loser_target IS NOT NULL THEN
72+
PERFORM resolve_bracket_bye(loser_target);
73+
END IF;
74+
END;
75+
END IF;
76+
ELSE
77+
-- Dead bracket: zero teams, all feeders finished (e.g. all feeders were byes)
78+
RAISE NOTICE 'Resolving dead bracket: bracket % has no teams and no pending feeders',
79+
current_bracket.id;
80+
81+
-- Check if parent bracket should now resolve as bye (it lost a feeder)
82+
IF current_bracket.parent_bracket_id IS NOT NULL THEN
83+
DECLARE
84+
parent_bracket tournament_brackets%ROWTYPE;
85+
BEGIN
86+
SELECT * INTO parent_bracket
87+
FROM tournament_brackets WHERE id = current_bracket.parent_bracket_id;
88+
IF parent_bracket IS NOT NULL THEN
89+
PERFORM resolve_bracket_bye(parent_bracket);
90+
END IF;
91+
END;
92+
END IF;
93+
END IF;
94+
95+
-- Check if tournament is now complete
96+
SELECT ts.tournament_id INTO tournament_id
97+
FROM tournament_stages ts
98+
WHERE ts.id = current_bracket.tournament_stage_id;
99+
100+
IF tournament_id IS NOT NULL THEN
101+
PERFORM check_tournament_finished(tournament_id);
102+
END IF;
103+
104+
RETURN true;
105+
END;
106+
$$;

hasura/functions/tournaments/seed_stage.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ BEGIN
166166
team_2_seed_val, team_2_id;
167167
END LOOP;
168168

169-
update tournament_brackets set bye = (team_1_id IS NULL OR team_2_id IS NULL)
170-
where tournament_stage_id = stage.id and round = 1;
169+
update tournament_brackets set bye = (tournament_team_id_1 IS NULL OR tournament_team_id_2 IS NULL)
170+
where tournament_stage_id = stage.id and round = 1 and COALESCE(path, 'WB') = 'WB';
171171
END IF;
172172

173173
IF stage.type != 'RoundRobin' THEN

hasura/functions/tournaments/update_tournament_bracket.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ BEGIN
3434
WHERE id = bracket.id;
3535

3636
IF bracket.parent_bracket_id IS NOT NULL THEN
37-
PERFORM public.assign_team_to_bracket_slot(bracket.parent_bracket_id, winning_team_id);
37+
PERFORM public.assign_team_to_bracket_slot(bracket.parent_bracket_id, winning_team_id, bracket.id);
3838
END IF;
3939

4040
IF bracket.loser_parent_bracket_id IS NOT NULL THEN
41-
PERFORM public.assign_team_to_bracket_slot(bracket.loser_parent_bracket_id, losing_team_id);
41+
PERFORM public.assign_team_to_bracket_slot(bracket.loser_parent_bracket_id, losing_team_id, bracket.id);
4242
END IF;
4343

4444
SELECT ts.tournament_id, ts.type INTO tournament_id, stage_type

hasura/triggers/tournament_brackets.sql

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,20 @@ BEGIN
99
IF OLD.match_id IS NOT NULL THEN
1010
return NEW;
1111
END IF;
12-
12+
1313
-- Don't schedule if bracket is already finished
1414
IF NEW.finished = true THEN
1515
return NEW;
1616
END IF;
1717

18+
-- Only run scheduling logic when team columns actually change.
19+
-- Other updates (scheduled_eta, scheduled_at, etc.) should not
20+
-- trigger scheduling or bye resolution.
21+
IF (OLD.tournament_team_id_1 IS NOT DISTINCT FROM NEW.tournament_team_id_1) AND
22+
(OLD.tournament_team_id_2 IS NOT DISTINCT FROM NEW.tournament_team_id_2) THEN
23+
RETURN NEW;
24+
END IF;
25+
1826
IF NEW.match_id IS NULL THEN
1927
-- Check if this is a RoundRobin stage
2028
SELECT ts.type INTO stage_type
@@ -32,6 +40,11 @@ BEGIN
3240
IF should_auto_schedule(NEW.tournament_stage_id) THEN
3341
PERFORM schedule_tournament_match(NEW);
3442
END IF;
43+
-- One team present but not the other: check for runtime bye
44+
ELSIF (NEW.tournament_team_id_1 IS NOT NULL) != (NEW.tournament_team_id_2 IS NOT NULL) THEN
45+
IF should_auto_schedule(NEW.tournament_stage_id) THEN
46+
PERFORM resolve_bracket_bye(NEW);
47+
END IF;
3548
END IF;
3649
END IF;
3750

hasura/triggers/tournaments.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ BEGIN
3737
OLD.status = 'Paused' AND NEW.status = 'Live'
3838
AND NEW.auto_start
3939
) THEN
40+
-- Resolve runtime byes first (one team, no pending feeders)
41+
-- Process lower rounds first so cascading byes propagate correctly
42+
FOR bracket_row IN
43+
SELECT tb.*
44+
FROM tournament_brackets tb
45+
INNER JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id
46+
WHERE ts.tournament_id = NEW.id
47+
AND tb.match_id IS NULL
48+
AND tb.finished = false
49+
AND tb.bye = false
50+
AND ((tb.tournament_team_id_1 IS NOT NULL AND tb.tournament_team_id_2 IS NULL)
51+
OR (tb.tournament_team_id_1 IS NULL AND tb.tournament_team_id_2 IS NOT NULL))
52+
ORDER BY tb.round, tb.match_number
53+
LOOP
54+
PERFORM resolve_bracket_bye(bracket_row);
55+
END LOOP;
56+
57+
-- Then schedule matches with both teams present
4058
FOR bracket_row IN
4159
SELECT tb.*
4260
FROM tournament_brackets tb

0 commit comments

Comments
 (0)