- 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
215 lines
6.9 KiB
Python
215 lines
6.9 KiB
Python
"""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()
|