Add ability to force specific play outcomes instead of random dice rolls, enabling targeted testing of specific game scenarios. Changes: - play_resolver.resolve_play(): Add forced_outcome parameter, bypass dice rolls when provided, create dummy AbRoll with placeholder values - game_engine.resolve_play(): Accept and pass through forced_outcome param - terminal_client/commands.py: Pass forced_outcome to game engine Testing: - Verified TRIPLE, HOMERUN, and STRIKEOUT outcomes work correctly - Dummy AbRoll properly constructed with all required fields - Game state updates correctly with forced outcomes Example usage in REPL: resolve_with triple resolve_with homerun Fixes terminal client testing workflow to allow controlled scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
531 lines
18 KiB
Python
531 lines
18 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
|
|
Updated: 2025-10-29 - Integrated universal PlayOutcome enum
|
|
"""
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Optional, List
|
|
import pendulum
|
|
|
|
from app.core.dice import dice_system
|
|
from app.core.roll_types import AbRoll, RollType
|
|
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision
|
|
from app.config import PlayOutcome
|
|
|
|
logger = logging.getLogger(f'{__name__}.PlayResolver')
|
|
|
|
|
|
@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 chaos_d20 value for outcome determination.
|
|
Checks for wild pitch/passed ball first.
|
|
"""
|
|
# Check for wild pitch/passed ball
|
|
if ab_roll.check_wild_pitch:
|
|
# chaos_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:
|
|
# chaos_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 chaos_d20
|
|
roll = ab_roll.chaos_d20
|
|
|
|
# Strikeouts
|
|
if roll <= 5:
|
|
return PlayOutcome.STRIKEOUT
|
|
|
|
# Groundballs - distribute across 3 variants
|
|
elif roll == 6:
|
|
return PlayOutcome.GROUNDBALL_A # DP opportunity
|
|
elif roll == 7:
|
|
return PlayOutcome.GROUNDBALL_B
|
|
elif roll == 8:
|
|
return PlayOutcome.GROUNDBALL_C
|
|
|
|
# Flyouts - distribute across 3 variants
|
|
elif roll == 9:
|
|
return PlayOutcome.FLYOUT_A
|
|
elif roll == 10:
|
|
return PlayOutcome.FLYOUT_B
|
|
elif roll == 11:
|
|
return PlayOutcome.FLYOUT_C
|
|
|
|
# Walks
|
|
elif roll in [12, 13]:
|
|
return PlayOutcome.WALK
|
|
|
|
# Singles - distribute between variants
|
|
elif roll == 14:
|
|
return PlayOutcome.SINGLE_1
|
|
elif roll == 15:
|
|
return PlayOutcome.SINGLE_2
|
|
|
|
# Doubles
|
|
elif roll == 16:
|
|
return PlayOutcome.DOUBLE_2
|
|
elif roll == 17:
|
|
return PlayOutcome.DOUBLE_3
|
|
|
|
# Lineout
|
|
elif roll == 18:
|
|
return PlayOutcome.LINEOUT
|
|
|
|
# Triple
|
|
elif roll == 19:
|
|
return PlayOutcome.TRIPLE
|
|
|
|
# Home run
|
|
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,
|
|
forced_outcome: Optional[PlayOutcome] = None
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve a complete play
|
|
|
|
Args:
|
|
state: Current game state
|
|
defensive_decision: Defensive team's choices
|
|
offensive_decision: Offensive team's choices
|
|
forced_outcome: If provided, use this outcome instead of rolling dice (for testing)
|
|
|
|
Returns:
|
|
PlayResult with complete outcome
|
|
"""
|
|
logger.info(f"Resolving play - Inning {state.inning} {state.half}, {state.outs} outs")
|
|
|
|
if forced_outcome:
|
|
# Use forced outcome for testing (no dice roll)
|
|
logger.info(f"Using forced outcome: {forced_outcome.value}")
|
|
outcome = forced_outcome
|
|
# Create a dummy AbRoll for the forced outcome
|
|
ab_roll = AbRoll(
|
|
roll_id=f"forced_{state.game_id}_{state.play_count}",
|
|
roll_type=RollType.AB,
|
|
league_id=state.league_id,
|
|
timestamp=pendulum.now('UTC'),
|
|
game_id=state.game_id,
|
|
d6_one=1, # Dummy values - not used for forced outcomes
|
|
d6_two_a=3,
|
|
d6_two_b=4,
|
|
chaos_d20=10,
|
|
resolution_d20=10
|
|
)
|
|
else:
|
|
# 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"""
|
|
|
|
# ==================== Strikeout ====================
|
|
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
|
|
)
|
|
|
|
# ==================== Groundballs ====================
|
|
elif outcome == PlayOutcome.GROUNDBALL_A:
|
|
# TODO Phase 3: Check for double play opportunity
|
|
# For now, treat as groundout
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Groundball to shortstop (DP opportunity)",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.GROUNDBALL_B:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Groundball to second base",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.GROUNDBALL_C:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Groundball to third base",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
# ==================== Flyouts ====================
|
|
elif outcome == PlayOutcome.FLYOUT_A:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Flyout to left field",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.FLYOUT_B:
|
|
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.FLYOUT_C:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Flyout to right field",
|
|
ab_roll=ab_roll,
|
|
is_out=True
|
|
)
|
|
|
|
# ==================== Lineout ====================
|
|
elif outcome == PlayOutcome.LINEOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Lineout",
|
|
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
|
|
)
|
|
|
|
# ==================== Singles ====================
|
|
elif outcome == PlayOutcome.SINGLE_1:
|
|
# Single with standard advancement
|
|
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.SINGLE_2:
|
|
# Single with enhanced advancement (more aggressive)
|
|
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 right field",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.SINGLE_UNCAPPED:
|
|
# TODO Phase 3: Implement uncapped hit decision tree
|
|
# For now, treat as SINGLE_1
|
|
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 center (uncapped)",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
# ==================== Doubles ====================
|
|
elif outcome == PlayOutcome.DOUBLE_2:
|
|
# Double to 2nd base
|
|
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 left-center",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.DOUBLE_3:
|
|
# Double with extra advancement (batter to 3rd)
|
|
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=3, # Batter goes to 3rd
|
|
runners_advanced=runners_advanced,
|
|
description="Double to right-center gap (batter to 3rd)",
|
|
ab_roll=ab_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.DOUBLE_UNCAPPED:
|
|
# TODO Phase 3: Implement uncapped hit decision tree
|
|
# For now, treat as DOUBLE_2
|
|
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 (uncapped)",
|
|
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()
|