Game Engine Improvements: - State manager: Add game eviction with configurable max games, idle timeout, and memory limits - Game engine: Add resource cleanup on game completion - Play resolver: Enhanced RunnerAdvancementData with lineup_id for player name resolution in play-by-play - Substitution manager: Minor improvements Test Coverage: - New test_game_eviction.py with 13 tests for eviction scenarios - Updated state_manager tests for new functionality - Updated play_resolver tests for lineup_id handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
627 lines
22 KiB
Python
627 lines
22 KiB
Python
"""
|
|
Substitution Manager - Orchestrates player substitutions with DB-first pattern.
|
|
|
|
Follows the established pattern:
|
|
1. Validate (in-memory)
|
|
2. Update DATABASE FIRST
|
|
3. Update in-memory state SECOND
|
|
4. WebSocket broadcasts happen in handler
|
|
|
|
Author: Claude
|
|
Date: 2025-11-03
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
|
|
|
from app.core.state_manager import state_manager
|
|
from app.core.substitution_rules import SubstitutionRules
|
|
from app.models.game_models import LineupPlayerState, TeamLineupState
|
|
from app.services.lineup_service import lineup_service
|
|
|
|
if TYPE_CHECKING:
|
|
from app.database.operations import DatabaseOperations
|
|
|
|
logger = logging.getLogger(f"{__name__}.SubstitutionManager")
|
|
|
|
|
|
@dataclass
|
|
class SubstitutionResult:
|
|
"""Result of a substitution operation"""
|
|
|
|
success: bool
|
|
new_lineup_id: int | None = None
|
|
player_out_lineup_id: int | None = None
|
|
player_in_card_id: int | None = None
|
|
new_position: str | None = None
|
|
new_batting_order: int | None = None
|
|
updated_lineup: TeamLineupState | None = None
|
|
error_message: str | None = None
|
|
error_code: str | None = None
|
|
|
|
|
|
class SubstitutionManager:
|
|
"""
|
|
Manages player substitutions with state + database sync.
|
|
|
|
Pattern: DB-first → State-second
|
|
1. Validate using in-memory state
|
|
2. Update database FIRST
|
|
3. Update in-memory state SECOND
|
|
4. Return result (WebSocket broadcast happens in handler)
|
|
"""
|
|
|
|
def __init__(self, db_ops: "DatabaseOperations"):
|
|
"""
|
|
Initialize substitution manager.
|
|
|
|
Args:
|
|
db_ops: Database operations instance
|
|
"""
|
|
self.db_ops = db_ops
|
|
self.rules = SubstitutionRules()
|
|
|
|
async def pinch_hit(
|
|
self,
|
|
game_id: UUID,
|
|
player_out_lineup_id: int,
|
|
player_in_card_id: int,
|
|
team_id: int,
|
|
) -> SubstitutionResult:
|
|
"""
|
|
Execute pinch hitter substitution.
|
|
|
|
Flow:
|
|
1. Load game state + roster (in-memory)
|
|
2. Validate substitution (rules check)
|
|
3. Update database FIRST (create new lineup entry, mark old inactive)
|
|
4. Update in-memory state SECOND (update lineup cache)
|
|
5. Return result
|
|
|
|
Args:
|
|
game_id: Game identifier
|
|
player_out_lineup_id: Lineup ID of player being replaced (must be current batter)
|
|
player_in_card_id: Card ID of incoming player
|
|
team_id: Team identifier
|
|
|
|
Returns:
|
|
SubstitutionResult with success status and details
|
|
"""
|
|
logger.info(
|
|
f"Pinch hit request: game={game_id}, "
|
|
f"out={player_out_lineup_id}, in={player_in_card_id}, team={team_id}"
|
|
)
|
|
|
|
# STEP 1: Load game state and roster
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Game {game_id} not found",
|
|
error_code="GAME_NOT_FOUND",
|
|
)
|
|
|
|
roster = state_manager.get_lineup(game_id, team_id)
|
|
if not roster:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Roster not found for team {team_id}",
|
|
error_code="ROSTER_NOT_FOUND",
|
|
)
|
|
|
|
player_out = roster.get_player_by_lineup_id(player_out_lineup_id)
|
|
if not player_out:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Player with lineup_id {player_out_lineup_id} not found",
|
|
error_code="PLAYER_NOT_FOUND",
|
|
)
|
|
|
|
# STEP 2: Validate substitution
|
|
validation = self.rules.validate_pinch_hitter(
|
|
state=state,
|
|
player_out=player_out,
|
|
player_in_card_id=player_in_card_id,
|
|
roster=roster,
|
|
)
|
|
|
|
if not validation.is_valid:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=validation.error_message,
|
|
error_code=validation.error_code,
|
|
)
|
|
|
|
# STEP 3: Update DATABASE FIRST
|
|
try:
|
|
new_lineup_id = await self.db_ops.create_substitution(
|
|
game_id=game_id,
|
|
team_id=team_id,
|
|
player_out_lineup_id=player_out_lineup_id,
|
|
player_in_card_id=player_in_card_id,
|
|
position=player_out.position, # Pinch hitter takes same defensive position
|
|
batting_order=player_out.batting_order, # Takes same spot in order
|
|
inning=state.inning,
|
|
play_number=state.play_count,
|
|
)
|
|
except IntegrityError as e:
|
|
logger.error(f"Integrity error during pinch hit substitution: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Player substitution conflict - player may already be active",
|
|
error_code="DB_INTEGRITY_ERROR",
|
|
)
|
|
except OperationalError as e:
|
|
logger.error(f"Database connection error during pinch hit: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database connection error - please retry",
|
|
error_code="DB_CONNECTION_ERROR",
|
|
)
|
|
except SQLAlchemyError as e:
|
|
logger.error(
|
|
f"Database error during pinch hit substitution: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database error occurred",
|
|
error_code="DB_ERROR",
|
|
)
|
|
|
|
# STEP 4: Update IN-MEMORY STATE SECOND
|
|
try:
|
|
# Mark old player inactive
|
|
player_out.is_active = False
|
|
|
|
# Fetch player data for SBA league
|
|
player_name = f"Player #{player_in_card_id}"
|
|
player_image = ""
|
|
if state.league_id == "sba":
|
|
player_name, player_image = await lineup_service.get_sba_player_data(
|
|
player_in_card_id
|
|
)
|
|
|
|
# Create new player entry
|
|
new_player = LineupPlayerState(
|
|
lineup_id=new_lineup_id,
|
|
card_id=player_in_card_id,
|
|
position=player_out.position,
|
|
batting_order=player_out.batting_order,
|
|
is_active=True,
|
|
is_starter=False,
|
|
player_name=player_name,
|
|
player_image=player_image,
|
|
)
|
|
|
|
# Add to roster
|
|
roster.players.append(new_player)
|
|
|
|
# Update cache
|
|
state_manager.set_lineup(game_id, team_id, roster)
|
|
|
|
# Update current_batter if this is the current batter
|
|
if (
|
|
state.current_batter
|
|
and state.current_batter.lineup_id == player_out_lineup_id
|
|
):
|
|
state.current_batter = new_player # Update object reference
|
|
state_manager.update_state(game_id, state)
|
|
|
|
logger.info(
|
|
f"Pinch hit successful: {player_out.card_id} (lineup {player_out_lineup_id}) → "
|
|
f"{player_in_card_id} (new lineup {new_lineup_id})"
|
|
)
|
|
|
|
return SubstitutionResult(
|
|
success=True,
|
|
new_lineup_id=new_lineup_id,
|
|
player_out_lineup_id=player_out_lineup_id,
|
|
player_in_card_id=player_in_card_id,
|
|
new_position=player_out.position,
|
|
new_batting_order=player_out.batting_order,
|
|
updated_lineup=roster,
|
|
)
|
|
|
|
except (KeyError, AttributeError) as e:
|
|
# Missing expected data in state - indicates corrupted state
|
|
logger.error(
|
|
f"State corruption during pinch hit substitution: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message="State corruption detected - game state may need recovery",
|
|
error_code="STATE_CORRUPTION_ERROR",
|
|
)
|
|
except ValueError as e:
|
|
# Invalid value during state update
|
|
logger.error(f"Invalid value during pinch hit state update: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"Invalid state update: {str(e)}",
|
|
error_code="STATE_VALIDATION_ERROR",
|
|
)
|
|
|
|
async def defensive_replace(
|
|
self,
|
|
game_id: UUID,
|
|
player_out_lineup_id: int,
|
|
player_in_card_id: int,
|
|
new_position: str,
|
|
team_id: int,
|
|
new_batting_order: int | None = None,
|
|
allow_mid_inning: bool = False,
|
|
) -> SubstitutionResult:
|
|
"""
|
|
Execute defensive replacement.
|
|
|
|
Flow: Same as pinch_hit (validate → DB → state)
|
|
|
|
Args:
|
|
game_id: Game identifier
|
|
player_out_lineup_id: Lineup ID of player being replaced
|
|
player_in_card_id: Card ID of incoming player
|
|
new_position: Position for incoming player (can differ from player_out)
|
|
team_id: Team identifier
|
|
new_batting_order: Optional - if provided, changes batting order spot
|
|
allow_mid_inning: If True, allows mid-inning substitution (for injuries)
|
|
|
|
Returns:
|
|
SubstitutionResult with success status and details
|
|
"""
|
|
logger.info(
|
|
f"Defensive replacement request: game={game_id}, "
|
|
f"out={player_out_lineup_id}, in={player_in_card_id}, "
|
|
f"position={new_position}, team={team_id}"
|
|
)
|
|
|
|
# STEP 1: Load game state and roster
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Game {game_id} not found",
|
|
error_code="GAME_NOT_FOUND",
|
|
)
|
|
|
|
roster = state_manager.get_lineup(game_id, team_id)
|
|
if not roster:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Roster not found for team {team_id}",
|
|
error_code="ROSTER_NOT_FOUND",
|
|
)
|
|
|
|
player_out = roster.get_player_by_lineup_id(player_out_lineup_id)
|
|
if not player_out:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Player with lineup_id {player_out_lineup_id} not found",
|
|
error_code="PLAYER_NOT_FOUND",
|
|
)
|
|
|
|
# STEP 2: Validate substitution
|
|
validation = self.rules.validate_defensive_replacement(
|
|
state=state,
|
|
player_out=player_out,
|
|
player_in_card_id=player_in_card_id,
|
|
new_position=new_position,
|
|
roster=roster,
|
|
allow_mid_inning=allow_mid_inning,
|
|
)
|
|
|
|
if not validation.is_valid:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=validation.error_message,
|
|
error_code=validation.error_code,
|
|
)
|
|
|
|
# Determine batting order (keep old if not specified)
|
|
batting_order = (
|
|
new_batting_order
|
|
if new_batting_order is not None
|
|
else player_out.batting_order
|
|
)
|
|
|
|
# STEP 3: Update DATABASE FIRST
|
|
try:
|
|
new_lineup_id = await self.db_ops.create_substitution(
|
|
game_id=game_id,
|
|
team_id=team_id,
|
|
player_out_lineup_id=player_out_lineup_id,
|
|
player_in_card_id=player_in_card_id,
|
|
position=new_position,
|
|
batting_order=batting_order,
|
|
inning=state.inning,
|
|
play_number=state.play_count,
|
|
)
|
|
except IntegrityError as e:
|
|
logger.error(f"Integrity error during defensive replacement: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Player substitution conflict - player may already be active",
|
|
error_code="DB_INTEGRITY_ERROR",
|
|
)
|
|
except OperationalError as e:
|
|
logger.error(f"Database connection error during defensive replacement: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database connection error - please retry",
|
|
error_code="DB_CONNECTION_ERROR",
|
|
)
|
|
except SQLAlchemyError as e:
|
|
logger.error(
|
|
f"Database error during defensive replacement: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database error occurred",
|
|
error_code="DB_ERROR",
|
|
)
|
|
|
|
# STEP 4: Update IN-MEMORY STATE SECOND
|
|
try:
|
|
# Mark old player inactive
|
|
player_out.is_active = False
|
|
|
|
# Fetch player data for SBA league
|
|
player_name = f"Player #{player_in_card_id}"
|
|
player_image = ""
|
|
if state.league_id == "sba":
|
|
player_name, player_image = await lineup_service.get_sba_player_data(
|
|
player_in_card_id
|
|
)
|
|
|
|
# Create new player entry
|
|
new_player = LineupPlayerState(
|
|
lineup_id=new_lineup_id,
|
|
card_id=player_in_card_id,
|
|
position=new_position,
|
|
batting_order=batting_order,
|
|
is_active=True,
|
|
is_starter=False,
|
|
player_name=player_name,
|
|
player_image=player_image,
|
|
)
|
|
|
|
# Add to roster
|
|
roster.players.append(new_player)
|
|
|
|
# Update cache
|
|
state_manager.set_lineup(game_id, team_id, roster)
|
|
|
|
# Update current pitcher/catcher if this affects them
|
|
if (
|
|
player_out.position == "P"
|
|
and state.current_pitcher
|
|
and state.current_pitcher.lineup_id == player_out_lineup_id
|
|
):
|
|
state.current_pitcher = new_player
|
|
state_manager.update_state(game_id, state)
|
|
elif (
|
|
player_out.position == "C"
|
|
and state.current_catcher
|
|
and state.current_catcher.lineup_id == player_out_lineup_id
|
|
):
|
|
state.current_catcher = new_player
|
|
state_manager.update_state(game_id, state)
|
|
|
|
logger.info(
|
|
f"Defensive replacement successful: {player_out.card_id} ({player_out.position}) → "
|
|
f"{player_in_card_id} ({new_position})"
|
|
)
|
|
|
|
return SubstitutionResult(
|
|
success=True,
|
|
new_lineup_id=new_lineup_id,
|
|
player_out_lineup_id=player_out_lineup_id,
|
|
player_in_card_id=player_in_card_id,
|
|
new_position=new_position,
|
|
new_batting_order=batting_order,
|
|
updated_lineup=roster,
|
|
)
|
|
|
|
except (KeyError, AttributeError) as e:
|
|
# Missing expected data in state - indicates corrupted state
|
|
logger.error(
|
|
f"State corruption during defensive replacement: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message="State corruption detected - game state may need recovery",
|
|
error_code="STATE_CORRUPTION_ERROR",
|
|
)
|
|
except ValueError as e:
|
|
# Invalid value during state update
|
|
logger.error(f"Invalid value during defensive replacement state update: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"Invalid state update: {str(e)}",
|
|
error_code="STATE_VALIDATION_ERROR",
|
|
)
|
|
|
|
async def change_pitcher(
|
|
self,
|
|
game_id: UUID,
|
|
pitcher_out_lineup_id: int,
|
|
pitcher_in_card_id: int,
|
|
team_id: int,
|
|
force_change: bool = False,
|
|
) -> SubstitutionResult:
|
|
"""
|
|
Execute pitching change.
|
|
|
|
Special case of defensive replacement for pitchers.
|
|
Always changes position 'P', maintains batting order.
|
|
|
|
Flow: Same as pinch_hit (validate → DB → state)
|
|
|
|
Args:
|
|
game_id: Game identifier
|
|
pitcher_out_lineup_id: Lineup ID of current pitcher
|
|
pitcher_in_card_id: Card ID of incoming pitcher
|
|
team_id: Team identifier
|
|
force_change: If True, allows immediate change (for injuries)
|
|
|
|
Returns:
|
|
SubstitutionResult with success status and details
|
|
"""
|
|
logger.info(
|
|
f"Pitching change request: game={game_id}, "
|
|
f"out={pitcher_out_lineup_id}, in={pitcher_in_card_id}, team={team_id}"
|
|
)
|
|
|
|
# STEP 1: Load game state and roster
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Game {game_id} not found",
|
|
error_code="GAME_NOT_FOUND",
|
|
)
|
|
|
|
roster = state_manager.get_lineup(game_id, team_id)
|
|
if not roster:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Roster not found for team {team_id}",
|
|
error_code="ROSTER_NOT_FOUND",
|
|
)
|
|
|
|
pitcher_out = roster.get_player_by_lineup_id(pitcher_out_lineup_id)
|
|
if not pitcher_out:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Player with lineup_id {pitcher_out_lineup_id} not found",
|
|
error_code="PLAYER_NOT_FOUND",
|
|
)
|
|
|
|
# STEP 2: Validate substitution
|
|
validation = self.rules.validate_pitching_change(
|
|
state=state,
|
|
pitcher_out=pitcher_out,
|
|
pitcher_in_card_id=pitcher_in_card_id,
|
|
roster=roster,
|
|
force_change=force_change,
|
|
)
|
|
|
|
if not validation.is_valid:
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=validation.error_message,
|
|
error_code=validation.error_code,
|
|
)
|
|
|
|
# STEP 3: Update DATABASE FIRST
|
|
try:
|
|
new_lineup_id = await self.db_ops.create_substitution(
|
|
game_id=game_id,
|
|
team_id=team_id,
|
|
player_out_lineup_id=pitcher_out_lineup_id,
|
|
player_in_card_id=pitcher_in_card_id,
|
|
position="P", # Always pitcher
|
|
batting_order=pitcher_out.batting_order, # Maintains batting order
|
|
inning=state.inning,
|
|
play_number=state.play_count,
|
|
)
|
|
except IntegrityError as e:
|
|
logger.error(f"Integrity error during pitching change: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Pitching change conflict - pitcher may already be active",
|
|
error_code="DB_INTEGRITY_ERROR",
|
|
)
|
|
except OperationalError as e:
|
|
logger.error(f"Database connection error during pitching change: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database connection error - please retry",
|
|
error_code="DB_CONNECTION_ERROR",
|
|
)
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Database error during pitching change: {e}", exc_info=True)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message="Database error occurred",
|
|
error_code="DB_ERROR",
|
|
)
|
|
|
|
# STEP 4: Update IN-MEMORY STATE SECOND
|
|
try:
|
|
# Mark old pitcher inactive
|
|
pitcher_out.is_active = False
|
|
|
|
# Fetch player data for SBA league
|
|
player_name = f"Player #{pitcher_in_card_id}"
|
|
player_image = ""
|
|
if state.league_id == "sba":
|
|
player_name, player_image = await lineup_service.get_sba_player_data(
|
|
pitcher_in_card_id
|
|
)
|
|
|
|
# Create new pitcher entry
|
|
new_pitcher = LineupPlayerState(
|
|
lineup_id=new_lineup_id,
|
|
card_id=pitcher_in_card_id,
|
|
position="P",
|
|
batting_order=pitcher_out.batting_order,
|
|
is_active=True,
|
|
is_starter=False,
|
|
player_name=player_name,
|
|
player_image=player_image,
|
|
)
|
|
|
|
# Add to roster
|
|
roster.players.append(new_pitcher)
|
|
|
|
# Update cache
|
|
state_manager.set_lineup(game_id, team_id, roster)
|
|
|
|
# Update current pitcher in game state
|
|
state.current_pitcher = new_pitcher
|
|
state_manager.update_state(game_id, state)
|
|
|
|
logger.info(
|
|
f"Pitching change successful: {pitcher_out.card_id} (lineup {pitcher_out_lineup_id}) → "
|
|
f"{pitcher_in_card_id} (new lineup {new_lineup_id})"
|
|
)
|
|
|
|
return SubstitutionResult(
|
|
success=True,
|
|
new_lineup_id=new_lineup_id,
|
|
player_out_lineup_id=pitcher_out_lineup_id,
|
|
player_in_card_id=pitcher_in_card_id,
|
|
new_position="P",
|
|
new_batting_order=pitcher_out.batting_order,
|
|
updated_lineup=roster,
|
|
)
|
|
|
|
except (KeyError, AttributeError) as e:
|
|
# Missing expected data in state - indicates corrupted state
|
|
logger.error(
|
|
f"State corruption during pitching change: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message="State corruption detected - game state may need recovery",
|
|
error_code="STATE_CORRUPTION_ERROR",
|
|
)
|
|
except ValueError as e:
|
|
# Invalid value during state update
|
|
logger.error(f"Invalid value during pitching change state update: {e}")
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"Invalid state update: {str(e)}",
|
|
error_code="STATE_VALIDATION_ERROR",
|
|
)
|