diff --git a/backend/app/logic.py b/backend/app/logic.py index 324989e..2444e64 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -1,5 +1,5 @@ # backend/app/logic.py -from collections import defaultdict, deque +from collections import defaultdict from datetime import timedelta from uuid import uuid4 @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from . import models from .constants import MatchStatus, TournamentTypes from .core.brackets import BracketGenerator +from .core import structures def generate_bracket(db: Session, t: models.Tournament): @@ -20,7 +21,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): + def resolve_target(match_node: structures.Match | None): curr = match_node while curr and curr.is_bye: curr = curr.next_win @@ -68,86 +69,99 @@ def generate_bracket(db: Session, t: models.Tournament): def update_schedule_times(db: Session, t: models.Tournament): - if not t.courts: + if not t.courts or not t.matches: return matches = t.matches - if not matches: - return - - # 1. Build Dependency Graph - adj = defaultdict(list) - in_degree = {m.id: 0 for m in matches} match_map = {m.id: m for m in matches} + prereqs = defaultdict(list) for m in matches: - if m.id not in adj: - adj[m.id] = [] + 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) - if m.winner_next_match_id and m.winner_next_match_id in match_map: - adj[m.id].append(m.winner_next_match_id) - in_degree[m.winner_next_match_id] += 1 + depth_cache = {} - if m.loser_next_match_id and m.loser_next_match_id in match_map: - adj[m.id].append(m.loser_next_match_id) - in_degree[m.loser_next_match_id] += 1 + 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] - # 2. Initialize constraints - # FIX: Ensure tournament_start is naive (no timezone) to match SQLite DB datetimes - tournament_start = t.timestamp - if tournament_start.tzinfo is not None: - tournament_start = tournament_start.replace(tzinfo=None) + 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)) - match_earliest_start = {m.id: tournament_start for m in matches} + depth = 1 + max(child_depths) + depth_cache[m_id] = depth + return depth + + for m in matches: + get_depth(m.id) + + tournament_start = ( + t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp + ) court_timers = {c.id: tournament_start for c in t.courts} - # 3. Topological Sort - queue = deque([m.id for m in matches if in_degree[m.id] == 0]) + planned_finish_times = {} + unscheduled = list(matches) - # Sort initial batch to prioritize logical order - initial_order = sorted( - list(queue), - key=lambda mid: (match_map[mid].bracket_type, match_map[mid].match_number), - ) - queue = deque(initial_order) + loop_limit = len(matches) * 2 + while unscheduled and loop_limit > 0: + loop_limit -= 1 + best_court_id = min(court_timers, key=lambda k: court_timers[k]) + current_time = court_timers[best_court_id] - while queue: - current_id = queue.popleft() - match = match_map[current_id] + ready: list[models.Match] = [] + for m in unscheduled: + is_ready = True + max_prereq_time = tournament_start + 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]) - # FIX: Ensure DB start_time is treated as naive - m_start = match.start_time - if m_start and m_start.tzinfo is not None: - m_start = m_start.replace(tzinfo=None) + if is_ready and max_prereq_time <= current_time: + ready.append(m) - if match.status == MatchStatus.FINISHED: - # If finished, propagate actual time - if not m_start: - m_start = tournament_start - actual_end = m_start + timedelta(minutes=t.duration) - else: - # Schedule: Must wait for dependencies (min_start) AND court availability - min_start = match_earliest_start[current_id] - - best_court_id = min( - court_timers, key=lambda k: max(court_timers[k], min_start) + if ready: + ready.sort( + key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number) ) + cand = ready[0] - scheduled_start = max(court_timers[best_court_id], min_start) + cand.court_id = best_court_id + cand.start_time = current_time - match.court_id = best_court_id - match.start_time = scheduled_start + fin = current_time + timedelta(minutes=t.duration) + planned_finish_times[cand.id] = fin + court_timers[best_court_id] = fin + unscheduled.remove(cand) + else: + next_wake = None + for m in unscheduled: + is_ready = True + max_prereq_time = tournament_start + 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 - actual_end = scheduled_start + timedelta(minutes=t.duration) - court_timers[best_court_id] = actual_end - - for child_id in adj[current_id]: - if actual_end > match_earliest_start[child_id]: - match_earliest_start[child_id] = actual_end - - in_degree[child_id] -= 1 - if in_degree[child_id] == 0: - queue.append(child_id) + if next_wake: + court_timers[best_court_id] = next_wake + else: + break db.commit() @@ -158,7 +172,7 @@ def advance_winner(db: Session, match: models.Match, winner_id: int): loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id - def update_next_match(next_match, team_id): + def update_next_match(next_match: models.Match, team_id: int): if not next_match: return if not next_match.p1_team_id: @@ -189,16 +203,22 @@ def undo_advancement(db: Session, match: models.Match): 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, team_id): + def clear_from_next(next_match: models.Match, team_id: int): if not next_match: return + if next_match.winner_team_id: + undo_advancement(db, next_match) + next_match.winner_team_id = None + next_match.sets = [] + if next_match.p1_team_id == team_id: next_match.p1_team_id = None elif next_match.p2_team_id == team_id: next_match.p2_team_id = None - - if next_match.status == MatchStatus.PENDING: + if next_match.p1_team_id and next_match.p2_team_id: + next_match.status = MatchStatus.PENDING + else: next_match.status = MatchStatus.SCHEDULED db.add(next_match) @@ -206,5 +226,3 @@ def undo_advancement(db: Session, match: models.Match): clear_from_next(match.winner_next_match, winner_id) if match.loser_next_match and loser_id: clear_from_next(match.loser_next_match, loser_id) - - db.commit() diff --git a/frontend/src/pages/Tournament.jsx b/frontend/src/pages/Tournament.jsx index e1e711f..d241ed1 100644 --- a/frontend/src/pages/Tournament.jsx +++ b/frontend/src/pages/Tournament.jsx @@ -67,9 +67,7 @@ export default function Tournament() { time: m.start_time ? new Date(m.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '', p1_sets: m.sets?.filter(s => s.p1 > s.p2).length || 0, p2_sets: m.sets?.filter(s => s.p2 > s.p1).length || 0, - hasTeams, - isReady, isFinished }; });