strat-gameplay-webapp/backend/app/core/game_engine.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

332 lines
11 KiB
Python

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