strat-gameplay-webapp/backend/app/core/play_resolver.py
Cal Corum 0d7ddbe408 CLAUDE: Implement GameEngine, PlayResolver, and GameValidator
Core Components:
 GameValidator (validators.py)
   - Validates game state and decisions
   - Rule enforcement for baseball gameplay
   - Game-over and inning continuation logic

 PlayResolver (play_resolver.py)
   - Resolves play outcomes using AbRoll system
   - Simplified result charts for MVP
   - Handles wild pitch/passed ball checks
   - Runner advancement logic for all hit types
   - PlayOutcome enum with 12 outcome types

 GameEngine (game_engine.py)
   - Orchestrates complete game flow
   - Start game, submit decisions, resolve plays
   - Integrates DiceSystem with roll context
   - Batch saves rolls at end of each half-inning
   - Persists plays and game state to database
   - Manages inning advancement and game completion

Integration Features:
- Uses advanced AbRoll system (not simplified d20)
- Roll context tracking per inning
- Batch persistence at inning boundaries
- Full audit trail with roll history
- State synchronization between memory and database

Architecture:
  GameEngine → PlayResolver → DiceSystem
       ↓             ↓
  GameValidator  StateManager
       ↓             ↓
   Database      In-Memory Cache

Ready For:
 End-to-end at-bat testing
 WebSocket integration
 Result chart configuration
 Advanced decision logic (Phase 3)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 10:00:21 -05:00

366 lines
12 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, RunnerState, 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
runs_scored = len(state.runners)
return PlayResult(
outcome=outcome,
outs_recorded=0,
runs_scored=runs_scored,
batter_result=3,
runners_advanced=[(r.on_base, 4) for r in state.runners],
description="Triple to right-center gap",
ab_roll=ab_roll,
is_hit=True
)
elif outcome == PlayOutcome.HOMERUN:
# Everyone scores
runs_scored = len(state.runners) + 1
return PlayResult(
outcome=outcome,
outs_recorded=0,
runs_scored=runs_scored,
batter_result=4,
runners_advanced=[(r.on_base, 4) for r in state.runners],
description="Home run to left field",
ab_roll=ab_roll,
is_hit=True
)
elif outcome == PlayOutcome.WILD_PITCH:
# Runners advance one base
runners_advanced = [(r.on_base, r.on_base + 1) for r in state.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 = [(r.on_base, r.on_base + 1) for r in state.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 any(r.on_base == 1 for r in state.runners):
# First occupied - check second
if any(r.on_base == 2 for r in state.runners):
# Bases loaded scenario
if any(r.on_base == 3 for r in state.runners):
# 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 = []
for runner in state.runners:
if runner.on_base == 3:
# Runner on third scores
advances.append((3, 4))
elif runner.on_base == 2:
# Runner on second scores (simplified - usually would)
advances.append((2, 4))
elif runner.on_base == 1:
# 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 = []
for runner in state.runners:
# All runners score on double (simplified)
advances.append((runner.on_base, 4))
return advances
# Singleton instance
play_resolver = PlayResolver()