Phase 1 - Critical Fixes: - Add game_lock (asyncio.Lock) to Room class for serializing mutations - Wrap all game action handlers in lock to prevent race conditions - Split Card.to_dict into to_dict (full data) and to_client_dict (hidden) - Fix CardState.from_dict to handle missing rank/suit gracefully - Fix GameOptions reconstruction in recovery_service (dict -> object) - Extend state cache TTL from 4h to 24h, add touch_game method Phase 2 - Security: - Add optional WebSocket authentication via token query param - Use authenticated user ID/name when available - Add auth support to spectator WebSocket endpoint Phase 3 - Performance: - Make stats processing async (fire-and-forget) to avoid blocking game completion notifications Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
502 lines
16 KiB
Python
502 lines
16 KiB
Python
"""
|
|
Replay API router for Golf game.
|
|
|
|
Provides endpoints for:
|
|
- Viewing game replays
|
|
- Creating and managing share links
|
|
- Exporting/importing games
|
|
- Spectating live games
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Depends, Header, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/replay", tags=["replay"])
|
|
|
|
# Service instances (set during app startup)
|
|
_replay_service = None
|
|
_auth_service = None
|
|
_spectator_manager = None
|
|
_room_manager = None
|
|
|
|
|
|
def set_replay_service(service) -> None:
|
|
"""Set the replay service instance."""
|
|
global _replay_service
|
|
_replay_service = service
|
|
|
|
|
|
def set_auth_service(service) -> None:
|
|
"""Set the auth service instance."""
|
|
global _auth_service
|
|
_auth_service = service
|
|
|
|
|
|
def set_spectator_manager(manager) -> None:
|
|
"""Set the spectator manager instance."""
|
|
global _spectator_manager
|
|
_spectator_manager = manager
|
|
|
|
|
|
def set_room_manager(manager) -> None:
|
|
"""Set the room manager instance."""
|
|
global _room_manager
|
|
_room_manager = manager
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Auth Dependencies
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def get_current_user(authorization: Optional[str] = Header(None)):
|
|
"""Get current user from Authorization header."""
|
|
if not authorization or not _auth_service:
|
|
return None
|
|
|
|
parts = authorization.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
return None
|
|
|
|
token = parts[1]
|
|
return await _auth_service.get_user_from_token(token)
|
|
|
|
|
|
async def require_auth(user=Depends(get_current_user)):
|
|
"""Require authenticated user."""
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
return user
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Request/Response Models
|
|
# -------------------------------------------------------------------------
|
|
|
|
class ShareLinkRequest(BaseModel):
|
|
"""Request to create a share link."""
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
expires_days: Optional[int] = None
|
|
|
|
|
|
class ImportGameRequest(BaseModel):
|
|
"""Request to import a game."""
|
|
export_data: dict
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Replay Endpoints
|
|
# -------------------------------------------------------------------------
|
|
|
|
@router.get("/game/{game_id}")
|
|
async def get_replay(game_id: str, user=Depends(get_current_user)):
|
|
"""
|
|
Get full replay for a game.
|
|
|
|
Returns all frames with game state at each step.
|
|
Requires authentication and permission to view the game.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
# Check permission
|
|
user_id = user.id if user else None
|
|
if not await _replay_service.can_view_game(user_id, game_id):
|
|
raise HTTPException(status_code=403, detail="Cannot view this game")
|
|
|
|
try:
|
|
replay = await _replay_service.build_replay(game_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
return {
|
|
"game_id": replay.game_id,
|
|
"room_code": replay.room_code,
|
|
"frames": [
|
|
{
|
|
"index": f.event_index,
|
|
"event_type": f.event_type,
|
|
"event_data": f.event_data,
|
|
"timestamp": f.timestamp,
|
|
"state": f.game_state,
|
|
"player_id": f.player_id,
|
|
}
|
|
for f in replay.frames
|
|
],
|
|
"metadata": {
|
|
"players": replay.player_names,
|
|
"winner": replay.winner,
|
|
"final_scores": replay.final_scores,
|
|
"duration": replay.total_duration_seconds,
|
|
"total_rounds": replay.total_rounds,
|
|
"options": replay.options,
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/game/{game_id}/frame/{frame_index}")
|
|
async def get_replay_frame(game_id: str, frame_index: int, user=Depends(get_current_user)):
|
|
"""
|
|
Get a specific frame from a replay.
|
|
|
|
Useful for seeking without loading the entire replay.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
user_id = user.id if user else None
|
|
if not await _replay_service.can_view_game(user_id, game_id):
|
|
raise HTTPException(status_code=403, detail="Cannot view this game")
|
|
|
|
frame = await _replay_service.get_replay_frame(game_id, frame_index)
|
|
if not frame:
|
|
raise HTTPException(status_code=404, detail="Frame not found")
|
|
|
|
return {
|
|
"index": frame.event_index,
|
|
"event_type": frame.event_type,
|
|
"event_data": frame.event_data,
|
|
"timestamp": frame.timestamp,
|
|
"state": frame.game_state,
|
|
"player_id": frame.player_id,
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Share Link Endpoints
|
|
# -------------------------------------------------------------------------
|
|
|
|
@router.post("/game/{game_id}/share")
|
|
async def create_share_link(
|
|
game_id: str,
|
|
request: ShareLinkRequest,
|
|
user=Depends(require_auth),
|
|
):
|
|
"""
|
|
Create shareable link for a game.
|
|
|
|
Only users who played in the game can create share links.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
# Validate expires_days
|
|
if request.expires_days is not None and (request.expires_days < 1 or request.expires_days > 365):
|
|
raise HTTPException(status_code=400, detail="expires_days must be between 1 and 365")
|
|
|
|
# Check if user played in the game
|
|
if not await _replay_service.can_view_game(user.id, game_id):
|
|
raise HTTPException(status_code=403, detail="Can only share games you played in")
|
|
|
|
try:
|
|
share_code = await _replay_service.create_share_link(
|
|
game_id=game_id,
|
|
user_id=user.id,
|
|
title=request.title,
|
|
description=request.description,
|
|
expires_days=request.expires_days,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to create share link: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to create share link")
|
|
|
|
return {
|
|
"share_code": share_code,
|
|
"share_url": f"/replay/{share_code}",
|
|
"expires_days": request.expires_days,
|
|
}
|
|
|
|
|
|
@router.get("/shared/{share_code}")
|
|
async def get_shared_replay(share_code: str):
|
|
"""
|
|
Get replay via share code (public endpoint).
|
|
|
|
No authentication required for public share links.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
shared = await _replay_service.get_shared_game(share_code)
|
|
if not shared:
|
|
raise HTTPException(status_code=404, detail="Shared game not found or expired")
|
|
|
|
try:
|
|
replay = await _replay_service.build_replay(str(shared["game_id"]))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
return {
|
|
"title": shared.get("title"),
|
|
"description": shared.get("description"),
|
|
"view_count": shared["view_count"],
|
|
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
|
|
"game_id": str(shared["game_id"]),
|
|
"room_code": replay.room_code,
|
|
"frames": [
|
|
{
|
|
"index": f.event_index,
|
|
"event_type": f.event_type,
|
|
"event_data": f.event_data,
|
|
"timestamp": f.timestamp,
|
|
"state": f.game_state,
|
|
"player_id": f.player_id,
|
|
}
|
|
for f in replay.frames
|
|
],
|
|
"metadata": {
|
|
"players": replay.player_names,
|
|
"winner": replay.winner,
|
|
"final_scores": replay.final_scores,
|
|
"duration": replay.total_duration_seconds,
|
|
"total_rounds": replay.total_rounds,
|
|
"options": replay.options,
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/shared/{share_code}/info")
|
|
async def get_shared_info(share_code: str):
|
|
"""
|
|
Get info about a shared game without full replay data.
|
|
|
|
Useful for preview/metadata display.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
shared = await _replay_service.get_shared_game(share_code)
|
|
if not shared:
|
|
raise HTTPException(status_code=404, detail="Shared game not found or expired")
|
|
|
|
return {
|
|
"title": shared.get("title"),
|
|
"description": shared.get("description"),
|
|
"view_count": shared["view_count"],
|
|
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
|
|
"room_code": shared.get("room_code"),
|
|
"num_players": shared.get("num_players"),
|
|
"num_rounds": shared.get("num_rounds"),
|
|
}
|
|
|
|
|
|
@router.delete("/shared/{share_code}")
|
|
async def delete_share_link(share_code: str, user=Depends(require_auth)):
|
|
"""Delete a share link (creator only)."""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
deleted = await _replay_service.delete_share_link(share_code, user.id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Share link not found or not authorized")
|
|
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.get("/my-shares")
|
|
async def get_my_shares(user=Depends(require_auth)):
|
|
"""Get all share links created by the current user."""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
shares = await _replay_service.get_user_shared_games(user.id)
|
|
return {
|
|
"shares": [
|
|
{
|
|
"share_code": s["share_code"],
|
|
"game_id": str(s["game_id"]),
|
|
"title": s.get("title"),
|
|
"view_count": s["view_count"],
|
|
"created_at": s["created_at"].isoformat() if s.get("created_at") else None,
|
|
"expires_at": s["expires_at"].isoformat() if s.get("expires_at") else None,
|
|
}
|
|
for s in shares
|
|
],
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Export/Import Endpoints
|
|
# -------------------------------------------------------------------------
|
|
|
|
@router.get("/game/{game_id}/export")
|
|
async def export_game(game_id: str, user=Depends(require_auth)):
|
|
"""
|
|
Export game as downloadable JSON.
|
|
|
|
Returns the complete game data suitable for backup or sharing.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
if not await _replay_service.can_view_game(user.id, game_id):
|
|
raise HTTPException(status_code=403, detail="Cannot export this game")
|
|
|
|
try:
|
|
export_data = await _replay_service.export_game(game_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
# Return as downloadable JSON
|
|
return JSONResponse(
|
|
content=export_data,
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"'
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/import")
|
|
async def import_game(request: ImportGameRequest, user=Depends(require_auth)):
|
|
"""
|
|
Import a game from JSON export.
|
|
|
|
Creates a new game record from the exported data.
|
|
"""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
try:
|
|
new_game_id = await _replay_service.import_game(request.export_data, user.id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Import failed: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to import game")
|
|
|
|
return {
|
|
"game_id": new_game_id,
|
|
"message": "Game imported successfully",
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Game History
|
|
# -------------------------------------------------------------------------
|
|
|
|
@router.get("/history")
|
|
async def get_game_history(
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
user=Depends(require_auth),
|
|
):
|
|
"""Get game history for the current user."""
|
|
if not _replay_service:
|
|
raise HTTPException(status_code=503, detail="Replay service unavailable")
|
|
|
|
games = await _replay_service.get_user_game_history(user.id, limit, offset)
|
|
return {
|
|
"games": [
|
|
{
|
|
"game_id": str(g["id"]),
|
|
"room_code": g["room_code"],
|
|
"status": g["status"],
|
|
"completed_at": g["completed_at"].isoformat() if g.get("completed_at") else None,
|
|
"num_players": g["num_players"],
|
|
"num_rounds": g["num_rounds"],
|
|
"won": g.get("winner_id") == user.id,
|
|
}
|
|
for g in games
|
|
],
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Spectator Endpoints
|
|
# -------------------------------------------------------------------------
|
|
|
|
@router.websocket("/spectate/{room_code}")
|
|
async def spectate_game(websocket: WebSocket, room_code: str):
|
|
"""
|
|
WebSocket endpoint for spectating live games.
|
|
|
|
Spectators receive real-time game state updates but cannot interact.
|
|
Supports optional authentication via token query parameter.
|
|
"""
|
|
await websocket.accept()
|
|
|
|
# Optional authentication for spectators
|
|
token = websocket.query_params.get("token")
|
|
spectator_user = None
|
|
if token and _auth_service:
|
|
try:
|
|
spectator_user = await _auth_service.get_user_from_token(token)
|
|
except Exception:
|
|
pass # Anonymous spectator
|
|
|
|
if not _spectator_manager or not _room_manager:
|
|
await websocket.close(code=4003, reason="Spectator service unavailable")
|
|
return
|
|
|
|
# Find the game by room code
|
|
room = _room_manager.get_room(room_code.upper())
|
|
if not room:
|
|
await websocket.close(code=4004, reason="Game not found")
|
|
return
|
|
|
|
game_id = room_code.upper() # Use room code as identifier for spectators
|
|
|
|
# Add spectator
|
|
added = await _spectator_manager.add_spectator(game_id, websocket)
|
|
if not added:
|
|
await websocket.close(code=4005, reason="Spectator limit reached")
|
|
return
|
|
|
|
try:
|
|
# Send initial game state
|
|
game_state = room.game.get_state(None) # No player perspective
|
|
await websocket.send_json({
|
|
"type": "spectator_joined",
|
|
"game_state": game_state,
|
|
"spectator_count": _spectator_manager.get_spectator_count(game_id),
|
|
"players": room.player_list(),
|
|
"authenticated": spectator_user is not None,
|
|
})
|
|
|
|
# Keep connection alive
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
if data == "ping":
|
|
await websocket.send_text("pong")
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
logger.debug(f"Spectator connection error: {e}")
|
|
finally:
|
|
await _spectator_manager.remove_spectator(game_id, websocket)
|
|
|
|
|
|
@router.get("/spectate/{room_code}/count")
|
|
async def get_spectator_count(room_code: str):
|
|
"""Get the number of spectators for a game."""
|
|
if not _spectator_manager:
|
|
return {"count": 0}
|
|
|
|
count = _spectator_manager.get_spectator_count(room_code.upper())
|
|
return {"count": count}
|
|
|
|
|
|
@router.get("/spectate/active")
|
|
async def get_active_spectated_games():
|
|
"""Get list of games with active spectators."""
|
|
if not _spectator_manager:
|
|
return {"games": []}
|
|
|
|
games = _spectator_manager.get_games_with_spectators()
|
|
return {
|
|
"games": [
|
|
{"room_code": game_id, "spectator_count": count}
|
|
for game_id, count in games.items()
|
|
],
|
|
}
|