diff --git a/backend/app/core/brackets.py b/backend/app/core/brackets.py new file mode 100644 index 0000000..8950e0d --- /dev/null +++ b/backend/app/core/brackets.py @@ -0,0 +1,205 @@ +# backend/app/core/brackets.py + +import math +from typing import Optional + + +class Match: + def __init__(self, match_id: int, name: str): + self.id = match_id + self.name = name + self.next_win: Optional["Match"] = None + self.next_loss: Optional["Match"] = None + self.players: list[Optional[int]] = [None, None] + self.is_bye: bool = False + self.ghost_count: int = 0 + + +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) + + bracket = self._create_tree_bracket(size, "WB") + + for i, match in enumerate(bracket[0]): + match.players = [seeds[i * 2], seeds[i * 2 + 1]] + + lb_rounds = [] + if double_elimination: + lb_rounds = self._create_strict_lb(size, bracket) + + if double_elimination: + self._create_de_finals(bracket, lb_rounds) + else: + self._create_se_third_place(bracket) + + self._apply_ghost_logic(bracket[0], num_players) + + all_matches = sorted(self.matches_dict.values(), key=lambda x: x.id) + 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, prefix: str) -> 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): + name = f"{prefix} Round {r_num}" + m = Match(self.match_counter, name) + self.matches_dict[m.id] = m + round_matches.append(m) + self.match_counter += 1 + rounds.append(round_matches) + current_size //= 2 + r_num += 1 + + if rounds: + rounds[-1][0].name = "Grand Final" + if len(rounds) > 1: + for m in rounds[-2]: + m.name = f"{prefix} Semifinal" + + for r in range(len(rounds) - 1): + for i, match in enumerate(rounds[r]): + match.next_win = rounds[r + 1][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, f"LB Round {r}") + self.matches_dict[m.id] = m + round_matches.append(m) + self.match_counter += 1 + lb_rounds.append(round_matches) + + if lb_rounds: + lb_rounds[-1][0].name = "Losers Final (3rd Place Match)" + + 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): + match.next_win = next_r[i] + else: + match.next_win = next_r[i // 2] + + for r in range(num_wb_rounds): + wb_round = wb_rounds[r] + + if r == 0: + lb_target_idx = 0 + else: + lb_target_idx = (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] + else: + target = lb_round[i] if i < len(lb_round) else lb_round[0] + + wb_match.next_loss = target + + return lb_rounds + + def _create_se_third_place(self, wb_rounds: list[list[Match]]) -> None: + """Creates a standalone 3rd place match for Single Elim""" + if len(wb_rounds) < 2: + return + + semis = wb_rounds[-2] + third_place_match = Match(self.match_counter, "3rd Place Match") + self.matches_dict[third_place_match.id] = third_place_match + self.match_counter += 1 + + for m in semis: + m.next_loss = third_place_match + + def _create_de_finals( + self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]] + ) -> None: + """Links Double Elim Grand Final""" + wb_final = wb_rounds[-1][0] + wb_final.name = "Winners Final" + + gf = Match(self.match_counter, "Grand Final") + self.matches_dict[gf.id] = gf + self.match_counter += 1 + + wb_final.next_win = gf + if lb_rounds: + lb_rounds[-1][0].next_win = gf + reset = Match(self.match_counter, "Grand Final Reset") + self.matches_dict[reset.id] = reset + self.match_counter += 1 + gf.next_loss = reset + + def _apply_ghost_logic( + self, round_one_matches: list[Match], num_players: int + ) -> None: + """ + Injects ghosts into WB Round 1 and lets them flow recursively. + """ + for m in round_one_matches: + p1, p2 = m.players + if p1 and p1 > num_players: + self._add_ghost(m) + if p2 and p2 > num_players: + self._add_ghost(m) + + def _add_ghost(self, match: Match) -> None: + """ + Recursively propagates a ghost through the bracket. + """ + match.ghost_count += 1 + match.is_bye = True + + if match.next_loss: + self._add_ghost(match.next_loss) + + if match.ghost_count == 2: + if match.next_win: + self._add_ghost(match.next_win) diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py index 2307a2c..4c565c5 100644 --- a/backend/app/core/utils.py +++ b/backend/app/core/utils.py @@ -1 +1,63 @@ -# backend\app\core\utils.py +# backend/app/core/utils.py +from .brackets import Match + + +class MermaidLive: + + @staticmethod + def _get_visual_target(match: Match, is_win=True) -> Match | None: + current = match.next_win if is_win else match.next_loss + while current and current.is_bye: + current = current.next_win + return current + + @classmethod + def export(cls, matches: list[Match]) -> None: + print("\n--- COPY TO MERMAID.LIVE ---") + print("graph LR") + print(" classDef wb stroke:#01579b,stroke-width:2px;") + print(" classDef lb stroke:#b71c1c,stroke-width:2px,stroke-dasharray: 5 5;") + print(" classDef final stroke:#e65100,stroke-width:4px;") + + wb_nodes = [ + m + for m in matches + if "WB" in m.name or "Semifinal" in m.name or "Winners Final" in m.name + ] + + lb_nodes = [m for m in matches if "LB" in m.name or "Losers Final" in m.name] + final_nodes = [ + m for m in matches if "Grand Final" in m.name or "3rd Place" in m.name + ] + + print(" subgraph Winners Bracket") + for m in wb_nodes: + cls._print_node(m, "wb") + print(" end") + + if lb_nodes: + print(" subgraph Losers Bracket") + for m in lb_nodes: + cls._print_node(m, "lb") + print(" end") + + print(" subgraph Championship / 3rd Place") + for m in final_nodes: + cls._print_node(m, "final") + print(" end") + + for m in matches: + target_win = cls._get_visual_target(m, is_win=True) + if target_win: + print(f" M{m.id} --> M{target_win.id}") + + target_loss = cls._get_visual_target(m, is_win=False) + if target_loss: + print(f" M{m.id} -.-> M{target_loss.id}") + + @staticmethod + def _print_node(m: Match, style: str) -> None: + label = m.name + if m.players[0] and m.players[1]: + label += f" ({m.players[0]} vs {m.players[1]})" + print(f' M{m.id}["{label}"]:::{style}') diff --git a/backend/app/models.py b/backend/app/models.py index 925e8ae..a96a36f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -128,7 +128,7 @@ class Match(Base): court_id: Mapped[Optional[int]] = mapped_column( ForeignKey("courts.id"), nullable=True ) - start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) status: Mapped[MatchStatus] = mapped_column( SqlEnum(MatchStatus), default=MatchStatus.PENDING