strat-gameplay-webapp/backend/app/core/validators.py
Cal Corum 1c32787195 CLAUDE: Refactor game models and modularize terminal client
This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.

## Game Models Refactor

**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
  - on_first: Optional[LineupPlayerState]
  - on_second: Optional[LineupPlayerState]
  - on_third: Optional[LineupPlayerState]
- Updated helper methods:
  - get_runner_at_base() now returns LineupPlayerState directly
  - get_all_runners() returns List[Tuple[int, LineupPlayerState]]
  - is_runner_on_X() simplified to direct None checks

**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic

**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data

## Terminal Client Refactor

**Modularization (DRY principle):**
Created separate modules for better code organization:

1. **terminal_client/commands.py** (10,243 bytes)
   - Shared command functions for game operations
   - Used by both CLI (main.py) and REPL (repl.py)
   - Functions: submit_defensive_decision, submit_offensive_decision,
     resolve_play, quick_play_sequence
   - Single source of truth for command logic

2. **terminal_client/arg_parser.py** (7,280 bytes)
   - Centralized argument parsing and validation
   - Handles defensive/offensive decision arguments
   - Validates formats (alignment, depths, hold runners, steal attempts)

3. **terminal_client/completions.py** (10,357 bytes)
   - TAB completion support for REPL mode
   - Command completions, option completions, dynamic completions
   - Game ID completions, defensive/offensive option suggestions

4. **terminal_client/help_text.py** (10,839 bytes)
   - Centralized help text and command documentation
   - Detailed command descriptions
   - Usage examples for all commands

**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions

**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns

## Documentation

**New files:**
- app/models/visual_model_relationships.md
  - Visual documentation of model relationships
  - Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
  - Phased documentation for terminal client evolution
  - Historical context for implementation decisions

## Tests

**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py

**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure

## Summary

-  Simplified game state model (removed RunnerState)
-  Better alignment with database structure
-  Modularized terminal client (DRY principle)
-  Shared command logic between CLI and REPL
-  Comprehensive test coverage
-  Improved documentation

Total changes: 26 files modified/created

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:16:38 -05:00

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
occupied_bases = state.bases_occupied()
for base in decision.hold_runners:
if base not in occupied_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
occupied_bases = state.bases_occupied()
for base in decision.steal_attempts:
# Must have runner on base-1 to steal base
if (base - 1) not in occupied_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()