473 lines
16 KiB
Python
473 lines
16 KiB
Python
# 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()
|