Better bracket logic

This commit is contained in:
2026-02-14 01:04:54 +01:00 Verified
parent 87d3ea5ef0
commit 7b878b3abe
32 changed files with 1326 additions and 1348 deletions
+179 -302
View File
@@ -1,333 +1,210 @@
# backend/app/logic.py
import math
from datetime import datetime, timedelta
from typing import Optional
from collections import defaultdict, deque
from datetime import timedelta
from uuid import uuid4
from sqlalchemy.orm import Session
from . import models
from .constants import (
BracketType,
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
from .constants import MatchStatus, TournamentTypes
from .core.brackets import BracketGenerator
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 None for s in seeds]
def generate_bracket_nodes(t: models.Tournament) -> list[dict]:
"""
Generates the skeleton (BracketNodes). Does NOT create Matches.
"""
def generate_bracket(db: Session, t: models.Tournament):
teams = t.teams
count = len(teams)
if count < 2:
return []
if not teams:
return
power = math.ceil(math.log2(count)) if count > 0 else 1
size = 2**power
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}
nodes = []
display_counter = 1
def resolve_target(match_node):
curr = match_node
while curr and curr.is_bye:
curr = curr.next_win
return curr
def make_id():
return str(uuid4())
db_matches = []
friendly_counter = 1
class NodeRef:
def __init__(self, bracket, round_n):
self.id = make_id()
self.bracket = bracket
self.round = round_n
self.display_num = 0
for m in abstract_matches:
real_win = resolve_target(m.next_win)
real_loss = resolve_target(m.next_loss)
self.next_win: Optional["NodeRef"] = None
self.next_loss: Optional["NodeRef"] = None
self.src_p1: Optional["NodeRef"] = None
self.src_p2: Optional["NodeRef"] = None
initial_status = MatchStatus.SCHEDULED
p1_id = None
p2_id = None
self.src_p1_type: Optional[MatchSourceType] = None
self.src_p2_type: Optional[MatchSourceType] = None
p1_seed = m.teams[0]
p2_seed = m.teams[1]
def to_dict(self):
return {
"id": self.id,
"bracket_type": self.bracket,
"round_number": self.round,
"display_number": self.display_num,
"winner_next_node_id": self.next_win.id if self.next_win else None,
"loser_next_node_id": self.next_loss.id if self.next_loss else None,
"source_p1_node_id": self.src_p1.id if self.src_p1 else None,
"source_p2_node_id": self.src_p2.id if self.src_p2 else None,
"source_p1_type": self.src_p1_type,
"source_p2_type": self.src_p2_type,
}
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
wb_rounds = power
wb_layers = {r: [] for r in range(1, wb_rounds + 1)}
if p1_id and p2_id:
initial_status = MatchStatus.PENDING
for r in range(1, wb_rounds + 1):
for _ in range(size // (2**r)):
n = NodeRef(BracketType.WINNERS, r)
wb_layers[r].append(n)
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,
loser_next_match_id=id_map[real_loss.id] if real_loss else None,
)
db_matches.append(new_match)
friendly_counter += 1
# Link Winners
for r in range(1, wb_rounds):
for i, node in enumerate(wb_layers[r]):
target = wb_layers[r + 1][i // 2]
node.next_win = target
if i % 2 == 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
lb_layers = {}
if t.type == TournamentTypes.DOUBLE and size >= 4:
lb_rounds = (wb_rounds - 1) * 2
lb_layers = {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):
n = NodeRef(BracketType.LOSERS, r)
lb_layers[r].append(n)
if r % 2 == 0:
current_count //= 2
for r in range(1, lb_rounds):
for i, node in enumerate(lb_layers[r]):
target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2]
node.next_win = target
if r % 2 != 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
if i % 2 == 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
# Link Drop-down (Winners -> Losers)
for r in range(1, wb_rounds):
drop_round = 1 if r == 1 else (r - 1) * 2
wb_layer_nodes = wb_layers[r]
lb_layer_nodes = lb_layers[drop_round]
for i, wb_node in enumerate(wb_layer_nodes):
target = None
if r == 1:
target = lb_layer_nodes[i // 2]
else:
if i < len(lb_layer_nodes):
target = lb_layer_nodes[i]
else:
target = lb_layer_nodes[-1]
wb_node.next_loss = target
slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2
if slot == WinnerSide.P1:
target.src_p1 = wb_node
target.src_p1_type = MatchSourceType.LOSER
else:
target.src_p2 = wb_node
target.src_p2_type = MatchSourceType.LOSER
# Finals
final_node = NodeRef(BracketType.FINALS, 1)
wb_final = wb_layers[wb_rounds][0]
lb_final = lb_layers[lb_rounds][0]
wb_final.next_loss = lb_final
wb_final.next_win = final_node
lb_final.next_win = final_node
final_node.src_p1 = wb_final
final_node.src_p1_type = MatchSourceType.WINNER
final_node.src_p2 = lb_final
final_node.src_p2_type = MatchSourceType.WINNER
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for r in sorted(lb_layers.keys()):
all_nodes.extend(lb_layers[r])
all_nodes.append(final_node)
else:
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for n in all_nodes:
n.display_num = display_counter
display_counter += 1
nodes.append(n.to_dict())
return nodes
def initialize_seeding(db: Session, t: models.Tournament):
seeded_teams = get_seeded_positions(
2 ** math.ceil(math.log2(t.team_count)), t.teams
)
r1_nodes = [
n
for n in t.nodes
if n.bracket_type == BracketType.WINNERS and n.round_number == 1
]
r1_nodes.sort(key=lambda x: x.display_number)
for i, node in enumerate(r1_nodes):
t1 = seeded_teams[i * 2]
t2 = seeded_teams[i * 2 + 1]
node.p1_team_id = t1.id if t1 else None
node.p2_team_id = t2.id if t2 else None
advance_flow(db, t)
def advance_flow(db: Session, t: models.Tournament):
changes = True
while changes:
changes = False
for node in t.nodes:
if node.round_number == 1 and node.bracket_type == BracketType.WINNERS:
if node.p1_team_id and not node.p2_team_id:
if _move_team(
db,
t,
node.p1_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
elif node.p2_team_id and not node.p1_team_id:
if _move_team(
db,
t,
node.p2_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
# 2. MATCH CREATION
if node.p1_team_id and node.p2_team_id:
if not node.match:
m_id = str(uuid4())
new_match = models.Match(
id=m_id,
tournament_id=t.id,
node_id=node.id,
p1_team_id=node.p1_team_id,
p2_team_id=node.p2_team_id,
status=MatchStatus.PENDING,
court_id=node.planned_court_id,
start_time=node.planned_start_time,
)
db.add(new_match)
db.commit()
db.refresh(node)
changes = True
# 3. MATCH RESULT PROPAGATION
elif (
node.match.status == MatchStatus.FINISHED
and node.match.winner_team_id
):
winner_id = node.match.winner_team_id
loser_id = (
node.match.p1_team_id
if winner_id == node.match.p2_team_id
else node.match.p2_team_id
)
if _move_team(
db,
t,
winner_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
changes = True
if _move_team(
db, t, loser_id, node.loser_next_node_id, MatchSourceType.LOSER
):
changes = True
def _move_team(db, t, team_id, target_node_id, source_type):
if not target_node_id or not team_id:
return False
target = next((n for n in t.nodes if n.id == target_node_id), None)
if not target:
return False
updated = False
if target.source_p1_type == source_type and not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif target.source_p2_type == source_type and not target.p2_team_id:
target.p2_team_id = team_id
updated = True
elif not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif not target.p2_team_id:
target.p2_team_id = team_id
updated = True
if updated:
db.add(target)
db.commit()
return updated
db.add_all(db_matches)
db.flush()
def update_schedule_times(db: Session, t: models.Tournament):
nodes = sorted(t.nodes, key=lambda n: n.display_number)
current_time = t.timestamp
courts = t.courts
if not courts:
if not t.courts:
return
court_timers: dict[int, datetime] = {c.id: current_time for c in courts}
matches = t.matches
if not matches:
return
for node in nodes:
if node.planned_start_time:
continue
# 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}
best_court_id = min(court_timers, key=lambda k: court_timers[k])
start = court_timers[best_court_id]
for m in matches:
if m.id not in adj:
adj[m.id] = []
node.planned_court_id = best_court_id
node.planned_start_time = start
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
court_timers[best_court_id] = start + timedelta(minutes=t.duration)
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
if node.match and node.match.status == MatchStatus.PENDING:
node.match.court_id = best_court_id
node.match.timestamp = start
# 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)
match_earliest_start = {m.id: tournament_start for m in matches}
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])
# 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)
while queue:
current_id = queue.popleft()
match = match_map[current_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 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)
)
scheduled_start = max(court_timers[best_court_id], min_start)
match.court_id = best_court_id
match.start_time = scheduled_start
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)
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
def update_next_match(next_match, team_id):
if not next_match:
return
if not next_match.p1_team_id:
next_match.p1_team_id = team_id
elif not next_match.p2_team_id:
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)
if match.loser_next_match and loser_id:
update_next_match(match.loser_next_match, loser_id)
db.commit()
def undo_advancement(db: Session, match: models.Match):
if not match.winner_team_id:
return
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):
if not next_match:
return
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:
next_match.status = MatchStatus.SCHEDULED
db.add(next_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()