🏓 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

218
backend/app/api/courts.py Normal file
View File

@@ -0,0 +1,218 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.database import get_db
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.player import Player
from app.models.match import Match, MatchStatus
from pydantic import BaseModel
from datetime import datetime, timedelta
from typing import Optional, List
router = APIRouter(prefix="/courts", tags=["courts"])
class BookingCreate(BaseModel):
player_id: int
court_id: int
start_time: datetime
duration_hours: float = 1.0
class CourtResponse(BaseModel):
id: int
name: str
court_number: int
hourly_rate: float
is_active: bool
surface_type: str
features: str
current_status: str
current_match: Optional[dict] = None
class Config:
from_attributes = True
class BookingResponse(BaseModel):
id: int
player_id: int
player_name: str
court_id: int
court_name: str
start_time: datetime
end_time: datetime
duration_hours: float
total_cost: float
status: str
class Config:
from_attributes = True
def get_court_status(court: Court, db: Session) -> tuple:
"""Get current status of a court"""
# Check if match is in progress
match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
if match:
team1 = [mp.player.name for mp in match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in match.match_players if mp.team == 2]
return "occupied", {
"match_id": match.id,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"stage": match.stage.value,
"started_at": match.started_at.isoformat() if match.started_at else None,
}
# Check if there's a booking now
now = datetime.utcnow()
booking = db.query(Booking).filter(
Booking.court_id == court.id,
Booking.start_time <= now,
Booking.end_time >= now,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).first()
if booking:
return "booked", None
return "available", None
@router.get("/", response_model=List[CourtResponse])
def get_courts(db: Session = Depends(get_db)):
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
status, match_info = get_court_status(court, db)
result.append(CourtResponse(
id=court.id,
name=court.name,
court_number=court.court_number,
hourly_rate=court.hourly_rate,
is_active=court.is_active,
surface_type=court.surface_type,
features=court.features,
current_status=status,
current_match=match_info,
))
return result
@router.get("/{court_id}")
def get_court(court_id: int, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
status, match_info = get_court_status(court, db)
return {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"hourly_rate": court.hourly_rate,
"surface_type": court.surface_type,
"features": court.features,
"current_status": status,
"current_match": match_info,
}
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
"""Get bookings for a court for the next 7 days"""
now = datetime.utcnow()
end = now + timedelta(days=7)
bookings = db.query(Booking).filter(
Booking.court_id == court_id,
Booking.start_time >= now,
Booking.end_time <= end,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).order_by(Booking.start_time).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"duration_hours": b.duration_hours,
"is_match_booking": b.is_match_booking,
}
for b in bookings
]
@router.post("/book")
def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == booking_data.court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
player = db.query(Player).filter(Player.id == booking_data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours)
# Check for conflicts
conflict = db.query(Booking).filter(
Booking.court_id == booking_data.court_id,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]),
and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time)
).first()
if conflict:
raise HTTPException(status_code=400, detail="Court is already booked for this time slot")
total_cost = court.hourly_rate * booking_data.duration_hours
booking = Booking(
player_id=booking_data.player_id,
court_id=booking_data.court_id,
start_time=booking_data.start_time,
end_time=end_time,
duration_hours=booking_data.duration_hours,
total_cost=total_cost,
status=BookingStatus.CONFIRMED,
)
db.add(booking)
db.commit()
db.refresh(booking)
return {
"id": booking.id,
"court_name": court.name,
"player_name": player.name,
"start_time": booking.start_time.isoformat(),
"end_time": booking.end_time.isoformat(),
"total_cost": booking.total_cost,
"status": booking.status.value,
}
@router.get("/bookings/upcoming")
def get_upcoming_bookings(db: Session = Depends(get_db)):
now = datetime.utcnow()
bookings = db.query(Booking).filter(
Booking.start_time >= now,
Booking.status == BookingStatus.CONFIRMED
).order_by(Booking.start_time).limit(20).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"court_name": b.court.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"total_cost": b.total_cost,
}
for b in bookings
]

160
backend/app/api/matches.py Normal file
View File

