342 lines
12 KiB
Python
342 lines
12 KiB
Python
# backend/app/logic.py
|
|
import math
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Any
|
|
|
|
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
|
|
from .models import Tournament
|
|
|
|
|
|
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 "BYE" for s in seeds]
|
|
|
|
|
|
def generate_structure(
|
|
teams: List[str], type: TournamentTypes = TournamentTypes.DOUBLE
|
|
) -> List[Dict[str, Any]]:
|
|
count = len(teams)
|
|
if count < 2:
|
|
return []
|
|
|
|
power = math.ceil(math.log2(count)) if count > 0 else 1
|
|
size = 2**power
|
|
seeded_teams = get_seeded_positions(size, teams)
|
|
|
|
class Node:
|
|
def __init__(self, id, bracket: BracketType, round_n: int):
|
|
self.id = str(id)
|
|
self.bracket = bracket
|
|
self.round = round_n
|
|
self.p1: str | None = None
|
|
self.p2: str | None = None
|
|
|
|
self.winner_next_match_id: str | None = None
|
|
self.loser_next_match_id: str | None = None
|
|
self.previous_match_p1_id: str | None = None
|
|
self.previous_match_p2_id: str | None = None
|
|
|
|
self.source_p1_type: MatchSourceType | None = None
|
|
self.source_p2_type: MatchSourceType | None = None
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"id": self.id,
|
|
# Pass Enum OBJECTS, not strings. SQLAlchemy handles the rest.
|
|
"bracket": self.bracket,
|
|
"round": self.round,
|
|
"p1_name": self.p1,
|
|
"p2_name": self.p2,
|
|
"status": MatchStatus.PENDING,
|
|
"previous_match_p1_id": self.previous_match_p1_id,
|
|
"previous_match_p2_id": self.previous_match_p2_id,
|
|
"source_p1_type": self.source_p1_type,
|
|
"source_p2_type": self.source_p2_type,
|
|
"winner_next_match_id": self.winner_next_match_id,
|
|
"loser_next_match_id": self.loser_next_match_id,
|
|
}
|
|
|
|
nodes: List[Node] = []
|
|
match_counter = 1
|
|
|
|
def create_node(bracket: BracketType, round_n: int):
|
|
nonlocal match_counter
|
|
n = Node(match_counter, bracket, round_n)
|
|
match_counter += 1
|
|
nodes.append(n)
|
|
return n
|
|
|
|
# --- Winners Bracket ---
|
|
wb_rounds = power
|
|
wb_matches = {r: [] for r in range(1, wb_rounds + 1)}
|
|
|
|
for r in range(1, wb_rounds + 1):
|
|
for _ in range(size // (2**r)):
|
|
wb_matches[r].append(create_node(BracketType.WINNERS, r))
|
|
|
|
# Link Winners
|
|
for r in range(1, wb_rounds):
|
|
for i, m in enumerate(wb_matches[r]):
|
|
target = wb_matches[r + 1][i // 2]
|
|
|
|
m.winner_next_match_id = target.id
|
|
if i % 2 == 0:
|
|
target.previous_match_p1_id = m.id
|
|
target.source_p1_type = MatchSourceType.WINNER
|
|
else:
|
|
target.previous_match_p2_id = m.id
|
|
target.source_p2_type = MatchSourceType.WINNER
|
|
|
|
for i, m in enumerate(wb_matches[1]):
|
|
m.p1 = seeded_teams[i * 2]
|
|
m.p2 = seeded_teams[i * 2 + 1]
|
|
|
|
# --- Losers Bracket ---
|
|
if type == TournamentTypes.DOUBLE and size >= 4:
|
|
lb_rounds = (wb_rounds - 1) * 2
|
|
lb_matches = {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):
|
|
lb_matches[r].append(create_node(BracketType.LOSERS, r))
|
|
if r % 2 == 0:
|
|
current_count //= 2
|
|
|
|
# Link Losers Internal
|
|
for r in range(1, lb_rounds):
|
|
for i, m in enumerate(lb_matches[r]):
|
|
target = (
|
|
lb_matches[r + 1][i] if r % 2 != 0 else lb_matches[r + 1][i // 2]
|
|
)
|
|
|
|
m.winner_next_match_id = target.id
|
|
if r % 2 != 0:
|
|
target.previous_match_p1_id = m.id
|
|
target.source_p1_type = MatchSourceType.WINNER
|
|
else:
|
|
if i % 2 == 0:
|
|
target.previous_match_p1_id = m.id
|
|
target.source_p1_type = MatchSourceType.WINNER
|
|
else:
|
|
target.previous_match_p2_id = m.id
|
|
target.source_p2_type = MatchSourceType.WINNER
|
|
|
|
# Link Losers Drop-down
|
|
for r in range(1, wb_rounds):
|
|
drop_round = 1 if r == 1 else (r - 1) * 2
|
|
wb_layer = wb_matches[r]
|
|
lb_layer = lb_matches[drop_round]
|
|
|
|
for i, wb_m in enumerate(wb_layer):
|
|
target = (
|
|
lb_layer[i // 2]
|
|
if r == 1
|
|
else (lb_layer[i] if i < len(lb_layer) else lb_layer[-1])
|
|
)
|
|
slot = "p1" if (r == 1 and i % 2 == 0) else "p2"
|
|
|
|
wb_m.loser_next_match_id = target.id
|
|
if slot == "p1":
|
|
target.previous_match_p1_id = wb_m.id
|
|
target.source_p1_type = MatchSourceType.LOSER
|
|
else:
|
|
target.previous_match_p2_id = wb_m.id
|
|
target.source_p2_type = MatchSourceType.LOSER
|
|
|
|
# Finals Linking
|
|
wb_final = wb_matches[wb_rounds][0]
|
|
lb_final = lb_matches[lb_rounds][0]
|
|
|
|
wb_final.loser_next_match_id = lb_final.id
|
|
lb_final.previous_match_p2_id = wb_final.id
|
|
lb_final.source_p2_type = MatchSourceType.LOSER
|
|
|
|
final = create_node(BracketType.FINALS, 1)
|
|
wb_final.winner_next_match_id = final.id
|
|
lb_final.winner_next_match_id = final.id
|
|
|
|
final.previous_match_p1_id = wb_final.id
|
|
final.source_p1_type = MatchSourceType.WINNER
|
|
final.previous_match_p2_id = lb_final.id
|
|
final.source_p2_type = MatchSourceType.WINNER
|
|
|
|
return [n.to_dict() for n in nodes]
|
|
|
|
|
|
def refresh_bracket(t_obj: Tournament):
|
|
matches_map = {m.id: m for m in t_obj.matches}
|
|
|
|
for _ in range(20):
|
|
for m in t_obj.matches:
|
|
|
|
def resolve(src_id, type_):
|
|
if not src_id or src_id not in matches_map:
|
|
return None
|
|
src = matches_map[src_id]
|
|
|
|
if type_ == MatchSourceType.WINNER:
|
|
return src.winner
|
|
if type_ == MatchSourceType.LOSER:
|
|
if src.winner == "BYE":
|
|
return "BYE"
|
|
if src.winner:
|
|
return src.p1_name if src.winner == src.p2_name else src.p2_name
|
|
return None
|
|
return None
|
|
|
|
if m.previous_match_p1_id:
|
|
m.p1_name = resolve(m.previous_match_p1_id, m.source_p1_type)
|
|
if m.previous_match_p2_id:
|
|
m.p2_name = resolve(m.previous_match_p2_id, m.source_p2_type)
|
|
|
|
# BYE Auto-Win
|
|
if not m.winner and (m.p1_name == "BYE" or m.p2_name == "BYE"):
|
|
if m.p1_name == "BYE" and m.p2_name == "BYE":
|
|
m.winner = "BYE"
|
|
elif m.p1_name == "BYE":
|
|
m.winner = m.p2_name
|
|
else:
|
|
m.winner = m.p1_name
|
|
m.status = MatchStatus.FINISHED
|
|
|
|
# Reset Logic
|
|
if m.status == MatchStatus.FINISHED and m.winner != "BYE":
|
|
has_p1 = bool(m.p1_name)
|
|
has_p2 = bool(m.p2_name)
|
|
if (
|
|
not has_p1
|
|
or not has_p2
|
|
or (m.winner != m.p1_name and m.winner != m.p2_name)
|
|
):
|
|
m.winner = None
|
|
m.status = MatchStatus.PENDING
|
|
m.sets = []
|
|
|
|
# Numbering
|
|
display_counter = 1
|
|
sorted_matches = sorted(
|
|
t_obj.matches, key=lambda x: int(x.id) if x.id.isdigit() else 999
|
|
)
|
|
for m in sorted_matches:
|
|
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
|
|
m.number = None
|
|
else:
|
|
m.number = display_counter
|
|
display_counter += 1
|
|
|
|
# NO LABEL GENERATION HERE - FRONTEND HANDLES IT
|
|
|
|
|
|
def update_schedule(t_obj: Tournament):
|
|
match_map = {m.id: m for m in t_obj.matches}
|
|
depth_cache = {}
|
|
|
|
def get_depth(mid):
|
|
if mid not in match_map:
|
|
return 0
|
|
if mid in depth_cache:
|
|
return depth_cache[mid]
|
|
m = match_map[mid]
|
|
d = 1 + max(
|
|
get_depth(m.winner_next_match_id) if m.winner_next_match_id else 0,
|
|
get_depth(m.loser_next_match_id) if m.loser_next_match_id else 0,
|
|
)
|
|
depth_cache[mid] = d
|
|
return d
|
|
|
|
criticality_map = {}
|
|
for m in t_obj.matches:
|
|
criticality_map[m.id] = get_depth(m.id)
|
|
|
|
start_time = t_obj.timestamp
|
|
duration = t_obj.duration
|
|
|
|
finish_times: Dict[str, datetime] = {}
|
|
court_timers: Dict[str, datetime] = {c.name: start_time for c in t_obj.courts}
|
|
|
|
unscheduled = []
|
|
|
|
# 1. Initialize
|
|
for m in t_obj.matches:
|
|
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
|
|
finish_times[m.id] = start_time
|
|
m.status = MatchStatus.FINISHED
|
|
elif m.status == MatchStatus.FINISHED:
|
|
match_start = m.timestamp if m.timestamp else start_time
|
|
fin = match_start + timedelta(minutes=duration)
|
|
finish_times[m.id] = fin
|
|
if m.court_name and m.court_name in court_timers:
|
|
if fin > court_timers[m.court_name]:
|
|
court_timers[m.court_name] = fin
|
|
else:
|
|
m.timestamp = None
|
|
m.court_name = None
|
|
m.status = MatchStatus.PENDING
|
|
unscheduled.append(m)
|
|
|
|
if not court_timers:
|
|
return
|
|
|
|
# 2. Schedule
|
|
loop = len(t_obj.matches) * 2
|
|
while unscheduled and loop > 0:
|
|
loop -= 1
|
|
best_court = min(court_timers, key=lambda k: court_timers[k])
|
|
current_time_slot = court_timers[best_court]
|
|
|
|
ready = []
|
|
for m in unscheduled:
|
|
p1_r = (
|
|
finish_times.get(m.previous_match_p1_id, start_time)
|
|
if m.previous_match_p1_id
|
|
else start_time
|
|
)
|
|
p2_r = (
|
|
finish_times.get(m.previous_match_p2_id, start_time)
|
|
if m.previous_match_p2_id
|
|
else start_time
|
|
)
|
|
|
|
if max(p1_r, p2_r) <= current_time_slot:
|
|
ready.append(m)
|
|
|
|
if ready:
|
|
ready.sort(key=lambda x: (-criticality_map.get(x.id, 0), x.round))
|
|
cand = ready[0]
|
|
cand.court_name = best_court
|
|
cand.timestamp = current_time_slot
|
|
cand.status = MatchStatus.SCHEDULED
|
|
|
|
fin = current_time_slot + timedelta(minutes=duration)
|
|
finish_times[cand.id] = fin
|
|
court_timers[best_court] = fin
|
|
unscheduled.remove(cand)
|
|
else:
|
|
next_wake = None
|
|
for m in unscheduled:
|
|
p1_r = (
|
|
finish_times.get(m.previous_match_p1_id, start_time)
|
|
if m.previous_match_p1_id
|
|
else start_time
|
|
)
|
|
p2_r = (
|
|
finish_times.get(m.previous_match_p2_id, start_time)
|
|
if m.previous_match_p2_id
|
|
else start_time
|
|
)
|
|
ready_at = max(p1_r, p2_r)
|
|
if ready_at > current_time_slot:
|
|
if next_wake is None or ready_at < next_wake:
|
|
next_wake = ready_at
|
|
if next_wake:
|
|
court_timers[best_court] = next_wake
|
|
else:
|
|
break
|