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>
This commit is contained in:
parent
b0cc219bfe
commit
0d7ddbe408
331
backend/app/core/game_engine.py
Normal file
331
backend/app/core/game_engine.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""
|
||||
Game Engine - Main game orchestration engine.
|
||||
|
||||
Coordinates game flow, validates actions, resolves plays, and persists state.
|
||||
Integrates DiceSystem for roll tracking with context and batch saving.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-24
|
||||
"""
|
||||
import logging
|
||||
from uuid import UUID
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.state_manager import state_manager
|
||||
from app.core.play_resolver import play_resolver, PlayResult
|
||||
from app.core.validators import game_validator, ValidationError
|
||||
from app.core.dice import dice_system
|
||||
from app.database.operations import DatabaseOperations
|
||||
from app.models.game_models import (
|
||||
GameState, RunnerState, DefensiveDecision, OffensiveDecision
|
||||
)
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.GameEngine')
|
||||
|
||||
|
||||
class GameEngine:
|
||||
"""Main game orchestration engine"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_ops = DatabaseOperations()
|
||||
# Track rolls per inning for batch saving
|
||||
self._rolls_this_inning: dict[UUID, List] = {}
|
||||
|
||||
async def start_game(self, game_id: UUID) -> GameState:
|
||||
"""
|
||||
Start a game
|
||||
|
||||
Transitions from 'pending' to 'active'
|
||||
"""
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found in state manager")
|
||||
|
||||
if state.status != "pending":
|
||||
raise ValidationError(f"Game already started (status: {state.status})")
|
||||
|
||||
# Mark as active
|
||||
state.status = "active"
|
||||
state.inning = 1
|
||||
state.half = "top"
|
||||
state.outs = 0
|
||||
|
||||
# Update state
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Persist to DB
|
||||
await self.db_ops.update_game_state(
|
||||
game_id=game_id,
|
||||
inning=1,
|
||||
half="top",
|
||||
home_score=0,
|
||||
away_score=0,
|
||||
status="active"
|
||||
)
|
||||
|
||||
# Initialize roll tracking for this game
|
||||
self._rolls_this_inning[game_id] = []
|
||||
|
||||
logger.info(f"Started game {game_id}")
|
||||
return state
|
||||
|
||||
async def submit_defensive_decision(
|
||||
self,
|
||||
game_id: UUID,
|
||||
decision: DefensiveDecision
|
||||
) -> GameState:
|
||||
"""Submit defensive team decision"""
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
game_validator.validate_defensive_decision(decision, state)
|
||||
|
||||
# Store decision
|
||||
state.decisions_this_play['defensive'] = decision.model_dump()
|
||||
state.pending_decision = "offensive"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
logger.info(f"Defensive decision submitted for game {game_id}")
|
||||
|
||||
return state
|
||||
|
||||
async def submit_offensive_decision(
|
||||
self,
|
||||
game_id: UUID,
|
||||
decision: OffensiveDecision
|
||||
) -> GameState:
|
||||
"""Submit offensive team decision"""
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
game_validator.validate_offensive_decision(decision, state)
|
||||
|
||||
# Store decision
|
||||
state.decisions_this_play['offensive'] = decision.model_dump()
|
||||
state.pending_decision = "resolution"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
logger.info(f"Offensive decision submitted for game {game_id}")
|
||||
|
||||
return state
|
||||
|
||||
async def resolve_play(self, game_id: UUID) -> PlayResult:
|
||||
"""
|
||||
Resolve the current play with dice roll
|
||||
|
||||
This is the core game logic execution.
|
||||
Integrates roll context and tracks rolls for batch saving.
|
||||
"""
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
game_validator.validate_game_active(state)
|
||||
|
||||
# Get decisions
|
||||
defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {}))
|
||||
offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {}))
|
||||
|
||||
# Resolve play (this internally calls dice_system.roll_ab)
|
||||
result = play_resolver.resolve_play(state, defensive_decision, offensive_decision) # TODO: Ensure this loop supports "interruptive" plays such as jump checks and wild pitch checks
|
||||
|
||||
# Track roll with context for batch saving
|
||||
# The roll is already in dice_system history, but we track it here
|
||||
# with game context for batch persistence
|
||||
if game_id not in self._rolls_this_inning:
|
||||
self._rolls_this_inning[game_id] = []
|
||||
|
||||
self._rolls_this_inning[game_id].append(result.ab_roll)
|
||||
|
||||
# Apply result to state
|
||||
await self._apply_play_result(state, result, game_id)
|
||||
|
||||
# Clear decisions for next play
|
||||
state.decisions_this_play = {}
|
||||
state.pending_decision = "defensive"
|
||||
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
logger.info(f"Resolved play {state.play_count} for game {game_id}: {result.description}")
|
||||
return result
|
||||
|
||||
async def _apply_play_result(self, state: GameState, result: PlayResult, game_id: UUID) -> None:
|
||||
"""Apply play result to game state"""
|
||||
|
||||
# Update outs
|
||||
state.outs += result.outs_recorded
|
||||
|
||||
# Update runners
|
||||
new_runners = []
|
||||
|
||||
# Advance existing runners
|
||||
for runner in state.runners:
|
||||
advanced = False
|
||||
for from_base, to_base in result.runners_advanced:
|
||||
if runner.on_base == from_base:
|
||||
if to_base < 4: # Not scored
|
||||
runner.on_base = to_base
|
||||
new_runners.append(runner)
|
||||
advanced = True
|
||||
break
|
||||
|
||||
# Runner not in advancement list - stays put
|
||||
if not advanced:
|
||||
new_runners.append(runner)
|
||||
|
||||
# Add batter if reached base
|
||||
if result.batter_result and result.batter_result < 4:
|
||||
# TODO: Get actual batter lineup_id and card_id
|
||||
new_runners.append(RunnerState(
|
||||
lineup_id=0, # Placeholder
|
||||
card_id=0, # Placeholder
|
||||
on_base=result.batter_result
|
||||
))
|
||||
|
||||
state.runners = new_runners
|
||||
|
||||
# Update score
|
||||
if state.half == "top":
|
||||
state.away_score += result.runs_scored
|
||||
else:
|
||||
state.home_score += result.runs_scored
|
||||
|
||||
# Increment play count
|
||||
state.play_count += 1
|
||||
state.last_play_result = result.description
|
||||
|
||||
# Check if inning is over
|
||||
inning_ended = False
|
||||
if state.outs >= 3:
|
||||
await self._advance_inning(state, game_id)
|
||||
inning_ended = True
|
||||
|
||||
# Persist play to database
|
||||
await self._save_play_to_db(state, result)
|
||||
|
||||
# Update game state in DB
|
||||
await self.db_ops.update_game_state(
|
||||
game_id=state.game_id,
|
||||
inning=state.inning,
|
||||
half=state.half,
|
||||
home_score=state.home_score,
|
||||
away_score=state.away_score,
|
||||
status=state.status
|
||||
)
|
||||
|
||||
# If inning ended, batch save rolls
|
||||
if inning_ended:
|
||||
await self._batch_save_inning_rolls(game_id)
|
||||
|
||||
async def _advance_inning(self, state: GameState, game_id: UUID) -> None:
|
||||
"""Advance to next half inning"""
|
||||
if state.half == "top":
|
||||
state.half = "bottom"
|
||||
else:
|
||||
state.half = "top"
|
||||
state.inning += 1
|
||||
|
||||
state.outs = 0
|
||||
state.runners = []
|
||||
state.current_batter_idx = 0
|
||||
|
||||
logger.info(f"Advanced to inning {state.inning} {state.half}")
|
||||
|
||||
# Check if game is over
|
||||
if game_validator.is_game_over(state):
|
||||
state.status = "completed"
|
||||
logger.info(f"Game {state.game_id} completed - Final: Away {state.away_score}, Home {state.home_score}")
|
||||
|
||||
async def _batch_save_inning_rolls(self, game_id: UUID) -> None:
|
||||
"""
|
||||
Batch save all rolls from the inning
|
||||
|
||||
This is called at end of each half-inning to persist
|
||||
all dice rolls with their context to the database.
|
||||
"""
|
||||
if game_id not in self._rolls_this_inning:
|
||||
logger.debug(f"No rolls to save for game {game_id}")
|
||||
return
|
||||
|
||||
rolls = self._rolls_this_inning[game_id]
|
||||
if not rolls:
|
||||
logger.debug(f"Empty roll list for game {game_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.db_ops.save_rolls_batch(rolls)
|
||||
logger.info(f"Batch saved {len(rolls)} rolls for game {game_id}")
|
||||
|
||||
# Clear rolls for this inning
|
||||
self._rolls_this_inning[game_id] = []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to batch save rolls for game {game_id}: {e}")
|
||||
# Don't fail the game - rolls are still in dice_system history
|
||||
# We can recover them later if needed
|
||||
|
||||
async def _save_play_to_db(self, state: GameState, result: PlayResult) -> None:
|
||||
"""Save play to database"""
|
||||
play_data = {
|
||||
"game_id": state.game_id,
|
||||
"play_number": state.play_count,
|
||||
"inning": state.inning,
|
||||
"half": state.half,
|
||||
"outs_before": state.outs - result.outs_recorded,
|
||||
"outs_recorded": result.outs_recorded,
|
||||
"batter_id": 1, # TODO: Get from current batter
|
||||
"pitcher_id": 1, # TODO: Get from defensive lineup
|
||||
"catcher_id": 1, # TODO: Get from defensive lineup
|
||||
"dice_roll": str(result.ab_roll), # Store roll representation
|
||||
"hit_type": result.outcome.value,
|
||||
"result_description": result.description,
|
||||
"runs_scored": result.runs_scored,
|
||||
"away_score": state.away_score,
|
||||
"home_score": state.home_score,
|
||||
"complete": True,
|
||||
# Store full roll data for audit
|
||||
"defensive_choices": state.decisions_this_play.get('defensive', {}),
|
||||
"offensive_choices": state.decisions_this_play.get('offensive', {})
|
||||
}
|
||||
|
||||
await self.db_ops.save_play(play_data)
|
||||
|
||||
async def get_game_state(self, game_id: UUID) -> Optional[GameState]:
|
||||
"""Get current game state"""
|
||||
return state_manager.get_state(game_id)
|
||||
|
||||
async def end_game(self, game_id: UUID) -> GameState:
|
||||
"""
|
||||
Manually end a game
|
||||
|
||||
For forfeit, abandonment, etc.
|
||||
"""
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
raise ValueError(f"Game {game_id} not found")
|
||||
|
||||
# Batch save any remaining rolls
|
||||
await self._batch_save_inning_rolls(game_id)
|
||||
|
||||
state.status = "completed"
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
await self.db_ops.update_game_state(
|
||||
game_id=game_id,
|
||||
inning=state.inning,
|
||||
half=state.half,
|
||||
home_score=state.home_score,
|
||||
away_score=state.away_score,
|
||||
status="completed"
|
||||
)
|
||||
|
||||
logger.info(f"Game {game_id} ended manually")
|
||||
return state
|
||||
|
||||
|
||||
# Singleton instance
|
||||
game_engine = GameEngine()
|
||||
365
backend/app/core/play_resolver.py
Normal file
365
backend/app/core/play_resolver.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""
|
||||
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()
|
||||
109
backend/app/core/validators.py
Normal file
109
backend/app/core/validators.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""
|
||||
Rule Validators - Validate game actions and state transitions.
|
||||
|
||||
Ensures all game actions follow baseball rules and state is valid.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-24
|
||||
"""
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.GameValidator')
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when validation fails"""
|
||||
pass
|
||||
|
||||
|
||||
class GameValidator:
|
||||
"""Validates game actions and state"""
|
||||
|
||||
@staticmethod
|
||||
def validate_game_active(state: GameState) -> None:
|
||||
"""Ensure game is in active state"""
|
||||
if state.status != "active":
|
||||
raise ValidationError(f"Game is not active (status: {state.status})")
|
||||
|
||||
@staticmethod
|
||||
def validate_outs(outs: int) -> None:
|
||||
"""Ensure outs are valid"""
|
||||
if outs < 0 or outs > 2:
|
||||
raise ValidationError(f"Invalid outs: {outs} (must be 0-2)")
|
||||
|
||||
@staticmethod
|
||||
def validate_inning(inning: int, half: str) -> None:
|
||||
"""Ensure inning is valid"""
|
||||
if inning < 1:
|
||||
raise ValidationError(f"Invalid inning: {inning}")
|
||||
if half not in ["top", "bottom"]:
|
||||
raise ValidationError(f"Invalid half: {half}")
|
||||
|
||||
@staticmethod
|
||||
def validate_defensive_decision(decision: DefensiveDecision, state: GameState) -> None:
|
||||
"""Validate defensive team decision"""
|
||||
valid_alignments = ["normal", "shifted_left", "shifted_right"]
|
||||
if decision.alignment not in valid_alignments:
|
||||
raise ValidationError(f"Invalid alignment: {decision.alignment}")
|
||||
|
||||
valid_depths = ["in", "normal", "back", "double_play"] # TODO: update these to strat-specific values
|
||||
if decision.infield_depth not in valid_depths:
|
||||
raise ValidationError(f"Invalid infield depth: {decision.infield_depth}")
|
||||
|
||||
# Validate hold runners - can't hold empty bases
|
||||
runner_bases = [r.on_base for r in state.runners]
|
||||
for base in decision.hold_runners:
|
||||
if base not in runner_bases:
|
||||
raise ValidationError(f"Can't hold base {base} - no runner present")
|
||||
|
||||
logger.debug("Defensive decision validated")
|
||||
|
||||
@staticmethod
|
||||
def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None:
|
||||
"""Validate offensive team decision"""
|
||||
valid_approaches = ["normal", "contact", "power", "patient"] # TODO: update these to strat-specific values
|
||||
if decision.approach not in valid_approaches:
|
||||
raise ValidationError(f"Invalid approach: {decision.approach}")
|
||||
|
||||
# Validate steal attempts
|
||||
runner_bases = [r.on_base for r in state.runners]
|
||||
for base in decision.steal_attempts:
|
||||
# Must have runner on base-1 to steal base
|
||||
if (base - 1) not in runner_bases:
|
||||
raise ValidationError(f"Can't steal {base} - no runner on {base-1}")
|
||||
|
||||
# TODO: add check that base in front of stealing runner is unoccupied
|
||||
|
||||
# Can't bunt with 2 outs (simplified rule)
|
||||
if decision.bunt_attempt and state.outs == 2:
|
||||
raise ValidationError("Cannot bunt with 2 outs")
|
||||
|
||||
logger.debug("Offensive decision validated")
|
||||
|
||||
@staticmethod
|
||||
def can_continue_inning(state: GameState) -> bool:
|
||||
"""Check if inning can continue"""
|
||||
return state.outs < 3
|
||||
|
||||
@staticmethod
|
||||
def is_game_over(state: GameState) -> bool:
|
||||
"""Check if game is complete"""
|
||||
# Game over after 9 innings if score not tied
|
||||
if state.inning >= 9 and state.half == "bottom":
|
||||
if state.home_score != state.away_score:
|
||||
return True
|
||||
# Home team wins if ahead in bottom of 9th
|
||||
if state.home_score > state.away_score:
|
||||
return True
|
||||
# Also check if we're in extras and bottom team is ahead
|
||||
if state.inning > 9 and state.half == "bottom":
|
||||
if state.home_score > state.away_score:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
game_validator = GameValidator()
|
||||
Loading…
Reference in New Issue
Block a user