# backend/app/logic.py import math from datetime import datetime, timedelta from typing import List, Dict, Any from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes from .models import Tournament def get_seeded_positions(num_slots, teams): seeds = [1, 2] while len(seeds) < num_slots: next_seeds = [] for s in seeds: next_seeds.append(s) next_seeds.append(2 * len(seeds) + 1 - s) seeds = next_seeds return [teams[s - 1] if s <= len(teams) else "BYE" for s in seeds] def generate_structure( teams: List[str], type: TournamentTypes = TournamentTypes.DOUBLE ) -> List[Dict[str, Any]]: count = len(teams) if count < 2: return [] power = math.ceil(math.log2(count)) if count > 0 else 1 size = 2**power seeded_teams = get_seeded_positions(size, teams) class Node: def __init__(self, id, bracket: BracketType, round_n: int): self.id = str(id) self.bracket = bracket self.round = round_n self.p1: str | None = None self.p2: str | None = None self.winner_next_match_id: str | None = None self.loser_next_match_id: str | None = None self.previous_match_p1_id: str | None = None self.previous_match_p2_id: str | None = None self.source_p1_type: MatchSourceType | None = None self.source_p2_type: MatchSourceType | None = None def to_dict(self): return { "id": self.id, # Pass Enum OBJECTS, not strings. SQLAlchemy handles the rest. "bracket": self.bracket, "round": self.round, "p1_name": self.p1, "p2_name": self.p2, "status": MatchStatus.PENDING, "previous_match_p1_id": self.previous_match_p1_id, "previous_match_p2_id": self.previous_match_p2_id, "source_p1_type": self.source_p1_type, "source_p2_type": self.source_p2_type, "winner_next_match_id": self.winner_next_match_id, "loser_next_match_id": self.loser_next_match_id, } nodes: List[Node] = [] match_counter = 1 def create_node(bracket: BracketType, round_n: int): nonlocal match_counter n = Node(match_counter, bracket, round_n) match_counter += 1 nodes.append(n) return n # --- Winners Bracket --- wb_rounds = power wb_matches = {r: [] for r in range(1, wb_rounds + 1)} for r in range(1, wb_rounds + 1): for _ in range(size // (2**r)): wb_matches[r].append(create_node(BracketType.WINNERS, r)) # Link Winners for r in range(1, wb_rounds): for i, m in enumerate(wb_matches[r]): target = wb_matches[r + 1][i // 2] m.winner_next_match_id = target.id if i % 2 == 0: target.previous_match_p1_id = m.id target.source_p1_type = MatchSourceType.WINNER else: target.previous_match_p2_id = m.id target.source_p2_type = MatchSourceType.WINNER for i, m in enumerate(wb_matches[1]): m.p1 = seeded_teams[i * 2] m.p2 = seeded_teams[i * 2 + 1] # --- Losers Bracket --- if type == TournamentTypes.DOUBLE and size >= 4: lb_rounds = (wb_rounds - 1) * 2 lb_matches = {r: [] for r in range(1, lb_rounds + 1)} current_count = size // 4 for r in range(1, lb_rounds + 1): for _ in range(current_count): lb_matches[r].append(create_node(BracketType.LOSERS, r)) if r % 2 == 0: current_count //= 2 # Link Losers Internal for r in range(1, lb_rounds): for i, m in enumerate(lb_matches[r]): target = ( lb_matches[r + 1][i] if r % 2 != 0 else lb_matches[r + 1][i // 2] ) m.winner_next_match_id = target.id if r % 2 != 0: target.previous_match_p1_id = m.id target.source_p1_type = MatchSourceType.WINNER else: if i % 2 == 0: target.previous_match_p1_id = m.id target.source_p1_type = MatchSourceType.WINNER else: target.previous_match_p2_id = m.id target.source_p2_type = MatchSourceType.WINNER # Link Losers Drop-down for r in range(1, wb_rounds): drop_round = 1 if r == 1 else (r - 1) * 2 wb_layer = wb_matches[r] lb_layer = lb_matches[drop_round] for i, wb_m in enumerate(wb_layer): target = ( lb_layer[i // 2] if r == 1 else (lb_layer[i] if i < len(lb_layer) else lb_layer[-1]) ) slot = "p1" if (r == 1 and i % 2 == 0) else "p2" wb_m.loser_next_match_id = target.id if slot == "p1": target.previous_match_p1_id = wb_m.id target.source_p1_type = MatchSourceType.LOSER else: target.previous_match_p2_id = wb_m.id target.source_p2_type = MatchSourceType.LOSER # Finals Linking wb_final = wb_matches[wb_rounds][0] lb_final = lb_matches[lb_rounds][0] wb_final.loser_next_match_id = lb_final.id lb_final.previous_match_p2_id = wb_final.id lb_final.source_p2_type = MatchSourceType.LOSER final = create_node(BracketType.FINALS, 1) wb_final.winner_next_match_id = final.id lb_final.winner_next_match_id = final.id final.previous_match_p1_id = wb_final.id final.source_p1_type = MatchSourceType.WINNER final.previous_match_p2_id = lb_final.id final.source_p2_type = MatchSourceType.WINNER return [n.to_dict() for n in nodes] def refresh_bracket(t_obj: Tournament): matches_map = {m.id: m for m in t_obj.matches} for _ in range(20): for m in t_obj.matches: def resolve(src_id, type_): if not src_id or src_id not in matches_map: return None src = matches_map[src_id] if type_ == MatchSourceType.WINNER: return src.winner if type_ == MatchSourceType.LOSER: if src.winner == "BYE": return "BYE" if src.winner: return src.p1_name if src.winner == src.p2_name else src.p2_name return None return None if m.previous_match_p1_id: m.p1_name = resolve(m.previous_match_p1_id, m.source_p1_type) if m.previous_match_p2_id: m.p2_name = resolve(m.previous_match_p2_id, m.source_p2_type) # BYE Auto-Win if not m.winner and (m.p1_name == "BYE" or m.p2_name == "BYE"): if m.p1_name == "BYE" and m.p2_name == "BYE": m.winner = "BYE" elif m.p1_name == "BYE": m.winner = m.p2_name else: m.winner = m.p1_name m.status = MatchStatus.FINISHED # Reset Logic if m.status == MatchStatus.FINISHED and m.winner != "BYE": has_p1 = bool(m.p1_name) has_p2 = bool(m.p2_name) if ( not has_p1 or not has_p2 or (m.winner != m.p1_name and m.winner != m.p2_name) ): m.winner = None m.status = MatchStatus.PENDING m.sets = [] # Numbering display_counter = 1 sorted_matches = sorted( t_obj.matches, key=lambda x: int(x.id) if x.id.isdigit() else 999 ) for m in sorted_matches: if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE": m.number = None else: m.number = display_counter display_counter += 1 # NO LABEL GENERATION HERE - FRONTEND HANDLES IT def update_schedule(t_obj: Tournament): match_map = {m.id: m for m in t_obj.matches} depth_cache = {} def get_depth(mid): if mid not in match_map: return 0 if mid in depth_cache: return depth_cache[mid] m = match_map[mid] d = 1 + max( get_depth(m.winner_next_match_id) if m.winner_next_match_id else 0, get_depth(m.loser_next_match_id) if m.loser_next_match_id else 0, ) depth_cache[mid] = d return d criticality_map = {} for m in t_obj.matches: criticality_map[m.id] = get_depth(m.id) start_time = t_obj.timestamp duration = t_obj.duration finish_times: Dict[str, datetime] = {} court_timers: Dict[str, datetime] = {c.name: start_time for c in t_obj.courts} unscheduled = [] # 1. Initialize for m in t_obj.matches: if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE": finish_times[m.id] = start_time m.status = MatchStatus.FINISHED elif m.status == MatchStatus.FINISHED: match_start = m.timestamp if m.timestamp else start_time fin = match_start + timedelta(minutes=duration) finish_times[m.id] = fin if m.court_name and m.court_name in court_timers: if fin > court_timers[m.court_name]: court_timers[m.court_name] = fin else: m.timestamp = None m.court_name = None m.status = MatchStatus.PENDING unscheduled.append(m) if not court_timers: return # 2. Schedule loop = len(t_obj.matches) * 2 while unscheduled and loop > 0: loop -= 1 best_court = min(court_timers, key=lambda k: court_timers[k]) current_time_slot = court_timers[best_court] ready = [] for m in unscheduled: p1_r = ( finish_times.get(m.previous_match_p1_id, start_time) if m.previous_match_p1_id else start_time ) p2_r = ( finish_times.get(m.previous_match_p2_id, start_time) if m.previous_match_p2_id else start_time ) if max(p1_r, p2_r) <= current_time_slot: ready.append(m) if ready: ready.sort(key=lambda x: (-criticality_map.get(x.id, 0), x.round)) cand = ready[0] cand.court_name = best_court cand.timestamp = current_time_slot cand.status = MatchStatus.SCHEDULED fin = current_time_slot + timedelta(minutes=duration) finish_times[cand.id] = fin court_timers[best_court] = fin unscheduled.remove(cand) else: next_wake = None for m in unscheduled: p1_r = ( finish_times.get(m.previous_match_p1_id, start_time) if m.previous_match_p1_id else start_time ) p2_r = ( finish_times.get(m.previous_match_p2_id, start_time) if m.previous_match_p2_id else start_time ) ready_at = max(p1_r, p2_r) if ready_at > current_time_slot: if next_wake is None or ready_at < next_wake: next_wake = ready_at if next_wake: court_timers[best_court] = next_wake else: break