219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
# 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 = None
|
|
loser = None
|
|
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)
|
|
# Important: We push the GHOST to the LB.
|
|
# When the loop reaches that LB match later, it will see the ghost
|
|
# and mark THAT match as a bye too, recursively cleaning the bracket.
|
|
if m.next_loss:
|
|
m.next_loss.teams[m.next_loss_slot] = loser
|