# backend/app/core/brackets.py import math from ..constants import BracketTypes from .structures import Match class BracketGenerator: def __init__(self): self.matches_dict = {} self.match_counter = 1 def generate( self, num_players: int, double_elimination: bool = False ) -> list[Match]: self.matches_dict = {} self.match_counter = 1 size = 2 ** math.ceil(math.log2(num_players)) seeds = self._generate_seeding_indices(num_players) # Create Winners Bracket bracket = self._create_tree_bracket(size) for i, match in enumerate(bracket[0]): match.teams = [seeds[i * 2], seeds[i * 2 + 1]] lb_rounds = [] if double_elimination: lb_rounds = self._create_strict_lb(size, bracket) self._create_de_finals(bracket, lb_rounds) else: self._create_se_third_place(bracket) # Propagate ghosts (this marks matches as is_bye=True) self._resolve_byes(num_players) all_matches = sorted(self.matches_dict.values(), key=lambda x: x.id) # Filter out byes so they don't become DB rows return [m for m in all_matches if not m.is_bye] def _generate_seeding_indices(self, num_players: int) -> list[int]: p2 = 2 ** math.ceil(math.log2(num_players)) seeds = [1] curr = 1 while curr < p2: seeds = [x for val in seeds for x in (val, (curr * 2) + 1 - val)] curr *= 2 return seeds def _create_tree_bracket(self, size: int) -> list[list[Match]]: rounds: list[list[Match]] = [] current_size = size // 2 r_num = 1 while current_size >= 1: round_matches: list[Match] = [] for _ in range(current_size): m = Match(self.match_counter, BracketTypes.WB, r_num) self.matches_dict[m.id] = m round_matches.append(m) self.match_counter += 1 rounds.append(round_matches) current_size //= 2 r_num += 1 for r in range(len(rounds) - 1): for i, match in enumerate(rounds[r]): match.next_win = rounds[r + 1][i // 2] match.next_win_slot = i % 2 return rounds def _create_strict_lb( self, size: int, wb_rounds: list[list[Match]] ) -> list[list[Match]]: lb_rounds: list[list[Match]] = [] num_wb_rounds = len(wb_rounds) total_lb_rounds = (num_wb_rounds - 1) * 2 current_lb_size = size // 4 if current_lb_size < 1: current_lb_size = 1 for r in range(1, total_lb_rounds + 1): if r > 2 and r % 2 != 0: current_lb_size //= 2 round_matches = [] for _ in range(current_lb_size): m = Match(self.match_counter, BracketTypes.LB, r) self.matches_dict[m.id] = m round_matches.append(m) self.match_counter += 1 lb_rounds.append(round_matches) # Link LB rounds for r in range(len(lb_rounds) - 1): curr = lb_rounds[r] next_r = lb_rounds[r + 1] for i, match in enumerate(curr): if len(curr) == len(next_r): # 1-to-1 Mapping case (e.g. 4 matches -> 4 matches) match.next_win = next_r[i] # FIX: Always use slot 1 (Slot 0 is taken by WB Drop-down) match.next_win_slot = 1 else: # 2-to-1 Mapping case (e.g. 4 matches -> 2 matches) match.next_win = next_r[i // 2] match.next_win_slot = i % 2 # Link WB Losers -> LB for r in range(num_wb_rounds): wb_round = wb_rounds[r] lb_target_idx = 0 if r == 0 else (r * 2) - 1 if lb_target_idx >= len(lb_rounds): break lb_round = lb_rounds[lb_target_idx] for i, wb_match in enumerate(wb_round): if r == 0: target = lb_round[i // 2] wb_match.next_loss_slot = i % 2 else: # Drop-in round target = lb_round[i] if i < len(lb_round) else lb_round[0] # Drop-ins take Slot 0 wb_match.next_loss_slot = 0 wb_match.next_loss = target return lb_rounds def _create_se_third_place(self, wb_rounds: list[list[Match]]) -> None: if len(wb_rounds) < 2: return semis = wb_rounds[-2] final_round_idx = wb_rounds[-1][0].round_number third_place_match = Match( self.match_counter, BracketTypes.FINALS, final_round_idx ) self.matches_dict[third_place_match.id] = third_place_match self.match_counter += 1 for i, sm in enumerate(semis): sm.next_loss = third_place_match sm.next_loss_slot = i % 2 def _create_de_finals( self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]] ) -> None: wb_final = wb_rounds[-1][0] final_round_idx = wb_final.round_number + 1 gf = Match(self.match_counter, BracketTypes.FINALS, final_round_idx) self.matches_dict[gf.id] = gf self.match_counter += 1 wb_final.next_win = gf wb_final.next_win_slot = 0 if lb_rounds: lb_rounds[-1][0].next_win = gf lb_rounds[-1][0].next_win_slot = 1 reset = Match(self.match_counter, BracketTypes.FINALS, final_round_idx + 1) self.matches_dict[reset.id] = reset self.match_counter += 1 gf.next_loss = reset gf.next_loss_slot = 0 gf.next_win = reset gf.next_win_slot = 1 def _resolve_byes(self, num_players: int) -> None: """ Iterates through all matches to identify 'Ghost' players (seeds > num_players). If a match has a ghost, it is marked as a Bye, and the real player is automatically pushed to the next match. """ all_matches: list[Match] = sorted( self.matches_dict.values(), key=lambda x: x.id ) for m in all_matches: if m.is_bye: continue p1, p2 = m.teams p1_is_ghost = p1 is not None and p1 > num_players p2_is_ghost = p2 is not None and p2 > num_players if p1_is_ghost or p2_is_ghost: m.is_bye = True winner = None loser = None # Ghost if p1_is_ghost and p2_is_ghost: winner = p1 loser = p2 elif p1_is_ghost: winner = p2 loser = p1 elif p2_is_ghost: winner = p1 loser = p2 # Propagate Winner (Advance to next round) if m.next_win: m.next_win.teams[m.next_win_slot] = winner # Propagate Loser (Drop to LB) if m.next_loss: m.next_loss.teams[m.next_loss_slot] = loser