strat-gameplay-webapp/backend/app/core/state_manager.py
Cal Corum aabb90feb5 CLAUDE: Implement player models and optimize database queries
This commit includes Week 6 player models implementation and critical
performance optimizations discovered during testing.

## Player Models (Week 6 - 50% Complete)

**New Files:**
- app/models/player_models.py (516 lines)
  - BasePlayer abstract class with polymorphic interface
  - SbaPlayer with API parsing factory method
  - PdPlayer with batting/pitching scouting data support
  - Supporting models: PdCardset, PdRarity, PdBattingCard, PdPitchingCard
- tests/unit/models/test_player_models.py (692 lines)
  - 32 comprehensive unit tests, all passing
  - Tests for BasePlayer, SbaPlayer, PdPlayer, polymorphism

**Architecture:**
- Simplified single-layer approach vs planned two-layer
- Factory methods handle API → Game transformation directly
- SbaPlayer.from_api_response(data) - parses SBA API inline
- PdPlayer.from_api_response(player_data, batting_data, pitching_data)
- Full Pydantic validation, type safety, and polymorphism

## Performance Optimizations

**Database Query Reduction (60% fewer queries per play):**
- Before: 5 queries per play (INSERT play, SELECT play with JOINs,
  SELECT games, 2x SELECT lineups)
- After: 2 queries per play (INSERT play, UPDATE games conditionally)

Changes:
1. Lineup caching (game_engine.py:384-425)
   - Check state_manager.get_lineup() cache before DB fetch
   - Eliminates 2 SELECT queries per play
2. Remove unnecessary refresh (operations.py:281-302)
   - Removed session.refresh(play) after INSERT
   - Eliminates 1 SELECT with 3 expensive LEFT JOINs
3. Direct UPDATE statement (operations.py:109-165)
   - Changed update_game_state() to use direct UPDATE
   - No longer does SELECT + modify + commit
4. Conditional game state updates (game_engine.py:200-217)
   - Only UPDATE games table when score/inning/status changes
   - Captures state before/after and compares
   - ~40-60% fewer updates (many plays don't score)

## Bug Fixes

1. Fixed outs_before tracking (game_engine.py:551)
   - Was incorrectly calculating: state.outs - result.outs_recorded
   - Now correctly captures: state.outs (before applying result)
   - All play records now have accurate out counts

2. Fixed game recovery (state_manager.py:312-314)
   - AttributeError when recovering: 'GameState' has no attribute 'runners'
   - Changed to use state.get_all_runners() method
   - Games can now be properly recovered from database

## Enhanced Terminal Client

**Status Display Improvements (terminal_client/display.py:75-97):**
- Added "⚠️ WAITING FOR ACTION" section when play is pending
- Shows specific guidance:
  - "The defense needs to submit their decision" → Run defensive [OPTIONS]
  - "The offense needs to submit their decision" → Run offensive [OPTIONS]
  - "Ready to resolve play" → Run resolve
- Color-coded command hints for better UX

## Documentation Updates

**backend/CLAUDE.md:**
- Added comprehensive Player Models section (204 lines)
- Updated Current Phase status to Week 6 (~50% complete)
- Documented all optimizations and bug fixes
- Added integration examples and usage patterns

**New Files:**
- .claude/implementation/week6-status-assessment.md
  - Comprehensive Week 6 progress review
  - Architecture decision rationale (single-layer vs two-layer)
  - Completion status and next priorities
  - Updated roadmap for remaining Week 6 work

## Test Results

- Player models: 32/32 tests passing
- All existing tests continue to pass
- Performance improvements verified with terminal client

## Next Steps (Week 6 Remaining)

1. Configuration system (BaseConfig, SbaConfig, PdConfig)
2. Result charts & PD play resolution with ratings
3. API client for live roster data (deferred)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:08:56 -05:00

398 lines
13 KiB
Python

"""
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 using the last completed play.
This method recovers the complete game state without replaying all plays.
It uses the final positions from the last play to reconstruct runners and
batter indices.
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', []))
)
# Get last completed play to recover runner state and batter indices
plays = game_data.get('plays', [])
if plays:
# Sort by play_number desc and get last completed play
completed_plays = [p for p in plays if p.get('complete', False)]
if completed_plays:
last_play = max(completed_plays, key=lambda p: p['play_number'])
# Recover runner state from final positions
from app.models.game_models import RunnerState
runners = []
# Check each base for a runner (using *_final fields)
for base_num, final_field in [(1, 'on_first_final'), (2, 'on_second_final'), (3, 'on_third_final')]:
final_base = last_play.get(final_field)
if final_base == base_num: # Runner ended on this base
# Get lineup_id from corresponding on_X_id field
lineup_id = last_play.get(f'on_{["", "first", "second", "third"][base_num]}_id')
if lineup_id:
runners.append(RunnerState(
lineup_id=lineup_id,
card_id=0, # Will be populated when needed
on_base=base_num
))
# Check if batter reached base
batter_final = last_play.get('batter_final')
if batter_final and 1 <= batter_final <= 3:
batter_id = last_play.get('batter_id')
if batter_id:
runners.append(RunnerState(
lineup_id=batter_id,
card_id=0,
on_base=batter_final
))
state.runners = runners
# Recover batter indices from lineups
# We need to find where each team is in their batting order
home_lineup = [l for l in game_data.get('lineups', []) if l['team_id'] == state.home_team_id]
away_lineup = [l for l in game_data.get('lineups', []) if l['team_id'] == state.away_team_id]
# For now, we'll need to be called with _prepare_next_play() after recovery
# to set the proper batter indices and snapshot
# Initialize to 0 - will be corrected by _prepare_next_play()
state.away_team_batter_idx = 0
state.home_team_batter_idx = 0
logger.debug(
f"Recovered state from play {last_play['play_number']}: "
f"{len(runners)} runners on base"
)
else:
logger.debug("No completed plays found - initializing fresh state")
else:
logger.debug("No plays found - initializing fresh state")
# Count runners on base
runners_on_base = len(state.get_all_runners())
logger.info(f"Rebuilt state for game {state.game_id}: {state.play_count} plays, {runners_on_base} runners")
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()