""" State Manager - In-memory game state management. Manages active game states in memory for fast gameplay (<500ms response time). Provides CRUD operations, lineup management, and state recovery from database. This is the single source of truth for active game states during gameplay. Author: Claude Date: 2025-10-22 """ import logging from typing import Dict, Optional from uuid import UUID import pendulum from app.models.game_models import GameState, TeamLineupState from app.database.operations import DatabaseOperations logger = logging.getLogger(f'{__name__}.StateManager') class StateManager: """ Manages in-memory game states for active games. Responsibilities: - Store game states in memory for fast access - Manage team lineups per game - Track last access times for eviction - Recover game states from database on demand This class uses dictionaries for O(1) lookups of game state by game_id. """ def __init__(self): """Initialize the state manager with empty storage""" self._states: Dict[UUID, GameState] = {} self._lineups: Dict[UUID, Dict[int, TeamLineupState]] = {} # game_id -> {team_id: lineup} self._last_access: Dict[UUID, pendulum.DateTime] = {} self.db_ops = DatabaseOperations() logger.info("StateManager initialized") async def create_game( self, game_id: UUID, league_id: str, home_team_id: int, away_team_id: int, home_team_is_ai: bool = False, away_team_is_ai: bool = False ) -> GameState: """ Create a new game state in memory. Args: game_id: Unique game identifier league_id: League identifier ('sba' or 'pd') home_team_id: Home team ID away_team_id: Away team ID home_team_is_ai: Whether home team is AI-controlled away_team_is_ai: Whether away team is AI-controlled Returns: Newly created GameState Raises: ValueError: If game_id already exists """ if game_id in self._states: raise ValueError(f"Game {game_id} already exists in state manager") logger.info(f"Creating game state for {game_id} ({league_id} league)") state = GameState( game_id=game_id, league_id=league_id, home_team_id=home_team_id, away_team_id=away_team_id, home_team_is_ai=home_team_is_ai, away_team_is_ai=away_team_is_ai ) self._states[game_id] = state self._lineups[game_id] = {} self._last_access[game_id] = pendulum.now('UTC') logger.debug(f"Game {game_id} created in memory") return state def get_state(self, game_id: UUID) -> Optional[GameState]: """ Get game state by ID. Updates last access time when accessed. Args: game_id: Game identifier Returns: GameState if found, None otherwise """ if game_id in self._states: self._last_access[game_id] = pendulum.now('UTC') return self._states[game_id] return None def update_state(self, game_id: UUID, state: GameState) -> None: """ Update game state. Args: game_id: Game identifier state: Updated GameState Raises: ValueError: If game_id doesn't exist """ if game_id not in self._states: raise ValueError(f"Game {game_id} not found in state manager") self._states[game_id] = state self._last_access[game_id] = pendulum.now('UTC') logger.debug(f"Updated state for game {game_id} (inning {state.inning}, {state.half})") def set_lineup(self, game_id: UUID, team_id: int, lineup: TeamLineupState) -> None: """ Set team lineup for a game. Args: game_id: Game identifier team_id: Team identifier lineup: Team lineup state Raises: ValueError: If game_id doesn't exist """ if game_id not in self._states: raise ValueError(f"Game {game_id} not found in state manager") if game_id not in self._lineups: self._lineups[game_id] = {} self._lineups[game_id][team_id] = lineup logger.info(f"Set lineup for team {team_id} in game {game_id} ({len(lineup.players)} players)") def get_lineup(self, game_id: UUID, team_id: int) -> Optional[TeamLineupState]: """ Get team lineup for a game. Args: game_id: Game identifier team_id: Team identifier Returns: TeamLineupState if found, None otherwise """ return self._lineups.get(game_id, {}).get(team_id) def remove_game(self, game_id: UUID) -> None: """ Remove game from memory. Call this when a game is completed or being archived. Args: game_id: Game identifier """ removed_parts = [] if game_id in self._states: self._states.pop(game_id) removed_parts.append("state") if game_id in self._lineups: self._lineups.pop(game_id) removed_parts.append("lineups") if game_id in self._last_access: self._last_access.pop(game_id) removed_parts.append("access") if removed_parts: logger.info(f"Removed game {game_id} from memory ({', '.join(removed_parts)})") else: logger.warning(f"Attempted to remove game {game_id} but it was not in memory") async def recover_game(self, game_id: UUID) -> Optional[GameState]: """ Recover game state from database. This is called when a game needs to be loaded (e.g., after server restart, or when a game is accessed that's not currently in memory). Loads game data from database and rebuilds the in-memory state. Args: game_id: Game identifier Returns: Recovered GameState if found in database, None otherwise """ logger.info(f"Recovering game {game_id} from database") # Load from database game_data = await self.db_ops.load_game_state(game_id) if not game_data: logger.warning(f"Game {game_id} not found in database") return None # Rebuild state from loaded data state = await self._rebuild_state_from_data(game_data) # Cache in memory self._states[game_id] = state self._last_access[game_id] = pendulum.now('UTC') logger.info(f"Recovered game {game_id} - inning {state.inning}, {state.half}") return state async def _rebuild_state_from_data(self, game_data: dict) -> GameState: """ Rebuild game state from database data. Creates a GameState object from the data loaded from database. In Week 5, this will be enhanced to replay plays for complete state recovery. Args: game_data: Dictionary with 'game', 'lineups', and 'plays' keys Returns: Reconstructed GameState """ game = game_data['game'] state = GameState( game_id=game['id'], league_id=game['league_id'], home_team_id=game['home_team_id'], away_team_id=game['away_team_id'], home_team_is_ai=game.get('home_team_is_ai', False), away_team_is_ai=game.get('away_team_is_ai', False), status=game['status'], inning=game.get('current_inning', 1), half=game.get('current_half', 'top'), home_score=game.get('home_score', 0), away_score=game.get('away_score', 0), play_count=len(game_data.get('plays', [])) ) # TODO Week 5: Replay plays to rebuild runner state, outs, current batter, etc. # For now, we just have the basic game state from the database fields logger.debug(f"Rebuilt state for game {state.game_id}: {len(game_data.get('plays', []))} plays") return state def evict_idle_games(self, idle_minutes: int = 60) -> int: """ Remove games that haven't been accessed recently. This helps manage memory by removing inactive games. Evicted games can be recovered from database if needed later. Args: idle_minutes: Minutes of inactivity before eviction (default 60) Returns: Number of games evicted """ cutoff = pendulum.now('UTC').subtract(minutes=idle_minutes) to_evict = [ game_id for game_id, last_access in self._last_access.items() if last_access < cutoff ] for game_id in to_evict: self.remove_game(game_id) if to_evict: logger.info(f"Evicted {len(to_evict)} idle games (idle > {idle_minutes}m)") return len(to_evict) def get_stats(self) -> dict: """ Get state manager statistics. Returns: Dictionary with current state statistics: - active_games: Number of games in memory - total_lineups: Total lineups across all games - games_by_league: Count of games per league - games_by_status: Count of games by status """ stats = { "active_games": len(self._states), "total_lineups": sum(len(lineups) for lineups in self._lineups.values()), "games_by_league": {}, "games_by_status": {}, } # Count by league for state in self._states.values(): league = state.league_id stats["games_by_league"][league] = stats["games_by_league"].get(league, 0) + 1 # Count by status for state in self._states.values(): status = state.status stats["games_by_status"][status] = stats["games_by_status"].get(status, 0) + 1 return stats def exists(self, game_id: UUID) -> bool: """ Check if game exists in memory. Args: game_id: Game identifier Returns: True if game is in memory, False otherwise """ return game_id in self._states def get_all_game_ids(self) -> list[UUID]: """ Get list of all game IDs currently in memory. Returns: List of game UUIDs """ return list(self._states.keys()) # Singleton instance for global access state_manager = StateManager()