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>
367 lines
11 KiB
Python
367 lines
11 KiB
Python
"""
|
|
Play Resolver - Resolves play outcomes based on dice rolls.
|
|
|
|
Uses our advanced dice system with AbRoll for at-bat resolution.
|
|
Simplified result charts for Phase 2 MVP.
|
|
|
|
Author: Claude
|
|
Date: 2025-10-24
|
|
"""
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Optional, List
|
|
from enum import Enum
|
|
|
|
from app.core.dice import dice_system
|
|
from app.core.roll_types import AbRoll
|
|
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision
|
|
|
|
logger = logging.getLogger(f'{__name__}.PlayResolver')
|
|
|
|
|
|
class PlayOutcome(str, Enum):
|
|
"""Possible play outcomes"""
|
|
# Outs
|
|
STRIKEOUT = "strikeout"
|
|
GROUNDOUT = "groundout"
|
|
FLYOUT = "flyout"
|
|
LINEOUT = "lineout"
|
|
DOUBLE_PLAY = "double_play"
|
|
|
|
# Hits
|
|
SINGLE = "single"
|
|
DOUBLE = "double"
|
|
TRIPLE = "triple"
|
|
HOMERUN = "homerun"
|
|
|
|
# Other
|
|
WALK = "walk"
|
|
HIT_BY_PITCH = "hbp"
|
|
ERROR = "error"
|
|
WILD_PITCH = "wild_pitch"
|
|
PASSED_BALL = "passed_ball"
|
|
|
|
|
|
@dataclass
|
|
class PlayResult:
|
|
"""Result of a resolved play"""
|
|
outcome: PlayOutcome
|
|
outs_recorded: int
|
|
runs_scored: int
|
|
batter_result: Optional[int] # None = out, 1-4 = base reached
|
|
runners_advanced: List[tuple[int, int]] # [(from_base, to_base), ...]
|
|
description: str
|
|
ab_roll: AbRoll # Full at-bat roll for audit trail
|
|
|
|
# Statistics
|
|
is_hit: bool = False
|
|
is_out: bool = False
|
|
is_walk: bool = False
|
|
|
|
|
|
class SimplifiedResultChart:
|
|
"""
|
|
Simplified SBA result chart for Phase 2
|
|
|
|
Real implementation will load from config files and consider:
|
|
- Batter card stats
|
|
- Pitcher card stats
|
|
- Defensive alignment
|
|
- Offensive approach
|
|
|
|
This provides basic outcomes for MVP testing.
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_outcome(ab_roll: AbRoll) -> PlayOutcome:
|
|
"""
|
|
Map AbRoll to outcome (simplified)
|
|
|
|
Uses the check_d20 value for outcome determination.
|
|
Checks for wild pitch/passed ball first.
|
|
"""
|
|
# Check for wild pitch/passed ball
|
|
if ab_roll.check_wild_pitch:
|
|
# check_d20 == 1, use resolution_d20 to confirm
|
|
if ab_roll.resolution_d20 <= 10: # 50% chance it actually happens
|
|
return PlayOutcome.WILD_PITCH
|
|
# Otherwise treat as ball/foul
|
|
return PlayOutcome.STRIKEOUT # Simplified
|
|
|
|
if ab_roll.check_passed_ball:
|
|
# check_d20 == 2, use resolution_d20 to confirm
|
|
if ab_roll.resolution_d20 <= 10: # 50% chance
|
|
return PlayOutcome.PASSED_BALL
|
|
# Otherwise treat as ball/foul
|
|
return PlayOutcome.STRIKEOUT # Simplified
|
|
|
|
# Normal at-bat resolution using check_d20
|
|
roll = ab_roll.check_d20
|
|
|
|
if roll <= 5:
|
|
return PlayOutcome.STRIKEOUT
|
|
elif roll <= 10:
|
|
return PlayOutcome.GROUNDOUT
|
|
elif roll <= 13:
|
|
return PlayOutcome.FLYOUT
|
|
elif roll <= 15:
|
|
return PlayOutcome.WALK
|
|
elif roll <= 17:
|
|
return PlayOutcome.SINGLE
|
|
elif roll <= 18:
|
|
return PlayOutcome.DOUBLE
|
|
elif roll == 19:
|
|
return PlayOutcome.TRIPLE
|
|
else: # 20
|
|
return PlayOutcome.HOMERUN
|
|
|
|
|
|
class PlayResolver:
|
|
"""Resolves play outcomes based on dice rolls and game state"""
|
|
|
|
def __init__(self):
|
|
self.result_chart = SimplifiedResultChart()
|
|
|
|
def resolve_play(
|
|
self,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
offensive_decision: OffensiveDecision
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve a complete play
|
|
|
|
Args:
|
|
state: Current game state
|
|
defensive_decision: Defensive team's choices
|
|
offensive_decision: Offensive team's choices
|
|
|
|
Returns:
|
|
PlayResult with complete outcome
|
|
"""
|
|
logger.info(f"Resolving play - Inning {state.inning} {state.half}, {state.outs} outs")
|
|
|
|
# Roll dice using our advanced AbRoll system
|
|
ab_roll = dice_system.roll_ab(
|
|
league_id=state.league_id,
|
|
game_id=state.game_id
|
|
)
|
|
logger.info(f"AB Roll: {ab_roll}")
|
|
|
|
# Get base outcome from chart
|
|
outcome = self.result_chart.get_outcome(ab_roll)
|
|
logger.info(f"Base outcome: {outcome}")
|
|
|
|
# Apply decisions (simplified for Phase 2)
|
|
# TODO: Implement full decision logic in Phase 3
|
|
|
|
# Resolve outcome details
|
|
result = self._resolve_outcome(outcome, state, ab_roll)
|
|
|
|
logger.info(f"Play result: {result.description}")
|
|
return result
|
|
|
|
def _resolve_outcome(
|
|
self,
|
|
outcome: PlayOutcome,
|
|
state: GameState,
|
|
ab_roll: AbRoll
|
|
) -> PlayResult:
|
|
"""Resolve specific outcome type"""
|
|
|
|
if outcome == PlayOutcome.STRIKEOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Strikeout looking",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.GROUNDOUT:
|
|
# Simple groundout - runners don't advance
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Groundout to shortstop",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.FLYOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Flyout to center field",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.WALK:
|
|
# Walk - batter to first, runners advance if forced
|
|
runners_advanced = self._advance_on_walk(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Walk",
|
|
ab_roll=ab_roll,
|
|
is_walk=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.SINGLE:
|
|
# Single - batter to first, runners advance 1-2 bases
|
|
runners_advanced = self._advance_on_single(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Single to left field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.DOUBLE:
|
|
runners_advanced = self._advance_on_double(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=2,
|
|
runners_advanced=runners_advanced,
|
|
description="Double to right-center",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.TRIPLE:
|
|
# All runners score
|
|
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
|
runs_scored = len(runners_advanced)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=3,
|
|
runners_advanced=runners_advanced,
|
|
description="Triple to right-center gap",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.HOMERUN:
|
|
# Everyone scores
|
|
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
|
runs_scored = len(runners_advanced) + 1
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=4,
|
|
runners_advanced=runners_advanced,
|
|
description="Home run to left field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.WILD_PITCH:
|
|
# Runners advance one base
|
|
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=None, # Batter stays at plate
|
|
runners_advanced=runners_advanced,
|
|
description="Wild pitch - runners advance",
|
|
ab_roll=ab_roll
|
|
)
|
|
|
|
elif outcome == PlayOutcome.PASSED_BALL:
|
|
# Runners advance one base
|
|
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=None, # Batter stays at plate
|
|
runners_advanced=runners_advanced,
|
|
description="Passed ball - runners advance",
|
|
ab_roll=ab_roll
|
|
)
|
|
|
|
else:
|
|
raise ValueError(f"Unhandled outcome: {outcome}")
|
|
|
|
def _advance_on_walk(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on walk"""
|
|
advances = []
|
|
|
|
# Only forced runners advance
|
|
if state.on_first:
|
|
# First occupied - check second
|
|
if state.on_second:
|
|
# Bases loaded scenario
|
|
if state.on_third:
|
|
# Bases loaded - force runner home
|
|
advances.append((3, 4))
|
|
advances.append((2, 3))
|
|
advances.append((1, 2))
|
|
|
|
return advances
|
|
|
|
def _advance_on_single(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on single (simplified)"""
|
|
advances = []
|
|
|
|
if state.on_third:
|
|
# Runner on third scores
|
|
advances.append((3, 4))
|
|
if state.on_second:
|
|
# Runner on second scores (simplified - usually would)
|
|
advances.append((2, 4))
|
|
if state.on_first:
|
|
# Runner on first to third (simplified)
|
|
advances.append((1, 3))
|
|
|
|
return advances
|
|
|
|
def _advance_on_double(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on double"""
|
|
advances = []
|
|
|
|
# All runners score on double (simplified)
|
|
for base, _ in state.get_all_runners():
|
|
advances.append((base, 4))
|
|
|
|
return advances
|
|
|
|
|
|
# Singleton instance
|
|
play_resolver = PlayResolver()
|