🏓 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:
Forge
2026-03-19 00:58:17 +08:00
commit 46fd2953c3
45 changed files with 4383 additions and 0 deletions

View File

View 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)

View 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()

View 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,
}