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>
366 lines
12 KiB
Python
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()
|