strat-gameplay-webapp/backend/app/api/routes/games.py
Cal Corum e0c12467b0 CLAUDE: Improve UX with single-click OAuth, enhanced games list, and layout fix
Frontend UX improvements:
- Single-click Discord OAuth from home page (no intermediate /auth page)
- Auto-redirect authenticated users from home to /games
- Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout
- Games page now has proper card container with shadow/border styling
- Layout header includes working logout with API cookie clearing

Games list enhancements:
- Display team names (lname) instead of just team IDs
- Show current score for each team
- Show inning indicator (Top/Bot X) for active games
- Responsive header with wrapped buttons on mobile

Backend improvements:
- Added team caching to SbaApiClient (1-hour TTL)
- Enhanced GameListItem with team names, scores, inning data
- Games endpoint now enriches response with SBA API team data

Docker optimizations:
- Optimized Dockerfile using --chown flag on COPY (faster than chown -R)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:14:00 -06:00

544 lines
19 KiB
Python

import logging
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator
from app.core.game_engine import game_engine
from app.core.state_manager import state_manager
from app.database.operations import DatabaseOperations
from app.services.lineup_service import lineup_service
from app.services.sba_api_client import sba_api_client
logger = logging.getLogger(f"{__name__}.games")
router = APIRouter()
class GameListItem(BaseModel):
"""Game list item model with enriched team and game state info"""
game_id: str
league_id: str
status: str
home_team_id: int
away_team_id: int
# Enriched fields
home_team_name: str | None = None
away_team_name: str | None = None
home_team_abbrev: str | None = None
away_team_abbrev: str | None = None
home_score: int = 0
away_score: int = 0
inning: int | None = None
half: str | None = None # 'top' or 'bottom'
class CreateGameRequest(BaseModel):
"""Request model for creating a new game"""
name: str = Field(..., description="Game name")
home_team_id: int = Field(..., description="Home team ID")
away_team_id: int = Field(..., description="Away team ID")
is_ai_opponent: bool = Field(default=False, description="Is AI opponent")
season: int = Field(default=3, description="Season number")
league_id: str = Field(default="sba", description="League ID (sba or pd)")
class CreateGameResponse(BaseModel):
"""Response model for game creation"""
game_id: str
message: str
status: str
class LineupPlayerRequest(BaseModel):
"""Single player in lineup request"""
player_id: int = Field(..., description="SBA player ID")
position: str = Field(..., description="Defensive position (P, C, 1B, etc.)")
batting_order: int | None = Field(
None, ge=1, le=9, description="Batting order (1-9), null for pitcher in DH lineup"
)
@field_validator("position")
@classmethod
def validate_position(cls, v: str) -> str:
"""Ensure position is valid"""
valid = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
if v not in valid:
raise ValueError(f"Position must be one of {valid}")
return v
class SubmitLineupsRequest(BaseModel):
"""
Request model for submitting lineups for both teams.
Supports both 9-player (pitcher bats) and 10-player (DH) configurations:
- 9 players: All have batting orders 1-9
- 10 players: Pitcher has batting_order=null, others have 1-9
"""
home_lineup: list[LineupPlayerRequest] = Field(
..., min_length=9, max_length=10, description="Home team starting lineup (9-10 players)"
)
away_lineup: list[LineupPlayerRequest] = Field(
..., min_length=9, max_length=10, description="Away team starting lineup (9-10 players)"
)
@field_validator("home_lineup", "away_lineup")
@classmethod
def validate_lineup(cls, v: list[LineupPlayerRequest]) -> list[LineupPlayerRequest]:
"""
Validate lineup structure for 9-player or 10-player (DH) configurations.
Rules:
- 9 players: All must have batting orders 1-9
- 10 players: Exactly 9 have batting orders 1-9, pitcher has null
"""
lineup_size = len(v)
# Check batting orders
batters = [p for p in v if p.batting_order is not None]
batting_orders = [p.batting_order for p in batters]
if len(batting_orders) != 9:
raise ValueError(f"Must have exactly 9 batters with batting orders, got {len(batting_orders)}")
if set(batting_orders) != {1, 2, 3, 4, 5, 6, 7, 8, 9}:
raise ValueError("Batting orders must be exactly 1-9 with no duplicates")
# Check positions
positions = [p.position for p in v]
required_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
for req_pos in required_positions:
if req_pos not in positions:
raise ValueError(f"Missing required position: {req_pos}")
# For 10-player lineup, must have DH and pitcher must not bat
if lineup_size == 10:
if "DH" not in positions:
raise ValueError("10-player lineup must include DH position")
pitchers = [p for p in v if p.position == "P"]
if len(pitchers) != 1:
raise ValueError("Must have exactly 1 pitcher")
if pitchers[0].batting_order is not None:
raise ValueError("Pitcher cannot have batting order in DH lineup")
# For 9-player lineup, pitcher must bat (no DH)
elif lineup_size == 9:
if "DH" in positions:
raise ValueError("9-player lineup cannot include DH")
pitchers = [p for p in v if p.position == "P"]
if len(pitchers) != 1:
raise ValueError("Must have exactly 1 pitcher")
if pitchers[0].batting_order is None:
raise ValueError("Pitcher must have batting order when no DH")
# Check player uniqueness
player_ids = [p.player_id for p in v]
if len(set(player_ids)) != len(player_ids):
raise ValueError("Players cannot be duplicated in lineup")
return v
class SubmitLineupsResponse(BaseModel):
"""Response model for lineup submission"""
game_id: str
message: str
home_lineup_count: int
away_lineup_count: int
@router.get("/", response_model=list[GameListItem])
async def list_games():
"""
List all games from the database with enriched team and game state info.
Returns game information including team names, scores, and current inning.
TODO: Add user filtering, pagination, and more sophisticated queries
"""
try:
logger.info("Fetching games list from database")
# Get all games from database (for now - later we can add filters)
from app.database.session import AsyncSessionLocal
from app.models.db_models import Game
async with AsyncSessionLocal() as session:
from sqlalchemy import select
result = await session.execute(
select(Game).order_by(Game.created_at.desc())
)
games = result.scalars().all()
# Collect unique team IDs for batch lookup
team_ids = set()
for game in games:
team_ids.add(game.home_team_id)
team_ids.add(game.away_team_id)
# Fetch team data (uses cache)
teams_data = await sba_api_client.get_teams_by_ids(list(team_ids))
# Convert to response model with enriched data
game_list = []
for game in games:
home_team = teams_data.get(game.home_team_id, {})
away_team = teams_data.get(game.away_team_id, {})
game_list.append(
GameListItem(
game_id=str(game.id),
league_id=game.league_id,
status=game.status,
home_team_id=game.home_team_id,
away_team_id=game.away_team_id,
home_team_name=home_team.get("lname"),
away_team_name=away_team.get("lname"),
home_team_abbrev=home_team.get("abbrev"),
away_team_abbrev=away_team.get("abbrev"),
home_score=game.home_score or 0,
away_score=game.away_score or 0,
inning=game.current_inning,
half=game.current_half,
)
)
logger.info(f"Retrieved {len(game_list)} games from database")
return game_list
except Exception as e:
logger.exception(f"Failed to list games: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list games: {str(e)}")
@router.get("/{game_id}")
async def get_game(game_id: str):
"""
Get game details including team IDs and status.
Args:
game_id: Game identifier
Returns:
Game information including home/away team IDs
Raises:
400: Invalid game_id format
404: Game not found
"""
try:
# Validate game_id format
try:
game_uuid = UUID(game_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid game_id format")
logger.info(f"Fetching game details for {game_id}")
# Get game from state manager
if game_uuid not in state_manager._states:
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
state = state_manager._states[game_uuid]
return {
"game_id": game_id,
"status": state.status,
"home_team_id": state.home_team_id,
"away_team_id": state.away_team_id,
"league_id": state.league_id,
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to fetch game {game_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch game: {str(e)}")
@router.post("/", response_model=CreateGameResponse)
async def create_game(request: CreateGameRequest):
"""
Create new game
Creates a new game in the state manager and database.
Note: Lineups must be added separately before the game can be started.
"""
try:
# Generate game ID
game_id = uuid4()
logger.info(
f"Creating game {game_id}: {request.home_team_id} vs {request.away_team_id}"
)
# Validate teams are different
if request.home_team_id == request.away_team_id:
raise HTTPException(
status_code=400, detail="Home and away teams must be different"
)
# Create game in state manager (in-memory)
state = await state_manager.create_game(
game_id=game_id,
league_id=request.league_id,
home_team_id=request.home_team_id,
away_team_id=request.away_team_id,
)
# Save to database
db_ops = DatabaseOperations()
await db_ops.create_game(
game_id=game_id,
league_id=request.league_id,
home_team_id=request.home_team_id,
away_team_id=request.away_team_id,
game_mode="friendly" if not request.is_ai_opponent else "ai",
visibility="public",
)
logger.info(
f"Game {game_id} created successfully - status: {state.status}"
)
return CreateGameResponse(
game_id=str(game_id),
message=f"Game '{request.name}' created successfully. Add lineups to start the game.",
status=state.status,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to create game: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}")
@router.post("/quick-create", response_model=CreateGameResponse)
async def quick_create_game():
"""
Quick-create endpoint for testing - creates a game with pre-configured lineups.
Uses the lineup configuration from the most recent game (Team 35 vs Team 38).
This eliminates the 2-minute lineup configuration process during testing.
Returns:
CreateGameResponse with game_id
"""
try:
# Generate game ID
game_id = uuid4()
# Use real team data from most recent game
home_team_id = 35
away_team_id = 38
league_id = "sba"
logger.info(
f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id}"
)
# Create game in state manager
state = await state_manager.create_game(
game_id=game_id,
league_id=league_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
# Save to database
db_ops = DatabaseOperations()
await db_ops.create_game(
game_id=game_id,
league_id=league_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
game_mode="friendly",
visibility="public",
)
# Submit home lineup (Team 35)
home_lineup_data = [
{"player_id": 1417, "position": "C", "batting_order": 1},
{"player_id": 1186, "position": "1B", "batting_order": 2},
{"player_id": 1381, "position": "2B", "batting_order": 3},
{"player_id": 1576, "position": "3B", "batting_order": 4},
{"player_id": 1242, "position": "SS", "batting_order": 5},
{"player_id": 1600, "position": "LF", "batting_order": 6},
{"player_id": 1675, "position": "CF", "batting_order": 7},
{"player_id": 1700, "position": "RF", "batting_order": 8},
{"player_id": 1759, "position": "DH", "batting_order": 9},
{"player_id": 1948, "position": "P", "batting_order": None},
]
for player in home_lineup_data:
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=home_team_id,
player_id=player["player_id"],
position=player["position"],
batting_order=player["batting_order"],
is_starter=True,
)
# Submit away lineup (Team 38)
away_lineup_data = [
{"player_id": 1080, "position": "C", "batting_order": 1},
{"player_id": 1148, "position": "1B", "batting_order": 2},
{"player_id": 1166, "position": "2B", "batting_order": 3},
{"player_id": 1513, "position": "3B", "batting_order": 4},
{"player_id": 1209, "position": "SS", "batting_order": 5},
{"player_id": 1735, "position": "LF", "batting_order": 6},
{"player_id": 1665, "position": "CF", "batting_order": 7},
{"player_id": 1961, "position": "RF", "batting_order": 8},
{"player_id": 1980, "position": "DH", "batting_order": 9},
{"player_id": 2005, "position": "P", "batting_order": None},
]
for player in away_lineup_data:
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=away_team_id,
player_id=player["player_id"],
position=player["position"],
batting_order=player["batting_order"],
is_starter=True,
)
# Start the game
await game_engine.start_game(game_id)
logger.info(f"Quick-created game {game_id} and started successfully")
return CreateGameResponse(
game_id=str(game_id),
message=f"Game quick-created with Team {home_team_id} vs Team {away_team_id}. Ready to play!",
status="active",
)
except Exception as e:
logger.exception(f"Failed to quick-create game: {e}")
raise HTTPException(status_code=500, detail=f"Failed to quick-create game: {str(e)}")
@router.post("/{game_id}/lineups", response_model=SubmitLineupsResponse)
async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
"""
Submit lineups for both teams.
Accepts complete lineups for home and away teams. Supports both:
- 9-player lineups (pitcher bats, no DH)
- 10-player lineups (universal DH, pitcher doesn't bat)
Args:
game_id: Game identifier
request: Lineup data for both teams
Returns:
Confirmation with lineup counts
Raises:
400: Invalid lineup structure or validation error
404: Game not found
500: Database or API error
"""
try:
# Validate game_id format
try:
game_uuid = UUID(game_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid game_id format")
logger.info(f"Submitting lineups for game {game_id}")
# Get game state from memory or load basic info from database
state = state_manager.get_state(game_uuid)
if not state:
logger.info(f"Game {game_id} not in memory, loading from database")
# Load basic game info from database
db_ops = DatabaseOperations()
game_data = await db_ops.load_game_state(game_uuid)
if not game_data:
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
game_info = game_data['game']
# Recreate game state in memory (without lineups - we're about to add them)
state = await state_manager.create_game(
game_id=game_uuid,
league_id=game_info['league_id'],
home_team_id=game_info['home_team_id'],
away_team_id=game_info['away_team_id'],
home_team_is_ai=game_info.get('home_team_is_ai', False),
away_team_is_ai=game_info.get('away_team_is_ai', False),
auto_mode=game_info.get('auto_mode', False)
)
logger.info(f"Recreated game {game_id} in memory from database")
# Process home team lineup
home_count = 0
for player in request.home_lineup:
await lineup_service.add_sba_player_to_lineup(
game_id=game_uuid,
team_id=state.home_team_id,
player_id=player.player_id,
position=player.position,
batting_order=player.batting_order,
is_starter=True,
)
home_count += 1
logger.info(f"Added {home_count} players to home team lineup")
# Process away team lineup
away_count = 0
for player in request.away_lineup:
await lineup_service.add_sba_player_to_lineup(
game_id=game_uuid,
team_id=state.away_team_id,
player_id=player.player_id,
position=player.position,
batting_order=player.batting_order,
is_starter=True,
)
away_count += 1
logger.info(f"Added {away_count} players to away team lineup")
# Automatically start the game after lineups are submitted
from app.core.game_engine import game_engine
try:
await game_engine.start_game(game_uuid)
logger.info(f"Game {game_id} started successfully after lineup submission")
except Exception as e:
logger.warning(f"Failed to auto-start game {game_id}: {e}")
# Don't fail the lineup submission if game start fails
# User can manually start if needed
return SubmitLineupsResponse(
game_id=game_id,
message=f"Lineups submitted successfully. Home: {home_count} players, Away: {away_count} players.",
home_lineup_count=home_count,
away_lineup_count=away_count,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to submit lineups for game {game_id}: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to submit lineups: {str(e)}"
)