Migrated to ruff for faster, modern code formatting and linting: Configuration changes: - pyproject.toml: Added ruff 0.8.6, removed black/flake8 - Configured ruff with black-compatible formatting (88 chars) - Enabled comprehensive linting rules (pycodestyle, pyflakes, isort, pyupgrade, bugbear, comprehensions, simplify, return) - Updated CLAUDE.md: Changed code quality commands to use ruff Code improvements (490 auto-fixes): - Modernized type hints: List[T] → list[T], Dict[K,V] → dict[K,V], Optional[T] → T | None - Sorted all imports (isort integration) - Removed unused imports - Fixed whitespace issues - Reformatted 38 files for consistency Bug fixes: - app/core/play_resolver.py: Fixed type hint bug (any → Any) - tests/unit/core/test_runner_advancement.py: Removed obsolete random mock Testing: - All 739 unit tests passing (100%) - No regressions introduced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
555 lines
19 KiB
Python
555 lines
19 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 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 Exception as e:
|
|
logger.error(
|
|
f"Database error during pinch hit substitution: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Database error: {str(e)}",
|
|
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 Exception as e:
|
|
logger.error(
|
|
f"State update error during pinch hit substitution: {e}", exc_info=True
|
|
)
|
|
# Database is already updated - this is a state sync issue
|
|
# Log error but return partial success (DB is source of truth)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"State sync error: {str(e)}",
|
|
error_code="STATE_SYNC_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 Exception as e:
|
|
logger.error(
|
|
f"Database error during defensive replacement: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Database error: {str(e)}",
|
|
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 Exception as e:
|
|
logger.error(
|
|
f"State update error during defensive replacement: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"State sync error: {str(e)}",
|
|
error_code="STATE_SYNC_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 Exception as e:
|
|
logger.error(f"Database error during pitching change: {e}", exc_info=True)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
error_message=f"Database error: {str(e)}",
|
|
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 Exception as e:
|
|
logger.error(
|
|
f"State update error during pitching change: {e}", exc_info=True
|
|
)
|
|
return SubstitutionResult(
|
|
success=False,
|
|
new_lineup_id=new_lineup_id,
|
|
error_message=f"State sync error: {str(e)}",
|
|
error_code="STATE_SYNC_ERROR",
|
|
)
|