strat-gameplay-webapp/backend/app/api/routes/games.py
Cal Corum 38fb76c849 CLAUDE: Fix resolution phase control and add demo mode
Bug fix: During resolution phase (dice rolling), isMyTurn was false
for both players, preventing anyone from seeing the dice roller.
Now the batting team has control during resolution since they read
their card.

Demo mode: myTeamId now returns whichever team needs to act,
allowing single-player testing of both sides.

Changes:
- Add creator_discord_id to GameState (backend + frontend types)
- Add get_current_user_optional dependency for optional auth
- Update quick-create to capture creator's discord_id
- Fix isMyTurn to give batting team control during resolution
- Demo mode: myTeamId returns active team based on phase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:47:21 -06:00

553 lines
20 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 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(user: dict | None = Depends(get_current_user_optional)):
"""
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.
The authenticated user's discord_id is stored as creator_discord_id, allowing
them to control the home team regardless of actual team ownership.
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"
# 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} (creator: {creator_discord_id})"
)
# Create game in state manager
# Creator controls both teams for solo testing
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",
)
# 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)}"
)