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>
252 lines
9.4 KiB
Python
252 lines
9.4 KiB
Python
"""
|
|
Rule Validators - Validate game actions and state transitions.
|
|
|
|
Ensures all game actions follow baseball rules and state is valid.
|
|
|
|
Author: Claude
|
|
Date: 2025-10-24
|
|
"""
|
|
|
|
import logging
|
|
|
|
from app.models.game_models import DefensiveDecision, GameState, OffensiveDecision
|
|
|
|
logger = logging.getLogger(f"{__name__}.GameValidator")
|
|
|
|
|
|
class ValidationError(Exception):
|
|
"""Raised when validation fails"""
|
|
|
|
pass
|
|
|
|
|
|
class GameValidator:
|
|
"""Validates game actions and state"""
|
|
|
|
@staticmethod
|
|
def validate_game_active(state: GameState) -> None:
|
|
"""Ensure game is in active state"""
|
|
if state.status != "active":
|
|
raise ValidationError(f"Game is not active (status: {state.status})")
|
|
|
|
@staticmethod
|
|
def validate_outs(outs: int) -> None:
|
|
"""Ensure outs are valid"""
|
|
if outs < 0 or outs > 2:
|
|
raise ValidationError(f"Invalid outs: {outs} (must be 0-2)")
|
|
|
|
@staticmethod
|
|
def validate_inning(inning: int, half: str) -> None:
|
|
"""Ensure inning is valid"""
|
|
if inning < 1:
|
|
raise ValidationError(f"Invalid inning: {inning}")
|
|
if half not in ["top", "bottom"]:
|
|
raise ValidationError(f"Invalid half: {half}")
|
|
|
|
@staticmethod
|
|
def validate_defensive_decision(
|
|
decision: DefensiveDecision, state: GameState
|
|
) -> None:
|
|
"""
|
|
Validate defensive team decision against current game state.
|
|
|
|
Args:
|
|
decision: Defensive decision to validate
|
|
state: Current game state
|
|
|
|
Raises:
|
|
ValidationError: If decision is invalid for current situation
|
|
"""
|
|
# # Validate alignment (already validated by Pydantic, but double-check)
|
|
# valid_alignments = ["normal", "shifted_left", "shifted_right", "extreme_shift"]
|
|
# if decision.alignment not in valid_alignments:
|
|
# raise ValidationError(f"Invalid alignment: {decision.alignment}")
|
|
|
|
# Validate depths (already validated by Pydantic, but double-check)
|
|
valid_infield_depths = ["infield_in", "normal", "corners_in"]
|
|
if decision.infield_depth not in valid_infield_depths:
|
|
raise ValidationError(f"Invalid infield depth: {decision.infield_depth}")
|
|
|
|
valid_outfield_depths = ["normal", "shallow"]
|
|
if decision.outfield_depth not in valid_outfield_depths:
|
|
raise ValidationError(f"Invalid outfield depth: {decision.outfield_depth}")
|
|
|
|
# Validate hold runners - can't hold empty bases
|
|
occupied_bases = state.bases_occupied()
|
|
for base in decision.hold_runners:
|
|
if base not in [1, 2, 3]:
|
|
raise ValidationError(
|
|
f"Invalid hold runner base: {base} (must be 1, 2, or 3)"
|
|
)
|
|
if base not in occupied_bases:
|
|
raise ValidationError(
|
|
f"Cannot hold runner on base {base} - no runner present"
|
|
)
|
|
|
|
# Validate corners_in/infield_in depth requirements (requires runner on third)
|
|
if decision.infield_depth in ["corners_in", "infield_in"]:
|
|
if not state.is_runner_on_third():
|
|
raise ValidationError(
|
|
f"Cannot play {decision.infield_depth} without a runner on third"
|
|
)
|
|
|
|
# Validate shallow outfield requires walk-off scenario
|
|
if decision.outfield_depth == "shallow":
|
|
# Walk-off conditions:
|
|
# 1. Home team batting (bottom of inning)
|
|
# 2. Bottom of final inning or later
|
|
# 3. Tied or trailing
|
|
# 4. Runner on base
|
|
is_home_batting = state.half == "bottom"
|
|
is_late_inning = state.inning >= state.regulation_innings
|
|
|
|
if is_home_batting:
|
|
is_close_game = state.home_score <= state.away_score
|
|
else:
|
|
is_close_game = state.away_score <= state.home_score
|
|
|
|
has_runners = len(occupied_bases) > 0
|
|
|
|
if not (
|
|
is_home_batting and is_late_inning and is_close_game and has_runners
|
|
):
|
|
raise ValidationError(
|
|
f"Shallow outfield only allowed in walk-off situations "
|
|
f"(home team batting, bottom {state.regulation_innings}th+ inning, tied/trailing, runner on base)"
|
|
)
|
|
|
|
logger.debug("Defensive decision validated")
|
|
|
|
@staticmethod
|
|
def validate_offensive_decision(
|
|
decision: OffensiveDecision, state: GameState
|
|
) -> None:
|
|
"""
|
|
Validate offensive team decision against current game state.
|
|
|
|
Args:
|
|
decision: Offensive decision to validate
|
|
state: Current game state
|
|
|
|
Raises:
|
|
ValidationError: If decision is invalid for current situation
|
|
|
|
Session 2 Update (2025-01-14): Added validation for action field.
|
|
"""
|
|
# Validate action field (already validated by Pydantic, but enforce situational rules)
|
|
occupied_bases = state.bases_occupied()
|
|
|
|
# Validate steal action - requires steal_attempts to be specified
|
|
if decision.action == "steal":
|
|
if not decision.steal_attempts:
|
|
raise ValidationError(
|
|
"Steal action requires steal_attempts to specify which bases to steal"
|
|
)
|
|
|
|
# Validate squeeze_bunt - requires R3, not with 2 outs
|
|
if decision.action == "squeeze_bunt":
|
|
if not state.is_runner_on_third():
|
|
raise ValidationError("Squeeze bunt requires a runner on third base")
|
|
if state.outs >= 2:
|
|
raise ValidationError("Squeeze bunt cannot be used with 2 outs")
|
|
|
|
# Validate check_jump - requires runner on base (lead runner only OR both if 1st+3rd)
|
|
if decision.action == "check_jump":
|
|
if len(occupied_bases) == 0:
|
|
raise ValidationError("Check jump requires at least one runner on base")
|
|
# Lead runner validation: can't check jump at 2nd if R3 exists
|
|
if state.is_runner_on_second() and state.is_runner_on_third():
|
|
raise ValidationError(
|
|
"Check jump not allowed for trail runner (R2) when R3 is on base"
|
|
)
|
|
|
|
# Validate sac_bunt - cannot be used with 2 outs
|
|
if decision.action == "sac_bunt":
|
|
if state.outs >= 2:
|
|
raise ValidationError("Sacrifice bunt cannot be used with 2 outs")
|
|
|
|
# Validate hit_and_run action - requires runner on base
|
|
if decision.action == "hit_and_run":
|
|
if len(occupied_bases) == 0:
|
|
raise ValidationError(
|
|
"Hit and run action requires at least one runner on base"
|
|
)
|
|
|
|
# Validate steal attempts (when provided)
|
|
for base in decision.steal_attempts:
|
|
# Validate steal base is valid (2, 3, or 4 for home)
|
|
if base not in [2, 3, 4]:
|
|
raise ValidationError(
|
|
f"Invalid steal attempt to base {base} (must be 2, 3, or 4)"
|
|
)
|
|
|
|
# Must have runner on base-1 to steal base
|
|
stealing_from = base - 1
|
|
if stealing_from not in occupied_bases:
|
|
raise ValidationError(
|
|
f"Cannot steal base {base} - no runner on base {stealing_from}"
|
|
)
|
|
|
|
logger.debug("Offensive decision validated")
|
|
|
|
@staticmethod
|
|
def validate_defensive_lineup_positions(lineup: list) -> None:
|
|
"""
|
|
Validate defensive lineup has exactly 1 active player per position.
|
|
|
|
Args:
|
|
lineup: List of LineupPlayerState objects
|
|
|
|
Raises:
|
|
ValidationError: If any position is missing or duplicated
|
|
"""
|
|
required_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
|
|
# Count active players per position
|
|
position_counts: dict[str, int] = {}
|
|
for player in lineup:
|
|
if player.is_active:
|
|
pos = player.position
|
|
position_counts[pos] = position_counts.get(pos, 0) + 1
|
|
|
|
# Check each required position has exactly 1 active player
|
|
errors = []
|
|
for pos in required_positions:
|
|
count = position_counts.get(pos, 0)
|
|
if count == 0:
|
|
errors.append(f"Missing active player at {pos}")
|
|
elif count > 1:
|
|
errors.append(f"Multiple active players at {pos} ({count} players)")
|
|
|
|
if errors:
|
|
raise ValidationError(f"Invalid defensive lineup: {'; '.join(errors)}")
|
|
|
|
logger.debug("Defensive lineup positions validated")
|
|
|
|
@staticmethod
|
|
def can_continue_inning(state: GameState) -> bool:
|
|
"""Check if inning can continue using state's outs_per_inning"""
|
|
return state.outs < state.outs_per_inning
|
|
|
|
@staticmethod
|
|
def is_game_over(state: GameState) -> bool:
|
|
"""Check if game is complete using state's regulation_innings"""
|
|
reg = state.regulation_innings
|
|
|
|
# Game over after regulation innings if score not tied
|
|
if state.inning >= reg and state.half == "bottom":
|
|
if state.home_score != state.away_score:
|
|
return True
|
|
# Home team wins if ahead in bottom of final inning
|
|
if state.home_score > state.away_score:
|
|
return True
|
|
# Also check if we're in extras and bottom team is ahead
|
|
if state.inning > reg and state.half == "bottom":
|
|
if state.home_score > state.away_score:
|
|
return True
|
|
return False
|
|
|
|
|
|
# Singleton instance
|
|
game_validator = GameValidator()
|