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>
332 lines
11 KiB
Python
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()
|