strat-gameplay-webapp/backend/app/core/validators.py
Cal Corum 63bffbc23d CLAUDE: Session 1 cleanup complete - Parts 4-6
Completed remaining Session 1 work:

Part 4: Remove offensive approach field
- Removed `approach` field from OffensiveDecision model
- Removed approach validation and validator
- Updated 7 backend files (model, tests, handlers, AI, validators, display)

Part 5: Server-side depth validation
- Added walk-off validation for shallow outfield (home batting, 9th+, close game, runners)
- Updated outfield depths from ["in", "normal"] to ["normal", "shallow"]
- Infield validation already complete (corners_in/infield_in require R3)
- Added comprehensive test coverage

Part 6: Client-side smart filtering
- Updated DefensiveSetup.vue with dynamic option filtering
- Infield options: only show infield_in/corners_in when R3 present
- Outfield options: only show shallow in walk-off scenarios
- Hybrid validation (server authority + client UX)

Total Session 1: 25 files modified across 6 parts
- Removed unused config fields
- Fixed hit location requirements
- Removed alignment/approach fields
- Added complete depth validation

All backend tests passing (730/731 - 1 pre-existing failure)

Next: Session 2 - Offensive decision workflow refactor (Changes #10-11)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 13:54:34 -06:00

205 lines
7.7 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 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 9th or later
# 3. Tied or trailing
# 4. Runner on base
is_home_batting = (state.half == 'bottom')
is_late_inning = (state.inning >= 9)
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(
"Shallow outfield only allowed in walk-off situations "
"(home team batting, bottom 9th+ 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
"""
# Validate steal attempts
occupied_bases = state.bases_occupied()
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}")
# Validate bunt attempt
if decision.bunt_attempt:
if state.outs >= 2:
raise ValidationError("Cannot bunt with 2 outs")
if decision.hit_and_run:
raise ValidationError("Cannot bunt and hit-and-run simultaneously")
# Validate hit and run - requires at least one runner on base
if decision.hit_and_run:
if not any(state.get_runner_at_base(b) is not None for b in [1, 2, 3]):
raise ValidationError("Hit and run requires at least one runner on base")
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()