strat-gameplay-webapp/backend/app/core/substitution_manager.py
Cal Corum a4b99ee53e CLAUDE: Replace black and flake8 with ruff for formatting and linting
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>
2025-11-20 15:33:21 -06:00

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",
)