# backend/app/logic.py import heapq from collections import defaultdict from datetime import datetime, timedelta from uuid import uuid4 from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified from . import models from .constants import BracketTypes, MatchStatus, TournamentTypes from .core.brackets import BracketGenerator from .core.structures import Match def generate_bracket(db: Session, t: models.Tournament): teams = t.teams if not teams: return gen = BracketGenerator() is_double = t.type == TournamentTypes.DOUBLE abstract_matches = gen.generate(len(teams), double_elimination=is_double) id_map = {m.id: str(uuid4()) for m in abstract_matches} def resolve_target(match_node: Match, is_winner_path): if is_winner_path: curr = match_node.next_win slot = getattr(match_node, "next_win_slot", None) else: curr = match_node.next_loss slot = getattr(match_node, "next_loss_slot", None) while curr and getattr(curr, "is_bye", False): slot = getattr(curr, "next_win_slot", None) curr = curr.next_win return curr, slot db_matches = [] friendly_counter = 1 for m in abstract_matches: real_win, win_slot = resolve_target(m, True) real_loss, loss_slot = resolve_target(m, False) initial_status = MatchStatus.SCHEDULED p1_id = None p2_id = None p1_seed = m.teams[0] p2_seed = m.teams[1] if p1_seed and p1_seed <= len(teams): p1_id = teams[p1_seed - 1].id if p2_seed and p2_seed <= len(teams): p2_id = teams[p2_seed - 1].id if p1_id and p2_id: initial_status = MatchStatus.PENDING new_match = models.Match( id=id_map[m.id], tournament_id=t.id, match_number=friendly_counter, bracket_type=m.bracket_type, round_number=m.round_number, status=initial_status, p1_team_id=p1_id, p2_team_id=p2_id, winner_next_match_id=id_map[real_win.id] if real_win else None, winner_next_match_slot=win_slot if real_win else None, loser_next_match_id=id_map[real_loss.id] if real_loss else None, loser_next_match_slot=loss_slot if real_loss else None, ) db_matches.append(new_match) friendly_counter += 1 db.add_all(db_matches) db.flush() def update_schedule_times(db: Session, t: models.Tournament): if not t.timestamp: return # 1. Grab the entire day of tournaments target_date = t.timestamp.date() start_of_day = datetime.combine(target_date, datetime.min.time()) end_of_day = start_of_day + timedelta(days=1) tournaments = ( db.query(models.Tournament) .filter( models.Tournament.timestamp >= start_of_day, models.Tournament.timestamp < end_of_day, ) .all() ) if not tournaments: return all_matches: list[models.Match] = [] match_map: dict[str, models.Match] = {} prereqs = defaultdict(list) depth_cache = {} # 2. Gather all matches and prereqs globally for t_item in tournaments: for m in t_item.matches: all_matches.append(m) match_map[m.id] = m if m.winner_next_match_id: prereqs[m.winner_next_match_id].append(m.id) if m.loser_next_match_id: prereqs[m.loser_next_match_id].append(m.id) # 3. Calculate depths def get_depth(m_id): if m_id not in match_map: return 0 if m_id in depth_cache: return depth_cache[m_id] m = match_map[m_id] child_depths = [0] if m.winner_next_match_id: child_depths.append(get_depth(m.winner_next_match_id)) if m.loser_next_match_id: child_depths.append(get_depth(m.loser_next_match_id)) depth = 1 + max(child_depths) depth_cache[m_id] = depth return depth for m in all_matches: get_depth(m.id) # 4. Initialize timers all_courts: set[models.Court] = set() court_sharing_count = defaultdict(int) for t_item in tournaments: for c in t_item.courts: all_courts.add(c) court_sharing_count[c.id] += 1 court_timers = {c.id: start_of_day for c in all_courts} planned_finish_times = {} unscheduled: list[models.Match] = [] for m in all_matches: if m.status == MatchStatus.FINISHED and m.start_time and m.court_id: fin = m.start_time + timedelta(minutes=m.tournament.duration) planned_finish_times[m.id] = fin if court_timers.get(m.court_id, start_of_day) < fin: court_timers[m.court_id] = fin else: unscheduled.append(m) tournament_match_counts = {t_item.id: 0 for t_item in tournaments} # 5. Critical Path Time-Stepping Scheduler loop_limit = max(5000, len(unscheduled) * 50) while unscheduled and loop_limit > 0: loop_limit -= 1 best_court_id = min( court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k]) ) current_time = court_timers[best_court_id] ready: list[models.Match] = [] for m in unscheduled: if best_court_id not in [c.id for c in m.tournament.courts]: continue is_ready = True max_prereq_time = ( m.tournament.timestamp.replace(tzinfo=None) if m.tournament.timestamp.tzinfo else m.tournament.timestamp ) for p_id in prereqs[m.id]: if p_id not in planned_finish_times: is_ready = False break max_prereq_time = max(max_prereq_time, planned_finish_times[p_id]) if is_ready and max_prereq_time <= current_time: ready.append(m) if ready: ready.sort( key=lambda x: ( len(x.tournament.courts), tournament_match_counts[x.tournament_id], -depth_cache[x.id], x.round_number, x.match_number, ) ) cand = ready[0] cand.court_id = best_court_id cand.start_time = current_time fin = current_time + timedelta(minutes=cand.tournament.duration) planned_finish_times[cand.id] = fin court_timers[best_court_id] = fin tournament_match_counts[cand.tournament_id] += 1 unscheduled.remove(cand) else: next_wake = None for m in unscheduled: if best_court_id not in [c.id for c in m.tournament.courts]: continue is_ready = True max_prereq_time = ( m.tournament.timestamp.replace(tzinfo=None) if m.tournament.timestamp.tzinfo else m.tournament.timestamp ) for p_id in prereqs[m.id]: if p_id not in planned_finish_times: is_ready = False break max_prereq_time = max(max_prereq_time, planned_finish_times[p_id]) if is_ready and max_prereq_time > current_time: if next_wake is None or max_prereq_time < next_wake: next_wake = max_prereq_time if next_wake: court_timers[best_court_id] = next_wake else: court_timers[best_court_id] += timedelta(minutes=5) # 6. Ultra-Fast Smart Referee Assignment all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0)) duty_counts = defaultdict(int) team_first_match = {} for m in all_matches: if m.start_time: if m.p1_team_id and m.p1_team_id not in team_first_match: team_first_match[m.p1_team_id] = m.start_time if m.p2_team_id and m.p2_team_id not in team_first_match: team_first_match[m.p2_team_id] = m.start_time active_refs = [] match_outcome_next_start = {} for m in all_matches: if m.winner_next_match_id: nm = match_map.get(m.winner_next_match_id) if nm and nm.start_time: match_outcome_next_start[("W", m.id)] = nm.start_time if m.loser_next_match_id: nm = match_map.get(m.loser_next_match_id) if nm and nm.start_time: match_outcome_next_start[("L", m.id)] = nm.start_time def is_ref_busy(identifier, start, end): for ref_id, r_start, r_end in active_refs: if ref_id == identifier and start < r_end and end > r_start: return True return False for m in all_matches: if not m.start_time: continue m_start = m.start_time m_end = m_start + timedelta(minutes=m.tournament.duration) if m.bracket_type == BracketTypes.FINALS: prev_final = next( ( p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id ), None, ) if prev_final: m.ref_team_id = prev_final.ref_team_id m.ref_label = prev_final.ref_label identifier = ( f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label ) active_refs.append((identifier, m_start, m_end)) continue best_outcome = None best_score = float("inf") for prev_m in all_matches: if not prev_m.start_time or prev_m.start_time >= m_start: continue prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration) if prev_end > m_start: continue for outcome in ["W", "L"]: out_key = (outcome, prev_m.id) next_start = match_outcome_next_start.get(out_key) if next_start and next_start < m_end: continue role_str = "Winner" if outcome == "W" else "Loser" identifier = f"{role_str} of #{prev_m.match_number}" if is_ref_busy(identifier, m_start, m_end): continue wait_mins = (m_start - prev_end).total_seconds() / 60.0 score = (duty_counts[identifier] * 120) + wait_mins if outcome == "W": score += 60 if prev_m.court_id == m.court_id: score -= 30 if score < best_score: best_score = score best_outcome = identifier if best_outcome: m.ref_label = best_outcome m.ref_team_id = None duty_counts[best_outcome] += 1 active_refs.append((best_outcome, m_start, m_end)) else: assigned_bye_team = False sorted_teams = sorted( m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"] ) for team in sorted_teams: t_id = team.id t_first_start = team_first_match.get(t_id) identifier = f"TEAM_{t_id}" if t_first_start and t_first_start >= m_end: if not is_ref_busy(identifier, m_start, m_end): m.ref_team_id = t_id m.ref_label = None duty_counts[identifier] += 1 active_refs.append((identifier, m_start, m_end)) assigned_bye_team = True break if not assigned_bye_team: m.ref_label = "Staff / Volunteers" m.ref_team_id = None db.commit() def advance_winner(db: Session, match: models.Match, winner_id: int): if not winner_id: return loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id for m in match.tournament.matches: if m.ref_label == f"Loser of #{match.match_number}": m.ref_team_id = loser_id db.add(m) elif m.ref_label == f"Winner of #{match.match_number}": m.ref_team_id = winner_id db.add(m) if ( match.bracket_type == BracketTypes.FINALS and match.winner_next_match and match.winner_next_match.bracket_type == BracketTypes.FINALS ): if winner_id == match.p1_team_id: reset_match = match.winner_next_match if reset_match.winner_team_id: undo_advancement(db, reset_match) reset_match.winner_team_id = None reset_match.sets = [] flag_modified(reset_match, "sets") reset_match.p1_team_id = None reset_match.p2_team_id = None reset_match.status = MatchStatus.SCHEDULED db.add(reset_match) db.commit() return def update_next_match( next_match: models.Match, team_id: int, target_slot: int | None ): if not next_match or target_slot is None: return current_team = ( next_match.p1_team_id if target_slot == 0 else next_match.p2_team_id ) if current_team and current_team != team_id: if next_match.winner_team_id: undo_advancement(db, next_match) next_match.winner_team_id = None next_match.sets = [] flag_modified(next_match, "sets") next_match.status = MatchStatus.SCHEDULED if target_slot == 0: next_match.p1_team_id = team_id elif target_slot == 1: next_match.p2_team_id = team_id if ( next_match.p1_team_id and next_match.p2_team_id and next_match.status == MatchStatus.SCHEDULED ): next_match.status = MatchStatus.PENDING db.add(next_match) update_next_match(match.winner_next_match, winner_id, match.winner_next_match_slot) if match.loser_next_match and loser_id: update_next_match(match.loser_next_match, loser_id, match.loser_next_match_slot) db.commit() def undo_advancement(db: Session, match: models.Match): if not match.winner_team_id: return for m in match.tournament.matches: if ( m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}" ): m.ref_team_id = None db.add(m) winner_id = match.winner_team_id loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id def clear_from_next(next_match: models.Match, target_slot: int | None): if not next_match or target_slot is None: return if next_match.winner_team_id: undo_advancement(db, next_match) next_match.winner_team_id = None next_match.sets = [] flag_modified(next_match, "sets") if target_slot == 0: next_match.p1_team_id = None elif target_slot == 1: next_match.p2_team_id = None next_match.status = MatchStatus.SCHEDULED db.add(next_match) clear_from_next(match.winner_next_match, match.winner_next_match_slot) if match.loser_next_match and loser_id: clear_from_next(match.loser_next_match, match.loser_next_match_slot) db.commit()