From d414f28cf5c5cf19647cc86bb84feef925d4b5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Mon, 1 Jun 2026 14:22:12 +0200 Subject: [PATCH] Better ref logic and better redundancy on the scheduling --- backend/app/crud.py | 8 +- backend/app/logic.py | 160 ++++++++++++----------- backend/app/routes/tournaments/report.py | 6 +- 3 files changed, 92 insertions(+), 82 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index ff93dca..7a1ad6c 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,7 +1,7 @@ # backend/app/crud.py from uuid import uuid4 -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from . import logic, models, schemas @@ -201,10 +201,12 @@ def get_tournament_matches(db: Session, tournament_id: str): ) -def get_match(db: Session, tournament_id: str, match_id: str): +def get_match(db: Session, match_id: str): return ( db.query(models.Match) - .filter(models.Match.tournament_id == tournament_id) + .options( + joinedload(models.Match.tournament).joinedload(models.Tournament.matches) + ) .filter(models.Match.id == match_id) .first() ) diff --git a/backend/app/logic.py b/backend/app/logic.py index cb0ffd4..6156392 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -1,4 +1,5 @@ # backend/app/logic.py +import heapq from collections import defaultdict from datetime import datetime, timedelta from uuid import uuid4 @@ -9,6 +10,7 @@ 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): @@ -21,7 +23,7 @@ def generate_bracket(db: Session, t: models.Tournament): 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, is_winner_path): + 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) @@ -133,16 +135,14 @@ def update_schedule_times(db: Session, t: models.Tournament): for m in all_matches: get_depth(m.id) - # 4. Initialize timers & lock finished matches + # 4. Initialize timers all_courts: set[models.Court] = set() - court_sharing_count = defaultdict( - int - ) # NEW: Track how highly contested each court is + 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 # NEW + court_sharing_count[c.id] += 1 court_timers = {c.id: start_of_day for c in all_courts} planned_finish_times = {} @@ -159,8 +159,9 @@ def update_schedule_times(db: Session, t: models.Tournament): tournament_match_counts = {t_item.id: 0 for t_item in tournaments} - # 5. Global Interleaving Schedule Loop - loop_limit = len(unscheduled) * 3 + # 5. Critical Path Time-Stepping Scheduler + loop_limit = max(5000, len(unscheduled) * 50) + while unscheduled and loop_limit > 0: loop_limit -= 1 @@ -173,7 +174,6 @@ def update_schedule_times(db: Session, t: models.Tournament): 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) @@ -236,36 +236,40 @@ def update_schedule_times(db: Session, t: models.Tournament): else: court_timers[best_court_id] += timedelta(minutes=5) - # 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!) - all_matches = sorted( - all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0) - ) + # 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) - active_refs = [] - def is_busy(outcome_tuple, source_m: models.Match, start, end): - next_m_id = ( - source_m.winner_next_match_id - if outcome_tuple[0] == "W" - else source_m.loser_next_match_id - ) - if next_m_id: - next_m = match_map[next_m_id] - next_start = next_m.start_time - if next_start: - next_end = next_start + timedelta(minutes=next_m.tournament.duration) - if not (next_end <= start or next_start >= end): - return True - for r_outcome, r_start, r_end in active_refs: - if r_outcome == outcome_tuple: - if not (r_end <= start or r_start >= end): - return True + 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) @@ -292,59 +296,63 @@ def update_schedule_times(db: Session, t: models.Tournament): best_score = float("inf") for prev_m in all_matches: - if not prev_m.start_time: + 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 - if prev_end <= m_start: - l_outcome = ("L", prev_m.id) - if not is_busy(l_outcome, prev_m, m_start, m_end): - wait_mins = (m_start - prev_end).total_seconds() / 60.0 - score = (duty_counts[l_outcome] * 120) + wait_mins - if prev_m.court_id == m.court_id: - score -= 30 - if score < best_score: - best_score = score - best_outcome = (l_outcome, prev_m) + 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 - w_outcome = ("W", prev_m.id) - if not is_busy(w_outcome, prev_m, m_start, m_end): - wait_mins = (m_start - prev_end).total_seconds() / 60.0 - score = (duty_counts[w_outcome] * 120) + wait_mins + 60 - if prev_m.court_id == m.court_id: - score -= 30 - if score < best_score: - best_score = score - best_outcome = (w_outcome, prev_m) + 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: - outcome_tuple, prev_m = best_outcome - duty_counts[outcome_tuple] += 1 - active_refs.append((outcome_tuple, m_start, m_end)) - role = "Winner" if outcome_tuple[0] == "W" else "Loser" - m.ref_label = f"{role} of #{prev_m.match_number}" + 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: - m.ref_label = "Staff / Volunteers" - for future_m in all_matches: - if future_m.start_time and future_m.start_time >= m_end: - assigned = False - for t_id in [future_m.p1_team_id, future_m.p2_team_id]: - if t_id: - team_identifier = f"TEAM_{t_id}" - is_team_busy = any( - r_outcome == team_identifier - and not (r_end <= m_start or r_start >= m_end) - for r_outcome, r_start, r_end in active_refs - ) - if not is_team_busy: - m.ref_team_id = t_id - m.ref_label = None - active_refs.append((team_identifier, m_start, m_end)) - assigned = True - break - if assigned: + 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() diff --git a/backend/app/routes/tournaments/report.py b/backend/app/routes/tournaments/report.py index a6f2186..6a6e5be 100644 --- a/backend/app/routes/tournaments/report.py +++ b/backend/app/routes/tournaments/report.py @@ -35,7 +35,7 @@ async def report_score( _check_auth(t, user, report.code) - match = crud.get_match(db, id, match_id) + match = crud.get_match(db, match_id) if not match: raise HTTPException(404, "Match not found") @@ -71,7 +71,7 @@ async def edit_score( _check_auth(t, user, report.code) - match = crud.get_match(db, id, match_id) + match = crud.get_match(db, match_id) if not match: raise HTTPException(404, "Match not found") @@ -105,7 +105,7 @@ async def clear_score( _check_auth(t, user, code) - match = crud.get_match(db, id, match_id) + match = crud.get_match(db, match_id) if not match: raise HTTPException(404, "Match not found")