@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.player import Player
from app.services import matchmaking
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/matches", tags=["matches"])
class CreateMatchRequest(BaseModel):
title: str
stage: str = "open"
match_type: str = "doubles"
creator_player_id: int
elo_tolerance: float = 200
class JoinMatchRequest(BaseModel):
player_id: int
team: int
class ScoreUpdateRequest(BaseModel):
team1_score: int
team2_score: int
def match_to_dict(match: Match, db: Session) -> dict:
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all()
team1 = []
team2 = []
for mp in players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pdata = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"elo_change": mp.elo_change,
}
if mp.team == 1:
team1.append(pdata)
else:
team2.append(pdata)
return {
"id": match.id,
"title": match.title,
"stage": match.stage.value,
"match_type": match.match_type.value,
"status": match.status.value,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"max_players": match.max_players,
"current_players": len(players),
"min_elo": match.min_elo,
"max_elo": match.max_elo,
"court_id": match.court_id,
"court_name": match.court.name if match.court else None,
"started_at": match.started_at.isoformat() if match.started_at else None,
"ended_at": match.ended_at.isoformat() if match.ended_at else None,
"created_at": match.created_at.isoformat() if match.created_at else None,
}
@router.get("/")
def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(Match)
if status:
query = query.filter(Match.status == status)
if stage:
query = query.filter(Match.stage == stage)
matches = query.order_by(Match.created_at.desc()).limit(50).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/lobby")
def get_lobby(db: Session = Depends(get_db)):
matches = matchmaking.get_lobby_matches(db)
return [match_to_dict(m, db) for m in matches]
@router.get("/active")
def get_active_matches(db: Session = Depends(get_db)):
matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/{match_id}")
def get_match(match_id: int, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
return match_to_dict(match, db)
@router.post("/")
def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == req.creator_player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES
if req.stage == "open":
match = matchmaking.create_open_match(db, req.title, match_type)
elif req.stage == "skill_based":
match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance)
else:
raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'")
# Auto-join creator to team 1
matchmaking.join_match(db, match.id, player.id, 1)
return match_to_dict(match, db)
@router.post("/{match_id}/join")
def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)):
result = matchmaking.join_match(db, match_id, req.player_id, req.team)
if not result:
raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)")
match = db.query(Match).filter(Match.id == match_id).first()
return match_to_dict(match, db)
@router.post("/{match_id}/start")
def start_match(match_id: int, db: Session = Depends(get_db)):
match = matchmaking.start_match(db, match_id)
if not match:
raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)")
return match_to_dict(match, db)
@router.post("/{match_id}/score")
def update_score(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
if match.status != MatchStatus.IN_PROGRESS:
raise HTTPException(status_code=400, detail="Match not in progress")
match.team1_score = req.team1_score
match.team2_score = req.team2_score
db.commit()
return match_to_dict(match, db)
@router.post("/{match_id}/complete")
def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score)
if not match:
raise HTTPException(status_code=400, detail="Cannot complete match")
return match_to_dict(match, db)

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.player import Player, MembershipTier
from app.services.elo import get_tier_from_elo
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/players", tags=["players"])
class PlayerCreate(BaseModel):
name: str
email: str
phone: Optional[str] = None
class PlayerUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
class PlayerResponse(BaseModel):
id: int
name: str
email: str
phone: Optional[str]
elo_rating: float
membership_tier: str
wins: int
losses: int
total_matches: int
win_rate: float
avatar_color: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, player: Player):
return cls(
id=player.id,
name=player.name,
email=player.email,
phone=player.phone,
elo_rating=round(player.elo_rating, 1),
membership_tier=player.membership_tier.value,
wins=player.wins,
losses=player.losses,
total_matches=player.total_matches,
win_rate=player.win_rate,
avatar_color=player.avatar_color,
)
@router.get("/", response_model=List[PlayerResponse])
def get_players(db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/leaderboard", response_model=List[PlayerResponse])
def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/{player_id}", response_model=PlayerResponse)
def get_player(player_id: int, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerResponse.from_orm(player)
@router.post("/", response_model=PlayerResponse)
def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)):
existing = db.query(Player).filter(Player.email == player_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
color = colors[db.query(Player).count() % len(colors)]
player = Player(
name=player_data.name,
email=player_data.email,
phone=player_data.phone,
avatar_color=color,
)
db.add(player)
db.commit()
db.refresh(player)
return PlayerResponse.from_orm(player)

156
backend/app/api/screen.py Normal file
View File

@@ -0,0 +1,156 @@
"""Screen Display API - for TV/display views"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.database import get_db
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStatus
from app.models.player import Player
from app.models.tournament import Tournament, TournamentStatus
from app.services.tournament import get_bracket_data
from datetime import datetime
router = APIRouter(prefix="/screen", tags=["screen"])
@router.get("/court/{court_id}")
def get_court_display(court_id: int, db: Session = Depends(get_db)):
"""Full court display data for TV screen"""
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
return {"error": "Court not found"}
# Current active match
active_match = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.IN_PROGRESS
).first()
match_data = None
if active_match:
team1_players = []
team2_players = []
for mp in active_match.match_players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pd = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"membership_tier": p.membership_tier.value,
}
if mp.team == 1:
team1_players.append(pd)
else:
team2_players.append(pd)
elapsed = None
if active_match.started_at:
elapsed = int((datetime.utcnow() - active_match.started_at).total_seconds())
match_data = {
"id": active_match.id,
"stage": active_match.stage.value,
"match_type": active_match.match_type.value,
"team1": team1_players,
"team2": team2_players,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"elapsed_seconds": elapsed,
}
# Recent completed matches on this court
recent_matches = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.COMPLETED
).order_by(desc(Match.ended_at)).limit(3).all()
recent = []
for m in recent_matches:
team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 1]
team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 2]
recent.append({
"team1": team1,
"team2": team2,
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner": "team1" if m.team1_score > m.team2_score else "team2",
})
return {
"court": {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"surface_type": court.surface_type,
},
"active_match": match_data,
"recent_matches": recent,
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/overview")
def get_overview(db: Session = Depends(get_db)):
"""All courts overview for main display"""
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
active_match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
court_data = {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"status": "occupied" if active_match else "available",
}
if active_match:
team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2]
court_data["match"] = {
"team1_names": team1,
"team2_names": team2,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"stage": active_match.stage.value,
}
result.append(court_data)
# Active tournament
tournament_data = None
active_tournament = db.query(Tournament).filter(
Tournament.status == TournamentStatus.IN_PROGRESS
).first()
if active_tournament:
tournament_data = get_bracket_data(db, active_tournament.id)
# Leaderboard top 5
top_players = db.query(Player).filter(Player.is_active == True).order_by(
Player.elo_rating.desc()
).limit(5).all()
return {
"courts": result,
"tournament": tournament_data,
"leaderboard": [
{
"rank": i + 1,
"name": p.name,
"elo": round(p.elo_rating, 1),
"tier": p.membership_tier.value,
"wins": p.wins,
"losses": p.losses,
"avatar_color": p.avatar_color,
}
for i, p in enumerate(top_players)
],
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus
from app.services import tournament as tournament_service
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/tournaments", tags=["tournaments"])
class CreateTournamentRequest(BaseModel):
name: str
max_participants: int = 8
class RegisterPlayerRequest(BaseModel):
player_id: int
class MatchScoreRequest(BaseModel):
team1_score: int
team2_score: int
@router.get("/")
def get_tournaments(db: Session = Depends(get_db)):
tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"status": t.status.value,
"max_participants": t.max_participants,
"current_participants": len(t.entries),
"started_at": t.started_at.isoformat() if t.started_at else None,
}
for t in tournaments
]
@router.get("/{tournament_id}")
def get_tournament(tournament_id: int, db: Session = Depends(get_db)):
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/")
def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)):
t = tournament_service.create_tournament(db, req.name, req.max_participants)
return {"id": t.id, "name": t.name, "status": t.status.value}
@router.post("/{tournament_id}/register")
def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)):
entry = tournament_service.register_player(db, tournament_id, req.player_id)
if not entry:
raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)")
return {"message": "Registered successfully", "player_id": req.player_id}
@router.post("/{tournament_id}/start")
def start_tournament(tournament_id: int, db: Session = Depends(get_db)):
t = tournament_service.start_tournament(db, tournament_id)
if not t:
raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)")
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/{tournament_id}/matches/{match_id}/score")
def complete_tournament_match(
tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db)
):
tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score)
if not tm:
raise HTTPException(status_code=400, detail="Cannot complete match")
return tournament_service.get_bracket_data(db, tournament_id)
@router.get("/{tournament_id}/leaderboard")
def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)):
data = tournament_service.get_bracket_data(db, tournament_id)
return data.get("leaderboard", [])

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://servesync:servesync@db:5432/servesync"
REDIS_URL: str = "redis://redis:6379"
SECRET_KEY: str = "servesync-demo-secret-key-2024"
DEBUG: bool = True
class Config:
env_file = ".env"
settings = Settings()

16
backend/app/database.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

141
backend/app/main.py Normal file
View File

@@ -0,0 +1,141 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import asyncio
import json
from typing import List, Dict
from app.database import engine, SessionLocal
from app.models import player, court, booking, match, tournament
# Create all tables
player.Base.metadata.create_all(bind=engine)
court.Base.metadata.create_all(bind=engine)
booking.Base.metadata.create_all(bind=engine)
match.Base.metadata.create_all(bind=engine)
tournament.Base.metadata.create_all(bind=engine)
from app.api import players, courts, matches, tournaments, screen
app = FastAPI(
title="ServeSync API",
description="Pickleball Court Management System",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(players.router, prefix="/api")
app.include_router(courts.router, prefix="/api")
app.include_router(matches.router, prefix="/api")
app.include_router(tournaments.router, prefix="/api")
app.include_router(screen.router, prefix="/api")
# WebSocket connection manager
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, channel: str):
await websocket.accept()
if channel not in self.active_connections:
self.active_connections[channel] = []
self.active_connections[channel].append(websocket)
def disconnect(self, websocket: WebSocket, channel: str):
if channel in self.active_connections:
self.active_connections[channel].remove(websocket)
async def broadcast(self, channel: str, data: dict):
if channel in self.active_connections:
disconnected = []
for connection in self.active_connections[channel]:
try:
await connection.send_text(json.dumps(data))
except Exception:
disconnected.append(connection)
for conn in disconnected:
self.active_connections[channel].remove(conn)
manager = ConnectionManager()
@app.websocket("/ws/court/{court_id}")
async def websocket_court(websocket: WebSocket, court_id: int):
"""WebSocket for real-time court display updates"""
channel = f"court_{court_id}"
await manager.connect(websocket, channel)
try:
while True:
# Send court data every 3 seconds
db = SessionLocal()
try:
from app.api.screen import get_court_display
data = get_court_display(court_id, db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/overview")
async def websocket_overview(websocket: WebSocket):
"""WebSocket for real-time overview display"""
channel = "overview"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.screen import get_overview
data = get_overview(db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/matches")
async def websocket_matches(websocket: WebSocket):
"""WebSocket for real-time match lobby updates"""
channel = "matches"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.matches import get_lobby
from app.api.matches import get_active_matches
data = {
"lobby": get_lobby(db),
"active": get_active_matches(db),
}
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(2)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.get("/")
def root():
return {"message": "ServeSync API v1.0", "status": "running"}
@app.get("/health")
def health():
return {"status": "healthy"}

View File

@@ -0,0 +1,5 @@
from app.models.player import Player
from app.models.court import Court
from app.models.booking import Booking
from app.models.match import Match, MatchPlayer
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class BookingStatus(str, enum.Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=False)
duration_hours = Column(Float, default=1.0)
total_cost = Column(Float, default=0.0)
status = Column(Enum(BookingStatus), default=BookingStatus.CONFIRMED)
is_match_booking = Column(Boolean, default=False)
notes = Column(String(500))
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
player = relationship("Player", back_populates="bookings")
court = relationship("Court", back_populates="bookings")

View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class Court(Base):
__tablename__ = "courts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
court_number = Column(Integer, unique=True, nullable=False)
hourly_rate = Column(Float, default=200.0)
is_active = Column(Boolean, default=True)
surface_type = Column(String(50), default="Sport Court")
features = Column(String(500), default="LED Lighting, Spectator Seating")
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
bookings = relationship("Booking", back_populates="court")
matches = relationship("Match", back_populates="court")

View File

@@ -0,0 +1,73 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MatchStage(str, enum.Enum):
OPEN = "open"
SKILL_BASED = "skill_based"
TOURNAMENT = "tournament"
class MatchStatus(str, enum.Enum):
LOBBY = "lobby"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class MatchType(str, enum.Enum):
SINGLES = "singles"
DOUBLES = "doubles"
class Match(Base):
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
stage = Column(Enum(MatchStage), default=MatchStage.OPEN)
match_type = Column(Enum(MatchType), default=MatchType.DOUBLES)
status = Column(Enum(MatchStatus), default=MatchStatus.LOBBY)
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
team1_games = Column(Integer, default=0)
team2_games = Column(Integer, default=0)
# Game details
max_players = Column(Integer, default=4)
min_elo = Column(Float, nullable=True)
max_elo = Column(Float, nullable=True)
# Match metadata
title = Column(String(200))
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
court = relationship("Court", back_populates="matches")
match_players = relationship("MatchPlayer", back_populates="match", cascade="all, delete-orphan")
class MatchPlayer(Base):
__tablename__ = "match_players"
id = Column(Integer, primary_key=True, index=True)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
team = Column(Integer, nullable=False) # 1 or 2
elo_before = Column(Float, nullable=True)
elo_after = Column(Float, nullable=True)
elo_change = Column(Float, default=0.0)
is_winner = Column(Boolean, nullable=True)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
match = relationship("Match", back_populates="match_players")
player = relationship("Player", back_populates="match_players")

View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MembershipTier(str, enum.Enum):
BRONZE = "bronze"
SILVER = "silver"
GOLD = "gold"
PLATINUM = "platinum"
ELITE = "elite"
class Player(Base):
__tablename__ = "players"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False)
phone = Column(String(20))
elo_rating = Column(Float, default=1000.0)
membership_tier = Column(Enum(MembershipTier), default=MembershipTier.BRONZE)
wins = Column(Integer, default=0)
losses = Column(Integer, default=0)
total_matches = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
avatar_color = Column(String(7), default="#3B82F6")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
bookings = relationship("Booking", back_populates="player")
match_players = relationship("MatchPlayer", back_populates="player")
tournament_entries = relationship("TournamentEntry", back_populates="player")
@property
def win_rate(self):
if self.total_matches == 0:
return 0
return round(self.wins / self.total_matches * 100, 1)
@property
def tier_label(self):
return self.membership_tier.value.capitalize()

View File

@@ -0,0 +1,93 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class TournamentStatus(str, enum.Enum):
REGISTRATION = "registration"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class BracketType(str, enum.Enum):
WINNERS = "winners"
LOSERS = "losers"
GRAND_FINAL = "grand_final"
class TournamentMatchStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class Tournament(Base):
__tablename__ = "tournaments"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
status = Column(Enum(TournamentStatus), default=TournamentStatus.REGISTRATION)
max_participants = Column(Integer, default=8)
current_round = Column(Integer, default=1)
bracket_data = Column(JSON, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
entries = relationship("TournamentEntry", back_populates="tournament")
tournament_matches = relationship("TournamentMatch", back_populates="tournament")
class TournamentEntry(Base):
__tablename__ = "tournament_entries"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
seed = Column(Integer, nullable=True)
final_rank = Column(Integer, nullable=True)
is_eliminated = Column(Boolean, default=False)
losses = Column(Integer, default=0)
wins = Column(Integer, default=0)
is_in_losers = Column(Boolean, default=False)
registered_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
tournament = relationship("Tournament", back_populates="entries")
player = relationship("Player", back_populates="tournament_entries")
class TournamentMatch(Base):
__tablename__ = "tournament_matches"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=True)
round_number = Column(Integer, nullable=False)
match_number = Column(Integer, nullable=False)
bracket_type = Column(Enum(BracketType), default=BracketType.WINNERS)
status = Column(Enum(TournamentMatchStatus), default=TournamentMatchStatus.PENDING)
# Players
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player3_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p1
player4_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p2
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
winner_team = Column(Integer, nullable=True)
# Next match routing
winner_next_match = Column(Integer, nullable=True)
loser_next_match = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
tournament = relationship("Tournament", back_populates="tournament_matches")

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

View File