🏓 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:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
53
backend/app/services/elo.py
Normal file
53
backend/app/services/elo.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""ELO Rating System for ServeSync"""
|
||||
|
||||
K_FACTOR = 32 # Standard K-factor
|
||||
|
||||
|
||||
def expected_score(rating_a: float, rating_b: float) -> float:
|
||||
"""Calculate expected score for player A vs player B"""
|
||||
return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
|
||||
|
||||
|
||||
def calculate_elo_change(winner_rating: float, loser_rating: float) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate ELO changes after a match.
|
||||
Returns (winner_change, loser_change)
|
||||
"""
|
||||
expected_winner = expected_score(winner_rating, loser_rating)
|
||||
expected_loser = expected_score(loser_rating, winner_rating)
|
||||
|
||||
winner_change = K_FACTOR * (1 - expected_winner)
|
||||
loser_change = K_FACTOR * (0 - expected_loser)
|
||||
|
||||
return round(winner_change, 2), round(loser_change, 2)
|
||||
|
||||
|
||||
def calculate_team_elo(player1_rating: float, player2_rating: float = None) -> float:
|
||||
"""Calculate team ELO (average for doubles)"""
|
||||
if player2_rating is None:
|
||||
return player1_rating
|
||||
return (player1_rating + player2_rating) / 2
|
||||
|
||||
|
||||
def get_tier_from_elo(elo: float) -> str:
|
||||
"""Get membership tier based on ELO rating"""
|
||||
if elo < 1000:
|
||||
return "bronze"
|
||||
elif elo < 1200:
|
||||
return "silver"
|
||||
elif elo < 1500:
|
||||
return "gold"
|
||||
elif elo < 1800:
|
||||
return "platinum"
|
||||
else:
|
||||
return "elite"
|
||||
|
||||
|
||||
def is_valid_match(player_elo: float, match_min_elo: float, match_max_elo: float) -> bool:
|
||||
"""Check if player is within ELO range for skill-based match"""
|
||||
return match_min_elo <= player_elo <= match_max_elo
|
||||
|
||||
|
||||
def get_elo_range(player_elo: float, tolerance: float = 200) -> tuple[float, float]:
|
||||
"""Get valid ELO range for matching"""
|
||||
return (max(0, player_elo - tolerance), player_elo + tolerance)
|
||||
214
backend/app/services/matchmaking.py
Normal file
214
backend/app/services/matchmaking.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Matchmaking Engine for ServeSync"""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
|
||||
from app.models.court import Court
|
||||
from app.models.player import Player
|
||||
from app.models.booking import Booking, BookingStatus
|
||||
from app.services.elo import calculate_elo_change, get_elo_range, is_valid_match
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
def create_open_match(db: Session, title: str, match_type: MatchType = MatchType.DOUBLES) -> Match:
|
||||
"""Stage 1: Create an open match lobby - no restrictions"""
|
||||
max_players = 4 if match_type == MatchType.DOUBLES else 2
|
||||
match = Match(
|
||||
stage=MatchStage.OPEN,
|
||||
match_type=match_type,
|
||||
status=MatchStatus.LOBBY,
|
||||
title=title,
|
||||
max_players=max_players,
|
||||
)
|
||||
db.add(match)
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def create_skill_match(
|
||||
db: Session,
|
||||
title: str,
|
||||
creator_elo: float,
|
||||
match_type: MatchType = MatchType.DOUBLES,
|
||||
tolerance: float = 200,
|
||||
) -> Match:
|
||||
"""Stage 2: Create a skill-based match with ELO constraints"""
|
||||
min_elo, max_elo = get_elo_range(creator_elo, tolerance)
|
||||
max_players = 4 if match_type == MatchType.DOUBLES else 2
|
||||
match = Match(
|
||||
stage=MatchStage.SKILL_BASED,
|
||||
match_type=match_type,
|
||||
status=MatchStatus.LOBBY,
|
||||
title=title,
|
||||
max_players=max_players,
|
||||
min_elo=min_elo,
|
||||
max_elo=max_elo,
|
||||
)
|
||||
db.add(match)
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def join_match(db: Session, match_id: int, player_id: int, team: int) -> Optional[MatchPlayer]:
|
||||
"""Add a player to a match lobby"""
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match or match.status != MatchStatus.LOBBY:
|
||||
return None
|
||||
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
return None
|
||||
|
||||
# Check player count
|
||||
current_players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
|
||||
if current_players >= match.max_players:
|
||||
return None
|
||||
|
||||
# Check if already joined
|
||||
existing = db.query(MatchPlayer).filter(
|
||||
MatchPlayer.match_id == match_id,
|
||||
MatchPlayer.player_id == player_id
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# For skill-based matches, check ELO constraint
|
||||
if match.stage == MatchStage.SKILL_BASED:
|
||||
if match.min_elo and match.max_elo:
|
||||
if not is_valid_match(player.elo_rating, match.min_elo, match.max_elo):
|
||||
return None
|
||||
|
||||
mp = MatchPlayer(
|
||||
match_id=match_id,
|
||||
player_id=player_id,
|
||||
team=team,
|
||||
elo_before=player.elo_rating,
|
||||
)
|
||||
db.add(mp)
|
||||
db.commit()
|
||||
db.refresh(mp)
|
||||
return mp
|
||||
|
||||
|
||||
def start_match(db: Session, match_id: int) -> Optional[Match]:
|
||||
"""Start a match - auto-assign court if available"""
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
return None
|
||||
|
||||
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
|
||||
if players < match.max_players:
|
||||
return None
|
||||
|
||||
# Auto-assign an available court
|
||||
if not match.court_id:
|
||||
court = find_available_court(db)
|
||||
if court:
|
||||
match.court_id = court.id
|
||||
# Create booking
|
||||
now = datetime.utcnow()
|
||||
booking = Booking(
|
||||
player_id=db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).first().player_id,
|
||||
court_id=court.id,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
duration_hours=1.0,
|
||||
total_cost=court.hourly_rate,
|
||||
status=BookingStatus.IN_PROGRESS,
|
||||
is_match_booking=True,
|
||||
)
|
||||
db.add(booking)
|
||||
|
||||
match.status = MatchStatus.IN_PROGRESS
|
||||
match.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def find_available_court(db: Session) -> Optional[Court]:
|
||||
"""Find an available court not currently in use"""
|
||||
from sqlalchemy import and_
|
||||
busy_court_ids = db.query(Match.court_id).filter(
|
||||
Match.status == MatchStatus.IN_PROGRESS,
|
||||
Match.court_id.isnot(None)
|
||||
).all()
|
||||
busy_ids = [c[0] for c in busy_court_ids]
|
||||
|
||||
court = db.query(Court).filter(
|
||||
Court.is_active == True,
|
||||
Court.id.notin_(busy_ids)
|
||||
).first()
|
||||
return court
|
||||
|
||||
|
||||
def complete_match(db: Session, match_id: int, team1_score: int, team2_score: int) -> Optional[Match]:
|
||||
"""Complete a match and update ELO ratings"""
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match or match.status != MatchStatus.IN_PROGRESS:
|
||||
return None
|
||||
|
||||
match.status = MatchStatus.COMPLETED
|
||||
match.team1_score = team1_score
|
||||
match.team2_score = team2_score
|
||||
match.ended_at = datetime.utcnow()
|
||||
|
||||
winner_team = 1 if team1_score > team2_score else 2
|
||||
|
||||
# Get players
|
||||
team1_players = db.query(MatchPlayer).filter(
|
||||
MatchPlayer.match_id == match_id, MatchPlayer.team == 1
|
||||
).all()
|
||||
team2_players = db.query(MatchPlayer).filter(
|
||||
MatchPlayer.match_id == match_id, MatchPlayer.team == 2
|
||||
).all()
|
||||
|
||||
# Calculate average ELO
|
||||
team1_avg = sum(p.elo_before or p.player.elo_rating for p in team1_players) / len(team1_players)
|
||||
team2_avg = sum(p.elo_before or p.player.elo_rating for p in team2_players) / len(team2_players)
|
||||
|
||||
if winner_team == 1:
|
||||
w_change, l_change = calculate_elo_change(team1_avg, team2_avg)
|
||||
else:
|
||||
l_change, w_change = calculate_elo_change(team2_avg, team1_avg)
|
||||
w_change, l_change = -l_change, -w_change
|
||||
|
||||
# Update player ELOs
|
||||
for mp in team1_players:
|
||||
is_winner = (winner_team == 1)
|
||||
change = w_change if is_winner else l_change
|
||||
mp.is_winner = is_winner
|
||||
mp.elo_change = change
|
||||
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
|
||||
mp.player.elo_rating += change
|
||||
if is_winner:
|
||||
mp.player.wins += 1
|
||||
else:
|
||||
mp.player.losses += 1
|
||||
mp.player.total_matches += 1
|
||||
|
||||
for mp in team2_players:
|
||||
is_winner = (winner_team == 2)
|
||||
change = w_change if is_winner else l_change
|
||||
mp.is_winner = is_winner
|
||||
mp.elo_change = change
|
||||
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
|
||||
mp.player.elo_rating += change
|
||||
if is_winner:
|
||||
mp.player.wins += 1
|
||||
else:
|
||||
mp.player.losses += 1
|
||||
mp.player.total_matches += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def get_lobby_matches(db: Session, stage: Optional[MatchStage] = None) -> List[Match]:
|
||||
"""Get all lobby matches, optionally filtered by stage"""
|
||||
query = db.query(Match).filter(Match.status == MatchStatus.LOBBY)
|
||||
if stage:
|
||||
query = query.filter(Match.stage == stage)
|
||||
return query.order_by(Match.created_at.desc()).all()
|
||||
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