strat-gameplay-webapp/backend/app/core/play_resolver.py
Cal Corum 8ecce0f5ad CLAUDE: Implement forced outcome feature for terminal client testing
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>
2025-10-30 15:39:35 -05:00

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()