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>
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""
|
|
Substitution Rules - Baseball substitution validation logic.
|
|
|
|
Enforces official baseball substitution rules:
|
|
- Players can only enter game once (no re-entry)
|
|
- Substitutes must be from active roster
|
|
- Substitutes must not already be in game
|
|
- Substitutions respect game flow (timing, positions)
|
|
|
|
Author: Claude
|
|
Date: 2025-11-03
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from app.models.game_models import GameState, LineupPlayerState, TeamLineupState
|
|
|
|
logger = logging.getLogger(f"{__name__}.SubstitutionRules")
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
"""Result of a substitution validation check"""
|
|
|
|
is_valid: bool
|
|
error_message: str | None = None
|
|
error_code: str | None = None
|
|
|
|
|
|
class SubstitutionRules:
|
|
"""
|
|
Baseball substitution rules validation.
|
|
|
|
Enforces official rules:
|
|
- No re-entry (once removed, player cannot return)
|
|
- Roster eligibility (must be on roster)
|
|
- Active status (substitute must be inactive)
|
|
- Game flow (timing and situational rules)
|
|
"""
|
|
|
|
@staticmethod
|
|
def validate_pinch_hitter(
|
|
state: GameState,
|
|
player_out: LineupPlayerState,
|
|
player_in_card_id: int,
|
|
roster: TeamLineupState,
|
|
) -> ValidationResult:
|
|
"""
|
|
Validate pinch hitter substitution.
|
|
|
|
Rules:
|
|
1. Substitute must be in roster
|
|
2. Substitute must be inactive (not already in game)
|
|
3. Player being replaced must be current batter
|
|
4. Substituted player cannot re-enter
|
|
|
|
Args:
|
|
state: Current game state
|
|
player_out: Player being replaced (must be current batter)
|
|
player_in_card_id: Card ID of incoming player
|
|
roster: Team's complete roster
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and optional error message
|
|
"""
|
|
# Check player_out is current batter
|
|
if player_out.lineup_id != state.current_batter.lineup_id:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Can only pinch hit for current batter. Current batter lineup_id: {state.current_batter.lineup_id}",
|
|
error_code="NOT_CURRENT_BATTER",
|
|
)
|
|
|
|
# Check player_out is active
|
|
if not player_out.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Cannot substitute for player who is already out of game",
|
|
error_code="PLAYER_ALREADY_OUT",
|
|
)
|
|
|
|
# Check substitute is in roster
|
|
player_in = roster.get_player_by_card_id(player_in_card_id)
|
|
if not player_in:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player with card_id {player_in_card_id} not found in roster",
|
|
error_code="NOT_IN_ROSTER",
|
|
)
|
|
|
|
# Check substitute is not already active
|
|
if player_in.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player {player_in.card_id} is already in the game",
|
|
error_code="ALREADY_ACTIVE",
|
|
)
|
|
|
|
# All checks passed
|
|
logger.info(
|
|
f"Pinch hitter validation passed: "
|
|
f"{player_out.card_id} (lineup_id {player_out.lineup_id}) → "
|
|
f"{player_in.card_id} (card_id {player_in_card_id})"
|
|
)
|
|
return ValidationResult(is_valid=True)
|
|
|
|
@staticmethod
|
|
def validate_defensive_replacement(
|
|
state: GameState,
|
|
player_out: LineupPlayerState,
|
|
player_in_card_id: int,
|
|
new_position: str,
|
|
roster: TeamLineupState,
|
|
allow_mid_inning: bool = False,
|
|
) -> ValidationResult:
|
|
"""
|
|
Validate defensive replacement.
|
|
|
|
Rules:
|
|
1. Substitute must be in roster
|
|
2. Substitute must be inactive
|
|
3. Substituted player cannot re-enter
|
|
4. Generally only between half-innings (unless allow_mid_inning=True for injury)
|
|
5. New position must be valid baseball position
|
|
|
|
Args:
|
|
state: Current game state
|
|
player_out: Player being replaced
|
|
player_in_card_id: Card ID of incoming player
|
|
new_position: Position for incoming player (usually same as player_out)
|
|
roster: Team's complete roster
|
|
allow_mid_inning: If True, allows mid-inning substitution (for injuries)
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and optional error message
|
|
"""
|
|
# Check player_out is active
|
|
if not player_out.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Cannot substitute for player who is already out of game",
|
|
error_code="PLAYER_ALREADY_OUT",
|
|
)
|
|
|
|
# Check timing (can substitute mid-play if injury, otherwise must wait for half-inning)
|
|
# For MVP: We'll allow any time, but log if mid-inning
|
|
if not allow_mid_inning and state.play_count > 0:
|
|
# In real game, would check if we're between half-innings
|
|
# For MVP, just log a warning
|
|
logger.warning(
|
|
f"Defensive replacement during active play (play {state.play_count}). "
|
|
f"In production, verify this is between half-innings or an injury."
|
|
)
|
|
|
|
# Validate new position
|
|
valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
|
|
if new_position not in valid_positions:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Invalid position: {new_position}. Must be one of {valid_positions}",
|
|
error_code="INVALID_POSITION",
|
|
)
|
|
|
|
# Check substitute is in roster
|
|
player_in = roster.get_player_by_card_id(player_in_card_id)
|
|
if not player_in:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player with card_id {player_in_card_id} not found in roster",
|
|
error_code="NOT_IN_ROSTER",
|
|
)
|
|
|
|
# Check substitute is not already active
|
|
if player_in.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player {player_in.card_id} is already in the game",
|
|
error_code="ALREADY_ACTIVE",
|
|
)
|
|
|
|
# All checks passed
|
|
logger.info(
|
|
f"Defensive replacement validation passed: "
|
|
f"{player_out.card_id} (lineup_id {player_out.lineup_id}, {player_out.position}) → "
|
|
f"{player_in.card_id} (card_id {player_in_card_id}, {new_position})"
|
|
)
|
|
return ValidationResult(is_valid=True)
|
|
|
|
@staticmethod
|
|
def validate_pitching_change(
|
|
state: GameState,
|
|
pitcher_out: LineupPlayerState,
|
|
pitcher_in_card_id: int,
|
|
roster: TeamLineupState,
|
|
force_change: bool = False,
|
|
) -> ValidationResult:
|
|
"""
|
|
Validate pitching change.
|
|
|
|
Rules:
|
|
1. Substitute must be in roster
|
|
2. Substitute must be inactive
|
|
3. Old pitcher must be active
|
|
4. Old pitcher must have faced at least 1 batter (unless force_change=True for injury)
|
|
5. Substituted pitcher cannot re-enter as pitcher
|
|
|
|
Args:
|
|
state: Current game state
|
|
pitcher_out: Current pitcher being replaced
|
|
pitcher_in_card_id: Card ID of incoming pitcher
|
|
roster: Team's complete roster
|
|
force_change: If True, allows immediate change (for injuries/emergencies)
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and optional error message
|
|
"""
|
|
# Check pitcher_out is active
|
|
if not pitcher_out.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Cannot substitute for pitcher who is already out of game",
|
|
error_code="PLAYER_ALREADY_OUT",
|
|
)
|
|
|
|
# Check pitcher_out is actually a pitcher
|
|
if pitcher_out.position != "P":
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player being replaced is not a pitcher (position: {pitcher_out.position})",
|
|
error_code="NOT_A_PITCHER",
|
|
)
|
|
|
|
# Check minimum batters faced (unless force_change)
|
|
if not force_change and state.play_count == 0:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Pitcher must face at least 1 batter before being replaced",
|
|
error_code="MIN_BATTERS_NOT_MET",
|
|
)
|
|
|
|
# Check substitute is in roster
|
|
pitcher_in = roster.get_player_by_card_id(pitcher_in_card_id)
|
|
if not pitcher_in:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player with card_id {pitcher_in_card_id} not found in roster",
|
|
error_code="NOT_IN_ROSTER",
|
|
)
|
|
|
|
# Check substitute is not already active
|
|
if pitcher_in.is_active:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Player {pitcher_in.card_id} is already in the game",
|
|
error_code="ALREADY_ACTIVE",
|
|
)
|
|
|
|
# Check new pitcher can play pitcher position (MVP: skip position eligibility check)
|
|
# In post-MVP, would check if player has 'P' in their positions list
|
|
|
|
# All checks passed
|
|
logger.info(
|
|
f"Pitching change validation passed: "
|
|
f"{pitcher_out.card_id} (lineup_id {pitcher_out.lineup_id}) → "
|
|
f"{pitcher_in.card_id} (card_id {pitcher_in_card_id})"
|
|
)
|
|
return ValidationResult(is_valid=True)
|
|
|
|
@staticmethod
|
|
def validate_double_switch(
|
|
state: GameState,
|
|
player_out_1: LineupPlayerState,
|
|
player_in_1_card_id: int,
|
|
new_position_1: str,
|
|
new_batting_order_1: int,
|
|
player_out_2: LineupPlayerState,
|
|
player_in_2_card_id: int,
|
|
new_position_2: str,
|
|
new_batting_order_2: int,
|
|
roster: TeamLineupState,
|
|
) -> ValidationResult:
|
|
"""
|
|
Validate double switch (two simultaneous substitutions with batting order swap).
|
|
|
|
Complex rule used in National League to optimize pitcher's spot in batting order.
|
|
|
|
Rules:
|
|
1. Both substitutes must be in roster
|
|
2. Both substitutes must be inactive
|
|
3. Both players being replaced must be active
|
|
4. New positions must be valid
|
|
5. New batting orders must be different from each other
|
|
6. Generally done between half-innings
|
|
|
|
Args:
|
|
state: Current game state
|
|
player_out_1: First player being replaced
|
|
player_in_1_card_id: Card ID of first incoming player
|
|
new_position_1: Position for first incoming player
|
|
new_batting_order_1: Batting order for first incoming player
|
|
player_out_2: Second player being replaced
|
|
player_in_2_card_id: Card ID of second incoming player
|
|
new_position_2: Position for second incoming player
|
|
new_batting_order_2: Batting order for second incoming player
|
|
roster: Team's complete roster
|
|
|
|
Returns:
|
|
ValidationResult with is_valid and optional error message
|
|
"""
|
|
# Validate first substitution
|
|
result_1 = SubstitutionRules.validate_defensive_replacement(
|
|
state=state,
|
|
player_out=player_out_1,
|
|
player_in_card_id=player_in_1_card_id,
|
|
new_position=new_position_1,
|
|
roster=roster,
|
|
allow_mid_inning=False,
|
|
)
|
|
if not result_1.is_valid:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"First substitution invalid: {result_1.error_message}",
|
|
error_code=f"FIRST_SUB_{result_1.error_code}",
|
|
)
|
|
|
|
# Validate second substitution
|
|
result_2 = SubstitutionRules.validate_defensive_replacement(
|
|
state=state,
|
|
player_out=player_out_2,
|
|
player_in_card_id=player_in_2_card_id,
|
|
new_position=new_position_2,
|
|
roster=roster,
|
|
allow_mid_inning=False,
|
|
)
|
|
if not result_2.is_valid:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message=f"Second substitution invalid: {result_2.error_message}",
|
|
error_code=f"SECOND_SUB_{result_2.error_code}",
|
|
)
|
|
|
|
# Validate batting orders
|
|
if new_batting_order_1 not in range(1, 10) or new_batting_order_2 not in range(
|
|
1, 10
|
|
):
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Batting orders must be between 1 and 9",
|
|
error_code="INVALID_BATTING_ORDER",
|
|
)
|
|
|
|
if new_batting_order_1 == new_batting_order_2:
|
|
return ValidationResult(
|
|
is_valid=False,
|
|
error_message="Both players cannot have same batting order",
|
|
error_code="DUPLICATE_BATTING_ORDER",
|
|
)
|
|
|
|
# All checks passed
|
|
logger.info(
|
|
f"Double switch validation passed: "
|
|
f"({player_out_1.card_id} → {player_in_1_card_id} at {new_position_1}, order {new_batting_order_1}) & "
|
|
f"({player_out_2.card_id} → {player_in_2_card_id} at {new_position_2}, order {new_batting_order_2})"
|
|
)
|
|
return ValidationResult(is_valid=True)
|