strat-gameplay-webapp/backend/app/api/routes/games.py
Cal Corum fbbb1cc5da CLAUDE: Add SBA schedule integration with weekly matchup display
Implements schedule viewing from SBA production API with week navigation
and game creation from scheduled matchups. Groups games by team matchup
horizontally with games stacked vertically for space efficiency.

Backend:
- Add schedule routes (/api/schedule/current, /api/schedule/games)
- Add SBA API client methods for schedule data
- Fix multi-worker state isolation (single worker for in-memory state)
- Add Redis migration TODO for future scalability
- Support custom team IDs in quick-create endpoint

Frontend:
- Add Schedule tab as default on home page
- Week navigation with prev/next and "Current Week" jump
- Horizontal group layout (2-6 columns responsive)
- Completed games show score + "Final" badge (no Play button)
- Incomplete games show "Play" button to create webapp game

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:39:31 -06:00

704 lines
25 KiB
Python

import logging
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.api.dependencies import get_current_user_optional
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 QuickCreateRequest(BaseModel):
"""Optional request model for quick-create with custom teams"""
home_team_id: int | None = Field(None, description="Home team ID (uses default if not provided)")
away_team_id: int | None = Field(None, description="Away team ID (uses default if not provided)")
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(
request: QuickCreateRequest | None = None,
user: dict | None = Depends(get_current_user_optional),
):
"""
Quick-create endpoint for testing - creates a game with auto-generated lineups.
If home_team_id and away_team_id are provided, fetches rosters from SBA API
and auto-generates lineups. Otherwise uses default teams (35 vs 38) with
pre-configured lineups.
The authenticated user's discord_id is stored as creator_discord_id, allowing
them to control teams regardless of actual team ownership.
Args:
request: Optional request body with custom team IDs
Returns:
CreateGameResponse with game_id
"""
try:
# Generate game ID
game_id = uuid4()
league_id = "sba"
# Determine team IDs
use_custom_teams = (
request is not None
and request.home_team_id is not None
and request.away_team_id is not None
)
if use_custom_teams:
home_team_id = request.home_team_id
away_team_id = request.away_team_id
else:
# Default demo teams
home_team_id = 35
away_team_id = 38
# Get creator's discord_id from authenticated user
creator_discord_id = user.get("discord_id") if user else None
logger.info(
f"Quick-creating game {game_id}: {home_team_id} vs {away_team_id} "
f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})"
)
# 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,
creator_discord_id=creator_discord_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",
)
if use_custom_teams:
# Fetch rosters from SBA API and build lineups dynamically
await _build_lineup_from_roster(game_id, home_team_id, season=13)
await _build_lineup_from_roster(game_id, away_team_id, season=13)
else:
# Use pre-configured demo lineups
# 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)}")
async def _build_lineup_from_roster(game_id: UUID, team_id: int, season: int = 13) -> None:
"""
Build a lineup from SBA API roster data.
Fetches the team roster and assigns players to positions based on their
listed positions. Uses a simple algorithm to fill all positions:
- First player at each position gets assigned
- DH filled with extra player if available
- Batting order assigned by position priority
Args:
game_id: Game UUID
team_id: Team ID to fetch roster for
season: Season number (default 13)
"""
# Fetch roster from SBA API
roster = await sba_api_client.get_roster(team_id=team_id, season=season)
if not roster:
raise HTTPException(
status_code=400,
detail=f"No roster found for team {team_id} in season {season}",
)
# Position priority for lineup building
# Standard DH lineup: 9 fielders + DH, pitcher doesn't bat
positions_needed = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "P"]
lineup_positions: dict[str, dict] = {}
used_player_ids: set[int] = set()
# First pass: assign players to their primary positions
for player in roster:
player_id = player.get("id")
player_positions = player.get("position", "").split("/")
if not player_id or player_id in used_player_ids:
continue
# Try to assign to first matching needed position
for pos in player_positions:
pos = pos.strip().upper()
if pos in positions_needed and pos not in lineup_positions:
lineup_positions[pos] = {
"player_id": player_id,
"position": pos,
}
used_player_ids.add(player_id)
break
# Check we have all required positions
missing = [p for p in positions_needed if p not in lineup_positions]
if missing:
# Second pass: try to fill missing positions with any available player
for pos in missing:
for player in roster:
player_id = player.get("id")
if player_id and player_id not in used_player_ids:
lineup_positions[pos] = {
"player_id": player_id,
"position": pos,
}
used_player_ids.add(player_id)
break
# Still missing? Raise error
still_missing = [p for p in positions_needed if p not in lineup_positions]
if still_missing:
raise HTTPException(
status_code=400,
detail=f"Could not fill positions {still_missing} for team {team_id}",
)
# Find DH - use next available player not in lineup
dh_player = None
for player in roster:
player_id = player.get("id")
if player_id and player_id not in used_player_ids:
dh_player = {"player_id": player_id, "position": "DH"}
used_player_ids.add(player_id)
break
if not dh_player:
raise HTTPException(
status_code=400,
detail=f"Not enough players for DH on team {team_id}",
)
# Build final lineup with batting order
# Batting order: C, 1B, 2B, 3B, SS, LF, CF, RF, DH
batting_order_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
for order, pos in enumerate(batting_order_positions, start=1):
if pos == "DH":
player_data = dh_player
else:
player_data = lineup_positions[pos]
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=team_id,
player_id=player_data["player_id"],
position=player_data["position"],
batting_order=order,
is_starter=True,
)
# Add pitcher (no batting order in DH lineup)
pitcher_data = lineup_positions["P"]
await lineup_service.add_sba_player_to_lineup(
game_id=game_id,
team_id=team_id,
player_id=pitcher_data["player_id"],
position="P",
batting_order=None,
is_starter=True,
)
logger.info(f"Built lineup for team {team_id} with {len(batting_order_positions) + 1} players")
@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)}"
)