# backend/app/logic.py import math from datetime import datetime, timedelta from typing import Optional from uuid import uuid4 from sqlalchemy.orm import Session from . import models from .constants import ( BracketType, MatchSourceType, MatchStatus, TournamentTypes, WinnerSide, ) 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 None for s in seeds] def generate_bracket_nodes(t: models.Tournament) -> list[dict]: """ Generates the skeleton (BracketNodes). Does NOT create Matches. """ teams = t.teams count = len(teams) if count < 2: return [] power = math.ceil(math.log2(count)) if count > 0 else 1 size = 2**power nodes = [] display_counter = 1 def make_id(): return str(uuid4()) class NodeRef: def __init__(self, bracket, round_n): self.id = make_id() self.bracket = bracket self.round = round_n self.display_num = 0 self.next_win: Optional["NodeRef"] = None self.next_loss: Optional["NodeRef"] = None self.src_p1: Optional["NodeRef"] = None self.src_p2: Optional["NodeRef"] = None self.src_p1_type: Optional[MatchSourceType] = None self.src_p2_type: Optional[MatchSourceType] = None def to_dict(self): return { "id": self.id, "bracket_type": self.bracket, "round_number": self.round, "display_number": self.display_num, "winner_next_node_id": self.next_win.id if self.next_win else None, "loser_next_node_id": self.next_loss.id if self.next_loss else None, "source_p1_node_id": self.src_p1.id if self.src_p1 else None, "source_p2_node_id": self.src_p2.id if self.src_p2 else None, "source_p1_type": self.src_p1_type, "source_p2_type": self.src_p2_type, } wb_rounds = power wb_layers = {r: [] for r in range(1, wb_rounds + 1)} for r in range(1, wb_rounds + 1): for _ in range(size // (2**r)): n = NodeRef(BracketType.WINNERS, r) wb_layers[r].append(n) # Link Winners for r in range(1, wb_rounds): for i, node in enumerate(wb_layers[r]): target = wb_layers[r + 1][i // 2] node.next_win = target if i % 2 == 0: target.src_p1 = node target.src_p1_type = MatchSourceType.WINNER else: target.src_p2 = node target.src_p2_type = MatchSourceType.WINNER lb_layers = {} if t.type == TournamentTypes.DOUBLE and size >= 4: lb_rounds = (wb_rounds - 1) * 2 lb_layers = {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): n = NodeRef(BracketType.LOSERS, r) lb_layers[r].append(n) if r % 2 == 0: current_count //= 2 for r in range(1, lb_rounds): for i, node in enumerate(lb_layers[r]): target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2] node.next_win = target if r % 2 != 0: target.src_p1 = node target.src_p1_type = MatchSourceType.WINNER else: if i % 2 == 0: target.src_p1 = node target.src_p1_type = MatchSourceType.WINNER else: target.src_p2 = node target.src_p2_type = MatchSourceType.WINNER # Link Drop-down (Winners -> Losers) for r in range(1, wb_rounds): drop_round = 1 if r == 1 else (r - 1) * 2 wb_layer_nodes = wb_layers[r] lb_layer_nodes = lb_layers[drop_round] for i, wb_node in enumerate(wb_layer_nodes): target = None if r == 1: target = lb_layer_nodes[i // 2] else: if i < len(lb_layer_nodes): target = lb_layer_nodes[i] else: target = lb_layer_nodes[-1] wb_node.next_loss = target slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2 if slot == WinnerSide.P1: target.src_p1 = wb_node target.src_p1_type = MatchSourceType.LOSER else: target.src_p2 = wb_node target.src_p2_type = MatchSourceType.LOSER # Finals final_node = NodeRef(BracketType.FINALS, 1) wb_final = wb_layers[wb_rounds][0] lb_final = lb_layers[lb_rounds][0] wb_final.next_loss = lb_final wb_final.next_win = final_node lb_final.next_win = final_node final_node.src_p1 = wb_final final_node.src_p1_type = MatchSourceType.WINNER final_node.src_p2 = lb_final final_node.src_p2_type = MatchSourceType.WINNER all_nodes = [] for r in sorted(wb_layers.keys()): all_nodes.extend(wb_layers[r]) for r in sorted(lb_layers.keys()): all_nodes.extend(lb_layers[r]) all_nodes.append(final_node) else: all_nodes = [] for r in sorted(wb_layers.keys()): all_nodes.extend(wb_layers[r]) for n in all_nodes: n.display_num = display_counter display_counter += 1 nodes.append(n.to_dict()) return nodes def initialize_seeding(db: Session, t: models.Tournament): seeded_teams = get_seeded_positions( 2 ** math.ceil(math.log2(t.team_count)), t.teams ) r1_nodes = [ n for n in t.nodes if n.bracket_type == BracketType.WINNERS and n.round_number == 1 ] r1_nodes.sort(key=lambda x: x.display_number) for i, node in enumerate(r1_nodes): t1 = seeded_teams[i * 2] t2 = seeded_teams[i * 2 + 1] node.p1_team_id = t1.id if t1 else None node.p2_team_id = t2.id if t2 else None advance_flow(db, t) def advance_flow(db: Session, t: models.Tournament): changes = True while changes: changes = False for node in t.nodes: if node.round_number == 1 and node.bracket_type == BracketType.WINNERS: if node.p1_team_id and not node.p2_team_id: if _move_team( db, t, node.p1_team_id, node.winner_next_node_id, MatchSourceType.WINNER, ): pass elif node.p2_team_id and not node.p1_team_id: if _move_team( db, t, node.p2_team_id, node.winner_next_node_id, MatchSourceType.WINNER, ): pass # 2. MATCH CREATION if node.p1_team_id and node.p2_team_id: if not node.match: m_id = str(uuid4()) new_match = models.Match( id=m_id, tournament_id=t.id, node_id=node.id, p1_team_id=node.p1_team_id, p2_team_id=node.p2_team_id, status=MatchStatus.PENDING, court_id=node.planned_court_id, start_time=node.planned_start_time, ) db.add(new_match) db.commit() db.refresh(node) changes = True # 3. MATCH RESULT PROPAGATION elif ( node.match.status == MatchStatus.FINISHED and node.match.winner_team_id ): winner_id = node.match.winner_team_id loser_id = ( node.match.p1_team_id if winner_id == node.match.p2_team_id else node.match.p2_team_id ) if _move_team( db, t, winner_id, node.winner_next_node_id, MatchSourceType.WINNER, ): changes = True if _move_team( db, t, loser_id, node.loser_next_node_id, MatchSourceType.LOSER ): changes = True def _move_team(db, t, team_id, target_node_id, source_type): if not target_node_id or not team_id: return False target = next((n for n in t.nodes if n.id == target_node_id), None) if not target: return False updated = False if target.source_p1_type == source_type and not target.p1_team_id: target.p1_team_id = team_id updated = True elif target.source_p2_type == source_type and not target.p2_team_id: target.p2_team_id = team_id updated = True elif not target.p1_team_id: target.p1_team_id = team_id updated = True elif not target.p2_team_id: target.p2_team_id = team_id updated = True if updated: db.add(target) db.commit() return updated def update_schedule_times(db: Session, t: models.Tournament): nodes = sorted(t.nodes, key=lambda n: n.display_number) current_time = t.timestamp courts = t.courts if not courts: return court_timers: dict[int, datetime] = {c.id: current_time for c in courts} for node in nodes: if node.planned_start_time: continue best_court_id = min(court_timers, key=lambda k: court_timers[k]) start = court_timers[best_court_id] node.planned_court_id = best_court_id node.planned_start_time = start court_timers[best_court_id] = start + timedelta(minutes=t.duration) if node.match and node.match.status == MatchStatus.PENDING: node.match.court_id = best_court_id node.match.timestamp = start db.commit()