🏓 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

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)