Added a more robust bracket generator

This commit is contained in:
2026-02-13 13:50:11 +01:00 Verified
parent c12d962da0
commit 87d3ea5ef0
3 changed files with 269 additions and 2 deletions
+205
View File
@@ -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)
+63 -1
View File
@@ -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}')
+1 -1
View File
@@ -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