🏓 Initial ServeSync demo build
- FastAPI backend with PostgreSQL + Redis - 4 core features: Registration, Courts, Matchmaking, Tournament - Double elimination tournament with bracket visualization - ELO-based matchmaking (Stage 1 Open, Stage 2 Skill-Based) - Real-time WebSocket updates - TV Screen display for courts - Vue 3 + Tailwind CSS frontend - Seed data: 12 players, 4 courts, active matches, tournament in progress - Docker compose stack with Nginx reverse proxy
This commit is contained in:
281
backend/app/services/tournament.py
Normal file
281
backend/app/services/tournament.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Double Elimination Tournament Engine for ServeSync"""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
|
||||
from app.models.player import Player
|
||||
from app.models.court import Court
|
||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
||||
from app.services.matchmaking import find_available_court
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
import math
|
||||
|
||||
|
||||
def create_tournament(db: Session, name: str, max_participants: int = 8) -> Tournament:
|
||||
"""Create a new tournament"""
|
||||
t = Tournament(name=name, max_participants=max_participants)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
def register_player(db: Session, tournament_id: int, player_id: int) -> Optional[TournamentEntry]:
|
||||
"""Register a player for the tournament"""
|
||||
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
||||
if not t or t.status != TournamentStatus.REGISTRATION:
|
||||
return None
|
||||
|
||||
current_count = db.query(TournamentEntry).filter(TournamentEntry.tournament_id == tournament_id).count()
|
||||
if current_count >= t.max_participants:
|
||||
return None
|
||||
|
||||
existing = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tournament_id,
|
||||
TournamentEntry.player_id == player_id
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
entry = TournamentEntry(tournament_id=tournament_id, player_id=player_id)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def start_tournament(db: Session, tournament_id: int) -> Optional[Tournament]:
|
||||
"""Start tournament and generate initial bracket"""
|
||||
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
||||
if not t or t.status != TournamentStatus.REGISTRATION:
|
||||
return None
|
||||
|
||||
entries = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tournament_id
|
||||
).join(Player).order_by(Player.elo_rating.desc()).all()
|
||||
|
||||
if len(entries) < 4:
|
||||
return None
|
||||
|
||||
# Seed players by ELO
|
||||
for i, entry in enumerate(entries):
|
||||
entry.seed = i + 1
|
||||
|
||||
# Generate bracket matches
|
||||
_generate_winners_bracket(db, t, entries)
|
||||
|
||||
t.status = TournamentStatus.IN_PROGRESS
|
||||
t.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
def _generate_winners_bracket(db: Session, tournament: Tournament, entries: List[TournamentEntry]):
|
||||
"""Generate the initial winners bracket matches"""
|
||||
n = len(entries)
|
||||
# Pair up by seeding (1 vs n, 2 vs n-1, etc.)
|
||||
match_num = 1
|
||||
pairs = []
|
||||
for i in range(n // 2):
|
||||
p1 = entries[i]
|
||||
p2 = entries[n - 1 - i]
|
||||
pairs.append((p1, p2))
|
||||
|
||||
for p1, p2 in pairs:
|
||||
tm = TournamentMatch(
|
||||
tournament_id=tournament.id,
|
||||
round_number=1,
|
||||
match_number=match_num,
|
||||
bracket_type=BracketType.WINNERS,
|
||||
status=TournamentMatchStatus.PENDING,
|
||||
player1_id=p1.player_id,
|
||||
player2_id=p2.player_id,
|
||||
)
|
||||
db.add(tm)
|
||||
match_num += 1
|
||||
|
||||
db.flush()
|
||||
|
||||
|
||||
def complete_tournament_match(
|
||||
db: Session,
|
||||
tournament_match_id: int,
|
||||
team1_score: int,
|
||||
team2_score: int
|
||||
) -> Optional[TournamentMatch]:
|
||||
"""Complete a tournament match and advance bracket"""
|
||||
tm = db.query(TournamentMatch).filter(TournamentMatch.id == tournament_match_id).first()
|
||||
if not tm:
|
||||
return None
|
||||
|
||||
tm.team1_score = team1_score
|
||||
tm.team2_score = team2_score
|
||||
tm.winner_team = 1 if team1_score > team2_score else 2
|
||||
tm.status = TournamentMatchStatus.COMPLETED
|
||||
tm.completed_at = datetime.utcnow()
|
||||
|
||||
winner_player_id = tm.player1_id if tm.winner_team == 1 else tm.player2_id
|
||||
loser_player_id = tm.player2_id if tm.winner_team == 1 else tm.player1_id
|
||||
|
||||
# Update entry stats
|
||||
winner_entry = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tm.tournament_id,
|
||||
TournamentEntry.player_id == winner_player_id
|
||||
).first()
|
||||
loser_entry = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tm.tournament_id,
|
||||
TournamentEntry.player_id == loser_player_id
|
||||
).first()
|
||||
|
||||
if winner_entry:
|
||||
winner_entry.wins += 1
|
||||
if loser_entry:
|
||||
loser_entry.losses += 1
|
||||
|
||||
# Double elimination logic
|
||||
if loser_entry and loser_entry.losses < 2:
|
||||
# Move to losers bracket
|
||||
loser_entry.is_in_losers = True
|
||||
_create_losers_match(db, tm, loser_player_id)
|
||||
elif loser_entry:
|
||||
# Eliminated
|
||||
loser_entry.is_eliminated = True
|
||||
_assign_final_ranks(db, tm.tournament_id)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tm)
|
||||
return tm
|
||||
|
||||
|
||||
def _create_losers_match(db: Session, completed_match: TournamentMatch, loser_id: int):
|
||||
"""Route loser to losers bracket"""
|
||||
# Find existing pending losers match in next round
|
||||
next_round = completed_match.round_number + 1
|
||||
existing = db.query(TournamentMatch).filter(
|
||||
TournamentMatch.tournament_id == completed_match.tournament_id,
|
||||
TournamentMatch.bracket_type == BracketType.LOSERS,
|
||||
TournamentMatch.round_number == next_round,
|
||||
TournamentMatch.player2_id.is_(None),
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.player2_id = loser_id
|
||||
else:
|
||||
# Create a new losers match
|
||||
match_count = db.query(TournamentMatch).filter(
|
||||
TournamentMatch.tournament_id == completed_match.tournament_id,
|
||||
TournamentMatch.bracket_type == BracketType.LOSERS,
|
||||
TournamentMatch.round_number == next_round,
|
||||
).count()
|
||||
|
||||
tm = TournamentMatch(
|
||||
tournament_id=completed_match.tournament_id,
|
||||
round_number=next_round,
|
||||
match_number=match_count + 1,
|
||||
bracket_type=BracketType.LOSERS,
|
||||
status=TournamentMatchStatus.PENDING,
|
||||
player1_id=loser_id,
|
||||
)
|
||||
db.add(tm)
|
||||
|
||||
|
||||
def _assign_final_ranks(db: Session, tournament_id: int):
|
||||
"""Assign final ranks to eliminated players"""
|
||||
entries = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tournament_id,
|
||||
TournamentEntry.is_eliminated == True,
|
||||
TournamentEntry.final_rank.is_(None)
|
||||
).order_by(TournamentEntry.losses.desc()).all()
|
||||
|
||||
remaining = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tournament_id,
|
||||
TournamentEntry.is_eliminated == False
|
||||
).count()
|
||||
|
||||
base_rank = remaining + 1
|
||||
for i, entry in enumerate(entries):
|
||||
entry.final_rank = base_rank + i
|
||||
|
||||
|
||||
def get_bracket_data(db: Session, tournament_id: int) -> dict:
|
||||
"""Get structured bracket data for frontend visualization"""
|
||||
tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first()
|
||||
if not tournament:
|
||||
return {}
|
||||
|
||||
matches = db.query(TournamentMatch).filter(
|
||||
TournamentMatch.tournament_id == tournament_id
|
||||
).order_by(TournamentMatch.round_number, TournamentMatch.match_number).all()
|
||||
|
||||
entries = db.query(TournamentEntry).filter(
|
||||
TournamentEntry.tournament_id == tournament_id
|
||||
).all()
|
||||
|
||||
players_map = {}
|
||||
for entry in entries:
|
||||
player = db.query(Player).filter(Player.id == entry.player_id).first()
|
||||
if player:
|
||||
players_map[player.id] = {
|
||||
"id": player.id,
|
||||
"name": player.name,
|
||||
"elo": player.elo_rating,
|
||||
"seed": entry.seed,
|
||||
"wins": entry.wins,
|
||||
"losses": entry.losses,
|
||||
"is_eliminated": entry.is_eliminated,
|
||||
"is_in_losers": entry.is_in_losers,
|
||||
"final_rank": entry.final_rank,
|
||||
}
|
||||
|
||||
winners_bracket = []
|
||||
losers_bracket = []
|
||||
grand_final = []
|
||||
|
||||
for m in matches:
|
||||
match_data = {
|
||||
"id": m.id,
|
||||
"round": m.round_number,
|
||||
"match_number": m.match_number,
|
||||
"status": m.status.value,
|
||||
"player1": players_map.get(m.player1_id),
|
||||
"player2": players_map.get(m.player2_id),
|
||||
"team1_score": m.team1_score,
|
||||
"team2_score": m.team2_score,
|
||||
"winner_team": m.winner_team,
|
||||
}
|
||||
if m.bracket_type == BracketType.WINNERS:
|
||||
winners_bracket.append(match_data)
|
||||
elif m.bracket_type == BracketType.LOSERS:
|
||||
losers_bracket.append(match_data)
|
||||
else:
|
||||
grand_final.append(match_data)
|
||||
|
||||
# Build leaderboard
|
||||
leaderboard = sorted(
|
||||
[
|
||||
{
|
||||
"player": players_map[e.player_id],
|
||||
"wins": e.wins,
|
||||
"losses": e.losses,
|
||||
"is_eliminated": e.is_eliminated,
|
||||
"is_in_losers": e.is_in_losers,
|
||||
"final_rank": e.final_rank,
|
||||
}
|
||||
for e in entries
|
||||
if e.player_id in players_map
|
||||
],
|
||||
key=lambda x: (x["final_rank"] or 999, -x["wins"]),
|
||||
)
|
||||
|
||||
return {
|
||||
"tournament": {
|
||||
"id": tournament.id,
|
||||
"name": tournament.name,
|
||||
"status": tournament.status.value,
|
||||
"current_round": tournament.current_round,
|
||||
},
|
||||
"winners_bracket": winners_bracket,
|
||||
"losers_bracket": losers_bracket,
|
||||
"grand_final": grand_final,
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
Reference in New Issue
Block a user