This commit is contained in:
2026-02-11 23:54:42 +01:00 Unverified
commit ace6f5a022
47 changed files with 5315 additions and 0 deletions
+341
View File
@@ -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