# Week 5: Game Logic & Play Resolution **Duration**: Week 5 of Phase 2 **Prerequisites**: Week 4 Complete (State Manager working) **Focus**: Build game engine core with dice system and play resolution **Status**: βœ… **COMPLETE** (2025-10-24) --- ## 🎯 Implementation Summary Week 5 has been **successfully completed** with enhancements beyond the original plan: ### βœ… Completed (Enhanced) - **Dice System**: Implemented with advanced `AbRoll` architecture (beyond simple d20) - `roll_types.py` module for structured roll modeling - Check rolls, resolution rolls, wild pitch/passed ball detection - Batch persistence at inning boundaries - **Play Resolver**: Working with simplified charts and wild pitch/passed ball outcomes - **Game Engine**: Fully functional with forward-looking snapshot pattern (refactored 2025-10-25) - **Validators**: Basic rule validation working - **Manual Test Script**: Comprehensive `test_game_flow.py` with 5 test scenarios ### βœ… Testing Complete (2025-10-25) - **Unit Tests**: `test_play_resolver.py` (18 tests) and `test_validators.py` (36 tests) created and passing - **Integration Tests**: `test_game_engine.py` (7 test classes) created with comprehensive coverage --- ## Overview (Original Plan) Implement the game simulation logic: cryptographic dice rolls, play resolution engine, game flow orchestration, and rule validation. ## Goals By end of Week 5: - βœ… Cryptographic dice system with d20 rolls - βœ… Play resolver for SBA (simplified charts) - βœ… Game engine coordinating turn flow - βœ… Rule validators for game actions - βœ… Complete ONE at-bat flow working end-to-end ## Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ GameEngine β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Validators β”‚β†’ β”‚ PlayResolverβ”‚β†’ β”‚ StateManager β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ↓ β”‚ β”‚ DiceSystem β”‚ β”‚ ↓ β”‚ β”‚ Cryptographic RNG β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Components to Build ### 1. Dice System (`backend/app/core/dice.py`) Cryptographically secure dice rolling with logging. ```python import logging import secrets from dataclasses import dataclass from typing import List import pendulum logger = logging.getLogger(f'{__name__}.DiceSystem') @dataclass class DiceRoll: """Result of a dice roll""" roll: int # The primary d20 roll modifiers: List[int] # Any modifier rolls total: int # roll + sum(modifiers) timestamp: pendulum.DateTime roll_id: str # Unique identifier for verification def __str__(self) -> str: if self.modifiers: mods = "+".join(str(m) for m in self.modifiers) return f"{self.roll}+{mods}={self.total}" return str(self.roll) class DiceSystem: """Cryptographically secure dice rolling system""" def __init__(self): self._roll_history: List[DiceRoll] = [] def roll_d20(self) -> DiceRoll: """Roll a single d20""" roll = secrets.randbelow(20) + 1 # 1-20 roll_result = DiceRoll( roll=roll, modifiers=[], total=roll, timestamp=pendulum.now('UTC'), roll_id=secrets.token_hex(8) ) self._roll_history.append(roll_result) logger.info(f"Rolled d20: {roll} (ID: {roll_result.roll_id})") return roll_result def roll_d6(self) -> int: """Roll a single d6 (for modifiers/checks)""" roll = secrets.randbelow(6) + 1 logger.debug(f"Rolled d6: {roll}") return roll def roll_with_modifier(self, modifier_dice: int = 0) -> DiceRoll: """ Roll d20 with additional modifier dice Args: modifier_dice: Number of d6 to add to roll """ base_roll = secrets.randbelow(20) + 1 modifiers = [self.roll_d6() for _ in range(modifier_dice)] total = base_roll + sum(modifiers) roll_result = DiceRoll( roll=base_roll, modifiers=modifiers, total=total, timestamp=pendulum.now('UTC'), roll_id=secrets.token_hex(8) ) self._roll_history.append(roll_result) logger.info(f"Rolled with modifiers: {roll_result}") return roll_result def get_roll_history(self, limit: int = 100) -> List[DiceRoll]: """Get recent roll history""" return self._roll_history[-limit:] def verify_roll(self, roll_id: str) -> bool: """Verify a roll ID exists in history""" return any(r.roll_id == roll_id for r in self._roll_history) def get_distribution_stats(self) -> dict: """Get distribution statistics for testing""" if not self._roll_history: return {} rolls = [r.roll for r in self._roll_history] distribution = {i: rolls.count(i) for i in range(1, 21)} return { "total_rolls": len(rolls), "distribution": distribution, "average": sum(rolls) / len(rolls), "min": min(rolls), "max": max(rolls) } # Singleton instance dice_system = DiceSystem() ``` **Implementation Steps:** 1. Create `backend/app/core/dice.py` 2. Implement DiceRoll dataclass 3. Implement DiceSystem with cryptographic RNG 4. Add roll history and verification 5. Write distribution tests **Tests:** - `tests/unit/core/test_dice.py` - Test basic d20 roll (in range 1-20) - Test roll history tracking - Test roll verification - Test distribution (run 1000+ rolls, verify roughly uniform) --- ### 2. Play Resolver (`backend/app/core/play_resolver.py`) Resolves play outcomes based on dice rolls and decisions. ```python import logging from dataclasses import dataclass from typing import Optional, List from enum import Enum from app.core.dice import DiceSystem, DiceRoll 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" @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 dice_roll: DiceRoll # 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. This placeholder provides basic outcomes for testing. """ @staticmethod def get_outcome(roll: int) -> PlayOutcome: """ Map d20 roll to outcome (simplified) Real chart will consider: - Batter card stats - Pitcher card stats - Defensive alignment - Offensive approach """ 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.dice = DiceSystem() 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 dice_roll = self.dice.roll_d20() logger.info(f"Dice roll: {dice_roll.roll}") # Get base outcome from chart outcome = self.result_chart.get_outcome(dice_roll.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, dice_roll) logger.info(f"Play result: {result.description}") return result def _resolve_outcome( self, outcome: PlayOutcome, state: GameState, dice_roll: DiceRoll ) -> 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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_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", dice_roll=dice_roll, is_hit=True ) 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() ``` **Implementation Steps:** 1. Create `backend/app/core/play_resolver.py` 2. Implement simplified result chart 3. Implement play resolution logic 4. Add runner advancement logic 5. Write unit tests **Tests:** - `tests/unit/core/test_play_resolver.py` - Test each outcome type - Test runner advancement logic - Test run scoring - Mock dice rolls for deterministic testing --- ### 3. Rule Validators (`backend/app/core/validators.py`) Validate game actions and state transitions. ```python import logging from typing import Optional from uuid import UUID from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision logger = logging.getLogger(f'{__name__}.Validators') 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"] 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"] 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}") # 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 return False # Singleton instance game_validator = GameValidator() ``` **Tests:** - `tests/unit/core/test_validators.py` - Test validation failures - Test edge cases --- ### 4. Game Engine (`backend/app/core/game_engine.py`) Orchestrates game flow and coordinates all components. ```python import logging from uuid import UUID from typing import Optional 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.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() 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 ) 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.dict() 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.dict() 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. """ 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 result = play_resolver.resolve_play(state, defensive_decision, offensive_decision) # Apply result to state await self._apply_play_result(state, result) # 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) -> 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: 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) break else: # Runner not in advancement list - stays put 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 if state.outs >= 3: await self._advance_inning(state) # 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 ) async def _advance_inning(self, state: GameState) -> 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") 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, # Placeholder "pitcher_id": 1, # Placeholder "catcher_id": 1, # Placeholder "dice_roll": str(result.dice_roll), "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 } await self.db_ops.save_play(play_data) # Singleton instance game_engine = GameEngine() ``` **Implementation Steps:** 1. Create `backend/app/core/game_engine.py` 2. Implement game start flow 3. Implement decision submission 4. Implement play resolution 5. Write integration tests **Tests:** - `tests/integration/test_game_engine.py` - Test complete at-bat flow - Test inning advancement - Test score tracking --- ## Week 5 Deliverables ### Code Files (Actual Implementation) - βœ… `backend/app/core/dice.py` - Dice system with batch persistence - βœ… `backend/app/core/roll_types.py` - **BONUS**: Structured roll modeling (AbRoll, CheckRoll, etc.) - βœ… `backend/app/core/play_resolver.py` - Play resolution with wild pitch/passed ball - βœ… `backend/app/core/validators.py` - Rule validation with lineup checks - βœ… `backend/app/core/game_engine.py` - Game orchestration with forward-looking snapshots ### Tests (Actual Status) - βœ… `tests/unit/core/test_dice.py` - Dice distribution tests **COMPLETE** (from initial implementation) - βœ… `tests/unit/core/test_roll_types.py` - **BONUS**: Roll type tests **COMPLETE** (from initial implementation) - βœ… `tests/unit/core/test_play_resolver.py` - **COMPLETE** (18 tests, created 2025-10-25) - βœ… `tests/unit/core/test_validators.py` - **COMPLETE** (36 tests, created 2025-10-25) - βœ… `tests/integration/test_game_engine.py` - **COMPLETE** (7 test classes, created 2025-10-25) - βœ… `scripts/test_game_flow.py` - **BONUS**: Manual test script **WORKING** (for manual validation) ### Test Script Create `scripts/test_game_flow.py` for manual testing: ```python """Test script to simulate a complete at-bat""" import asyncio from uuid import uuid4 from app.core.state_manager import state_manager from app.core.game_engine import game_engine from app.models.game_models import DefensiveDecision, OffensiveDecision async def test_at_bat(): """Simulate one complete at-bat""" # Create game game_id = uuid4() state = await state_manager.create_game( game_id=game_id, league_id="sba", home_team_id=1, away_team_id=2 ) print(f"Created game {game_id}") # Start game state = await game_engine.start_game(game_id) print(f"Game started - Inning {state.inning} {state.half}") # Defensive decision def_decision = DefensiveDecision(alignment="normal") await game_engine.submit_defensive_decision(game_id, def_decision) print("Defensive decision submitted") # Offensive decision off_decision = OffensiveDecision(approach="normal") await game_engine.submit_offensive_decision(game_id, off_decision) print("Offensive decision submitted") # Resolve play result = await game_engine.resolve_play(game_id) print(f"Play resolved: {result.description}") print(f"Dice: {result.dice_roll}") print(f"Outs: {result.outs_recorded}, Runs: {result.runs_scored}") # Check final state final_state = state_manager.get_state(game_id) print(f"\nFinal state:") print(f" Outs: {final_state.outs}") print(f" Score: Away {final_state.away_score} - Home {final_state.home_score}") print(f" Runners: {len(final_state.runners)}") if __name__ == "__main__": asyncio.run(test_at_bat()) ``` ## Success Criteria (Actual Results) - βœ… Dice system produces uniform distribution over 1000+ rolls (verified in tests) - βœ… One complete at-bat executes successfully (manual test passes + integration tests) - βœ… All state transitions validated (validators working + 36 validator tests) - βœ… Plays persist to database (with snapshots and roll batching) - βœ… **COMPLETE**: All tests pass (18 play_resolver + 36 validator + 7 integration test classes) - βœ… Play resolution completes in <200ms (fast in-memory operations) ### Test Coverage Summary - **Unit Tests**: 54 tests covering dice, roll types, play resolution, and validation - **Integration Tests**: 7 test classes covering complete game flows (requires database) - **Manual Test Script**: 5 comprehensive test scenarios for manual validation ## Enhancements Beyond Plan ### 1. Advanced Dice System (AbRoll) The implemented dice system is more sophisticated than planned: - **Structured Roll Types**: `AbRoll`, `CheckRoll`, `ResolutionRoll` dataclasses - **Context Tracking**: Each roll knows its game_id, inning, play_number - **Batch Persistence**: Rolls saved at inning boundaries instead of per-play - **Wild Pitch/Passed Ball**: Special roll detection on check_d20 == 1 or 2 ### 2. Forward-Looking Snapshot Pattern (Refactor 2025-10-25) The GameEngine uses a sophisticated snapshot pattern: - **Prepare Before Execute**: `_prepare_next_play()` sets snapshot fields before play resolution - **Independent Batting Orders**: `away_team_batter_idx` and `home_team_batter_idx` track separately - **Lineup Validation**: At game start and inning changes, defensive positions validated - **On-Base Code**: Bit field (1=1st, 2=2nd, 4=3rd) calculated from runners ### 3. Database-Driven Lineup Management Unlike the simple placeholder approach in the plan: - Lineups fetched from database via `get_active_lineup()` - Snapshot fields reference actual lineup IDs from database - Supports future substitution tracking ## Test Implementation Details (2025-10-25) ### test_play_resolver.py (18 tests) **Coverage**: - `TestSimplifiedResultChart` (12 tests): All outcome ranges (strikeout, groundout, flyout, walk, single, double, triple, homerun) + wild pitch/passed ball confirmation logic - `TestPlayResultResolution` (5 tests): Outcome resolution with runner advancement (walk, single, homerun, wild pitch scenarios) - `TestPlayResolverSingleton` (1 test): Singleton pattern validation **Key Insights**: - Tests use mock `AbRoll` objects with simplified constructor (no CheckRoll/ResolutionRoll sub-objects) - Wild pitch/passed ball confirmation tested (check_d20 triggers, resolution_d20 confirms) - Runner advancement logic validated for bases loaded, scoring from third, grand slams ### test_validators.py (36 tests) **Coverage**: - `TestGameStateValidation` (3 tests): Active/pending/completed state checks - `TestOutsValidation` (3 tests): Valid range (0-2), negative, too high - `TestInningValidation` (5 tests): Valid innings, zero/negative, invalid half values - `TestDefensiveDecisionValidation` (5 tests): Valid decisions, Pydantic validation of alignment/depth, hold runner logic - `TestOffensiveDecisionValidation` (6 tests): Valid decisions, Pydantic validation of approach, steal validation, bunt with 2 outs rule - `TestLineupValidation` (5 tests): Complete lineup, missing positions, duplicates, inactive players, DH optional - `TestGameFlowValidation` (8 tests): Inning continuation, game over conditions (9th inning, extras, tied games) - `TestGameValidatorSingleton` (1 test): Singleton pattern validation **Key Insights**: - Discovered that Pydantic validates at model creation, not assignment (unless `validate_assignment=True`) - Tests properly simulate `state.outs += 1` (goes to 3 temporarily) to match GameEngine flow - Confirmed lineup validation enforces exactly one active player per required position (P, C, 1B, 2B, 3B, SS, LF, CF, RF) ### test_game_engine.py (7 test classes) **Coverage**: - `TestSingleAtBat`: Complete at-bat flow (create β†’ start β†’ decisions β†’ resolve) - `TestFullInning`: Play until 3 outs, verify inning advancement - `TestLineupValidation`: Fail cases (no lineups, incomplete, missing positions) - `TestSnapshotTracking`: Verify snapshot fields populated, on_base_code calculation - `TestBattingOrderCycling`: Independent batting order per team, wraparound at 9 - `TestGameCompletion`: Game status changes to completed at end **Key Insights**: - All tests require database access (marked with `@pytest.mark.integration`) - Tests create full lineups (9 players per team) for realistic scenarios - Validates forward-looking snapshot pattern works end-to-end ## Week 5 Status: βœ… COMPLETE **Completion Date**: 2025-10-25 All deliverables achieved: - βœ… Code implementation (dice, play_resolver, validators, game_engine) - βœ… Unit tests (54 tests passing) - βœ… Integration tests (7 test classes) - βœ… Manual test script (5 scenarios) - βœ… Documentation updated ## Next Steps **Proceed to Week 6**: [Week 6: League Features & Integration](./02-week6-league-features.md) - League configuration system (SBA and PD configs) - Complete result charts (beyond simplified charts) - API client integration - End-to-end testing with real league data