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>
553 lines
20 KiB
Python
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)}"
|
|
)
|