Replaced awkward "lookback" pattern with clean "prepare → execute → save" orchestration that captures state snapshots BEFORE each play. Key improvements: - Added per-team batter indices (away_team_batter_idx, home_team_batter_idx) - Added play snapshot fields (current_batter/pitcher/catcher_lineup_id) - Added on_base_code bit field for efficient base situation queries - Created _prepare_next_play() method for snapshot preparation - Refactored start_game() with hard lineup validation requirement - Refactored resolve_play() with explicit 6-step orchestration - Updated _save_play_to_db() to use snapshots (no DB lookbacks) - Enhanced state recovery to rebuild from last play (single query) - Added defensive lineup position validator Benefits: - No special cases for first play - Single source of truth in GameState - Saves 18+ database queries per game - Fast state recovery without replay - Complete runner tracking (before/after positions) - Explicit orchestration (easy to debug) Testing: - Added 3 new test functions (lineup validation, snapshot tracking, batting order) - All 5 test suites passing (100%) - Type checking cleaned up with targeted suppressions for SQLAlchemy Documentation: - Added comprehensive "Type Checking & Common False Positives" section to CLAUDE.md - Created type-checking-guide.md and type-checking-summary.md - Added mypy.ini configuration for SQLAlchemy/Pydantic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
144 lines
5.2 KiB
Python
144 lines
5.2 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 uuid import UUID
|
|
|
|
from app.models.game_models import GameState, DefensiveDecision, 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"""
|
|
valid_alignments = ["normal", "shifted_left", "shifted_right"]
|
|
if decision.alignment not in valid_alignments:
|
|
raise ValidationError(f"Invalid alignment: {decision.alignment}")
|
|
|
|
valid_depths = ["in", "normal", "back", "double_play"] # TODO: update these to strat-specific values
|
|
if decision.infield_depth not in valid_depths:
|
|
raise ValidationError(f"Invalid infield depth: {decision.infield_depth}")
|
|
|
|
# Validate hold runners - can't hold empty bases
|
|
runner_bases = [r.on_base for r in state.runners]
|
|
for base in decision.hold_runners:
|
|
if base not in runner_bases:
|
|
raise ValidationError(f"Can't hold base {base} - no runner present")
|
|
|
|
logger.debug("Defensive decision validated")
|
|
|
|
@staticmethod
|
|
def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None:
|
|
"""Validate offensive team decision"""
|
|
valid_approaches = ["normal", "contact", "power", "patient"] # TODO: update these to strat-specific values
|
|
if decision.approach not in valid_approaches:
|
|
raise ValidationError(f"Invalid approach: {decision.approach}")
|
|
|
|
# Validate steal attempts
|
|
runner_bases = [r.on_base for r in state.runners]
|
|
for base in decision.steal_attempts:
|
|
# Must have runner on base-1 to steal base
|
|
if (base - 1) not in runner_bases:
|
|
raise ValidationError(f"Can't steal {base} - no runner on {base-1}")
|
|
|
|
# TODO: add check that base in front of stealing runner is unoccupied
|
|
|
|
# Can't bunt with 2 outs (simplified rule)
|
|
if decision.bunt_attempt and state.outs == 2:
|
|
raise ValidationError("Cannot bunt with 2 outs")
|
|
|
|
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"""
|
|
return state.outs < 3
|
|
|
|
@staticmethod
|
|
def is_game_over(state: GameState) -> bool:
|
|
"""Check if game is complete"""
|
|
# Game over after 9 innings if score not tied
|
|
if state.inning >= 9 and state.half == "bottom":
|
|
if state.home_score != state.away_score:
|
|
return True
|
|
# Home team wins if ahead in bottom of 9th
|
|
if state.home_score > state.away_score:
|
|
return True
|
|
# Also check if we're in extras and bottom team is ahead
|
|
if state.inning > 9 and state.half == "bottom":
|
|
if state.home_score > state.away_score:
|
|
return True
|
|
return False
|
|
|
|
|
|
# Singleton instance
|
|
game_validator = GameValidator()
|