Backend
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user