🏓 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:
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()
|
||||
Reference in New Issue
Block a user