# Core - Game Engine & Logic ## Purpose The `core` directory contains the heart of the baseball simulation engine. It orchestrates complete gameplay flow from dice rolls to play resolution, managing in-memory game state for fast real-time performance (<500ms response target). **Key Responsibilities**: - In-memory state management (StateManager) - Game orchestration and workflow (GameEngine) - Cryptographic dice rolling (DiceSystem) - Play outcome resolution (PlayResolver) - Runner advancement logic (RunnerAdvancement) - Rule validation (GameValidator) - AI opponent decision-making (AIOpponent) ## Architecture Overview ### Data Flow ``` Player Action (WebSocket) ↓ GameEngine.submit_defensive_decision() GameEngine.submit_offensive_decision() ↓ GameEngine.resolve_play() ↓ DiceSystem.roll_ab() → AbRoll (1d6 + 2d6 + 2d20) ↓ PlayResolver.resolve_outcome() ├─ Manual mode: Use player-submitted outcome └─ Auto mode: Generate from PdPlayer ratings ↓ RunnerAdvancement.advance_runners() (for groundballs) ├─ Determine result (1-13) based on situation ├─ Execute result (movements, outs, runs) └─ Return AdvancementResult ↓ GameEngine._apply_play_result() → Update state ↓ GameEngine._save_play_to_db() → Persist play ↓ StateManager.update_state() → Cache updated state ↓ WebSocket broadcast → All clients ``` ### Component Relationships ``` StateManager (singleton) ├─ Stores: Dict[UUID, GameState] ├─ Stores: Dict[UUID, Dict[int, TeamLineupState]] └─ Provides: O(1) lookups, recovery from DB GameEngine (singleton) ├─ Uses: StateManager for state ├─ Uses: PlayResolver for outcomes ├─ Uses: RunnerAdvancement (via PlayResolver) ├─ Uses: DiceSystem for rolls ├─ Uses: GameValidator for rules ├─ Uses: AIOpponent for AI decisions └─ Uses: DatabaseOperations for persistence PlayResolver ├─ Uses: DiceSystem for rolls ├─ Uses: RunnerAdvancement for groundballs ├─ Uses: PdAutoResultChart (auto mode only) └─ Returns: PlayResult RunnerAdvancement ├─ Uses: GameState for context ├─ Uses: DefensiveDecision for positioning └─ Returns: AdvancementResult with movements DiceSystem (singleton) ├─ Uses: secrets module (cryptographic) ├─ Maintains: roll_history for auditing └─ Returns: Typed roll objects (AbRoll, JumpRoll, etc.) ``` ## Core Modules ### 1. state_manager.py **Purpose**: In-memory game state management with O(1) lookups **Key Classes**: - `StateManager`: Singleton managing all active game states **Storage**: ```python _states: Dict[UUID, GameState] # O(1) state lookup _lineups: Dict[UUID, Dict[int, TeamLineupState]] # Cached lineups _last_access: Dict[UUID, pendulum.DateTime] # For eviction _pending_decisions: Dict[tuple, asyncio.Future] # Phase 3 async decisions ``` **Common Methods**: ```python # Create new game state = await state_manager.create_game(game_id, league_id, home_id, away_id) # Get state (fast O(1)) state = state_manager.get_state(game_id) # Update state state_manager.update_state(game_id, state) # Lineup caching state_manager.set_lineup(game_id, team_id, lineup_state) lineup = state_manager.get_lineup(game_id, team_id) # Recovery from database state = await state_manager.recover_game(game_id) # Memory management evicted_count = state_manager.evict_idle_games(idle_minutes=60) ``` **Performance**: - State access: O(1) dictionary lookup - Memory per game: ~1-2KB (without player data) - Evicts idle games after configurable timeout **Recovery Strategy**: - Uses last completed play to rebuild runner positions - Extracts on_first_final, on_second_final, on_third_final, batter_final - Reconstructs batter indices (corrected by _prepare_next_play) - No play replay needed (efficient recovery) --- ### 2. game_engine.py **Purpose**: Main orchestrator coordinating complete gameplay workflow **Key Classes**: - `GameEngine`: Singleton handling game lifecycle and play resolution **Core Workflow** (resolve_play): ```python # STEP 1: Resolve play with dice rolls result = resolver.resolve_outcome(outcome, hit_location, state, decisions, ab_roll) # STEP 2: Save play to DB (uses snapshot from GameState) await self._save_play_to_db(state, result) # STEP 3: Apply result to state (outs, score, runners) self._apply_play_result(state, result) # STEP 4: Update game state in DB (only if changed) if state_changed: await self.db_ops.update_game_state(...) # STEP 5: Check for inning change if state.outs >= 3: await self._advance_inning(state, game_id) # STEP 6: Prepare next play (always last step) await self._prepare_next_play(state) ``` **Key Methods**: ```python # Game lifecycle await game_engine.start_game(game_id) await game_engine.end_game(game_id) # Decision submission await game_engine.submit_defensive_decision(game_id, decision) await game_engine.submit_offensive_decision(game_id, decision) # Play resolution result = await game_engine.resolve_play(game_id, forced_outcome=None) result = await game_engine.resolve_manual_play(game_id, ab_roll, outcome, hit_location) # Phase 3: Async decision awaiting defensive_dec = await game_engine.await_defensive_decision(state, timeout=30) offensive_dec = await game_engine.await_offensive_decision(state, timeout=30) # State management state = await game_engine.get_game_state(game_id) state = await game_engine.rollback_plays(game_id, num_plays) ``` **_prepare_next_play()**: Critical method that: 1. Advances batting order index (with wraparound) 2. Fetches/caches active lineups 3. Sets snapshot fields: current_batter/pitcher/catcher_lineup_id 4. Calculates on_base_code bit field 5. Used by _save_play_to_db() for Play record **Optimization Notes**: - Lineup caching eliminates redundant DB queries - Conditional state updates (only when score/inning/status changes) - Batch roll saving at inning boundaries - 60% query reduction vs naive implementation --- ### 3. play_resolver.py **Purpose**: Resolves play outcomes based on dice rolls and game state **Architecture**: Outcome-first design - **Manual mode** (primary): Players submit outcomes after reading physical cards - **Auto mode** (rare): System generates outcomes from digitized ratings (PD only) **Key Classes**: - `PlayResolver`: Core resolution logic - `PlayResult`: Complete outcome with statistics **Initialization**: ```python # Manual mode (SBA + PD manual) resolver = PlayResolver(league_id='sba', auto_mode=False) # Auto mode (PD only, rare) resolver = PlayResolver(league_id='pd', auto_mode=True) ``` **Resolution Methods**: ```python # MANUAL: Player submits outcome from physical card result = resolver.resolve_manual_play( submission=ManualOutcomeSubmission(outcome='single_1', hit_location='CF'), state=state, defensive_decision=def_dec, offensive_decision=off_dec, ab_roll=ab_roll ) # AUTO: System generates outcome (PD only) result = resolver.resolve_auto_play( state=state, batter=pd_player, pitcher=pd_pitcher, defensive_decision=def_dec, offensive_decision=off_dec ) # CORE: All resolution logic lives here result = resolver.resolve_outcome( outcome=PlayOutcome.GROUNDBALL_A, hit_location='SS', state=state, defensive_decision=def_dec, offensive_decision=off_dec, ab_roll=ab_roll ) ``` **PlayResult Structure**: ```python @dataclass class PlayResult: outcome: PlayOutcome outs_recorded: int runs_scored: int batter_result: Optional[int] # None=out, 1-4=base runners_advanced: List[tuple[int, int]] # [(from, to), ...] description: str ab_roll: AbRoll hit_location: Optional[str] # Statistics is_hit: bool is_out: bool is_walk: bool ``` **Runner Advancement Integration**: - Delegates all groundball outcomes to RunnerAdvancement - Delegates all flyball outcomes to RunnerAdvancement - Converts AdvancementResult to PlayResult format - Extracts batter movement from RunnerMovement list **Supported Outcomes**: - Strikeouts - Groundballs (A, B, C) → delegates to RunnerAdvancement - Flyouts (A, B, BQ, C) → delegates to RunnerAdvancement - Lineouts - Walks - Singles (1, 2, uncapped) - Doubles (2, 3, uncapped) - Triples - Home runs - Wild pitch / Passed ball --- ### 4. runner_advancement.py **Purpose**: Implements complete runner advancement system for groundballs and flyballs **Key Classes**: - `RunnerAdvancement`: Main logic handler (handles both groundballs and flyballs) - `GroundballResultType`: Enum of 13 result types (matches rulebook charts) - `RunnerMovement`: Single runner's movement - `AdvancementResult`: Complete result with all movements **Groundball Result Types** (1-13): ```python 1: BATTER_OUT_RUNNERS_HOLD 2: DOUBLE_PLAY_AT_SECOND 3: BATTER_OUT_RUNNERS_ADVANCE 4: BATTER_SAFE_FORCE_OUT_AT_SECOND 5: CONDITIONAL_ON_MIDDLE_INFIELD 6: CONDITIONAL_ON_RIGHT_SIDE 7: BATTER_OUT_FORCED_ONLY 8: BATTER_OUT_FORCED_ONLY_ALT 9: LEAD_HOLDS_TRAIL_ADVANCES 10: DOUBLE_PLAY_HOME_TO_FIRST 11: BATTER_SAFE_LEAD_OUT 12: DECIDE_OPPORTUNITY 13: CONDITIONAL_DOUBLE_PLAY ``` **Usage**: ```python runner_advancement = RunnerAdvancement() result = runner_advancement.advance_runners( outcome=PlayOutcome.GROUNDBALL_A, hit_location='SS', state=state, defensive_decision=defensive_decision ) # Result contains: result.movements # List[RunnerMovement] result.outs_recorded # int result.runs_scored # int result.result_type # GroundballResultType result.description # str ``` **Chart Selection Logic**: ``` Special case: 2 outs → Result 1 (batter out, runners hold) Infield In (runner on 3rd scenarios): - Applies when: defensive_decision.infield_depth == "infield_in" - Affects bases: 3, 5, 6, 7 (any scenario with runner on 3rd) - Uses: _apply_infield_in_chart() Corners In (hybrid approach): - Applies when: defensive_decision.infield_depth == "corners_in" - Only for corner hits: P, C, 1B, 3B - Middle infield (2B, SS) uses Infield Back Infield Back (default): - Normal defensive positioning - Uses: _apply_infield_back_chart() ``` **Double Play Mechanics**: - Base probability: 45% - Positioning modifiers: infield_in -15% - Hit location modifiers: up middle +10%, corners -10% - TODO: Runner speed modifiers when ratings available **DECIDE Opportunity** (Result 12): - Lead runner can attempt to advance - Offense chooses whether to attempt - Defense responds (take sure out at 1st OR throw to lead runner) - Currently simplified (conservative default) - TODO: Interactive decision-making via WebSocket --- **Flyball Types** (Direct Mapping): Flyballs use direct outcome-to-behavior mapping (no chart needed): | Outcome | Depth | R3 | R2 | R1 | Notes | |---------|-------|----|----|-----|-------| | **FLYOUT_A** | Deep | Advances (scores) | Advances to 3rd | Advances to 2nd | All runners tag up | | **FLYOUT_B** | Medium | **Scores** | DECIDE (defaults hold) | Holds | Sac fly + DECIDE | | **FLYOUT_BQ** | Medium-shallow | DECIDE (defaults hold) | Holds | Holds | fly(b)? from cards | | **FLYOUT_C** | Shallow | Holds | Holds | Holds | Too shallow to tag | **Usage**: ```python runner_advancement = RunnerAdvancement() # Flyball advancement (same interface as groundballs) result = runner_advancement.advance_runners( outcome=PlayOutcome.FLYOUT_B, hit_location='RF', # LF, CF, or RF state=state, defensive_decision=defensive_decision ) # Result contains: result.movements # List[RunnerMovement] result.outs_recorded # Always 1 for flyouts result.runs_scored # 0-3 depending on runners result.result_type # None (flyballs don't use result types) result.description # e.g., "Medium flyball to RF - R3 scores, R2 DECIDE (held), R1 holds" ``` **Key Differences from Groundballs**: 1. **No Chart Lookup**: Direct mapping from outcome to behavior 2. **No Result Type**: `result_type` is `None` for flyballs (groundballs use 1-13) 3. **DECIDE Mechanics**: - FLYOUT_B: R2 may attempt to tag to 3rd (currently defaults to hold) - FLYOUT_BQ: R3 may attempt to score (currently defaults to hold) - TODO: Interactive DECIDE with probability calculations (arm strength, runner speed) 4. **No-op Movements**: Hold movements are recorded for state recovery (same as groundballs) **Special Cases**: - **2 outs**: No runner advancement recorded (inning ends) - **Empty bases**: Only batter movement (out at plate) - **Hit location**: Used in description and future DECIDE probability calculations **Test Coverage**: - 21 flyball tests in `tests/unit/core/test_flyball_advancement.py` - Coverage: All 4 types, DECIDE scenarios, no-op movements, edge cases --- **X-Check Placeholder Functions** (Phase 3B - 2025-11-01): X-Check resolution functions for defensive plays triggered by dice rolls. Currently placeholders awaiting Phase 3C implementation. **Functions**: - `x_check_g1(on_base_code, defender_in, error_result)` → AdvancementResult - `x_check_g2(on_base_code, defender_in, error_result)` → AdvancementResult - `x_check_g3(on_base_code, defender_in, error_result)` → AdvancementResult - `x_check_f1(on_base_code, error_result)` → AdvancementResult - `x_check_f2(on_base_code, error_result)` → AdvancementResult - `x_check_f3(on_base_code, error_result)` → AdvancementResult **Arguments**: - `on_base_code`: Current base situation (0-7 bit field: 1=R1, 2=R2, 4=R3) - `defender_in`: Boolean indicating if defender is playing in - `error_result`: Error type from 3d6 roll ('NO', 'E1', 'E2', 'E3', 'RP') **Current Implementation**: All functions return placeholder `AdvancementResult` with empty movements. Will be implemented in Phase 3C using X-Check tables from `app.config.common_x_check_tables`. **Usage** (Future): ```python from app.core.runner_advancement import x_check_g1 result = x_check_g1( on_base_code=5, # R1 and R3 defender_in=False, error_result='NO' ) # Will return complete AdvancementResult with runner movements ``` **Test Coverage**: 9 tests in `tests/unit/core/test_runner_advancement.py::TestXCheckPlaceholders` --- ### 5. dice.py **Purpose**: Cryptographically secure dice rolling system **Key Classes**: - `DiceSystem`: Singleton dice roller **Roll Types**: - `AbRoll`: At-bat (1d6 + 2d6 + 2d20) - `JumpRoll`: Stolen base attempt - `FieldingRoll`: Defensive play (1d20 + 3d6 + 1d100) - `D20Roll`: Generic d20 **Usage**: ```python from app.core.dice import dice_system # At-bat roll ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id) # Returns: AbRoll with d6_one, d6_two_a, d6_two_b, chaos_d20, resolution_d20 # Jump roll (stolen base) jump_roll = dice_system.roll_jump(league_id='sba', game_id=game_id) # Fielding roll fielding_roll = dice_system.roll_fielding(position='SS', league_id='sba', game_id=game_id) # Generic d20 d20_roll = dice_system.roll_d20(league_id='sba', game_id=game_id) ``` **Roll History**: ```python # Get recent rolls rolls = dice_system.get_roll_history(roll_type=RollType.AB, game_id=game_id, limit=10) # Get rolls since timestamp (for batch saving) rolls = dice_system.get_rolls_since(game_id, since_timestamp) # Verify roll authenticity is_valid = dice_system.verify_roll(roll_id) # Statistics stats = dice_system.get_distribution_stats(roll_type=RollType.AB) ``` **Security**: - Uses Python's `secrets` module (cryptographically secure) - Unique roll_id for each roll (16 char hex) - Complete audit trail in roll history - Cannot be predicted or manipulated --- ### 6. validators.py **Purpose**: Enforce baseball rules and validate game actions **Key Classes**: - `GameValidator`: Static validation methods - `ValidationError`: Custom exception for rule violations **Common Validations**: ```python from app.core.validators import game_validator, ValidationError # Game status game_validator.validate_game_active(state) # Defensive decision game_validator.validate_defensive_decision(decision, state) # Checks: # - Valid depth choices # - Can't hold runner on empty base # - Infield in/corners in requires runner on 3rd # Offensive decision game_validator.validate_offensive_decision(decision, state) # Checks: # - Valid approach # - Can't steal from empty base # - Can't bunt with 2 outs # - Hit-and-run requires runner on base # Defensive lineup game_validator.validate_defensive_lineup_positions(lineup) # Checks: # - Exactly 1 active player per position (P, C, 1B, 2B, 3B, SS, LF, CF, RF) # - Called at game start and start of each half-inning # Game state can_continue = game_validator.can_continue_inning(state) # outs < 3 is_over = game_validator.is_game_over(state) # inning >= 9 and score not tied ``` --- ### 7. ai_opponent.py **Purpose**: AI decision-making for AI-controlled teams **Status**: Week 7 stub implementation (full AI in Week 9) **Key Classes**: - `AIOpponent`: Decision generator **Current Implementation**: ```python from app.core.ai_opponent import ai_opponent # Generate defensive decision decision = await ai_opponent.generate_defensive_decision(state) # Returns: DefensiveDecision with default "normal" settings # Generate offensive decision decision = await ai_opponent.generate_offensive_decision(state) # Returns: OffensiveDecision with default "normal" approach ``` **Difficulty Levels**: - `"balanced"`: Standard decision-making (current default) - `"yolo"`: Aggressive playstyle (more risks) - TODO Week 9 - `"safe"`: Conservative playstyle (fewer risks) - TODO Week 9 **TODO Week 9**: - Analyze batter tendencies for defensive positioning - Consider runner speed for hold decisions - Evaluate double play opportunities - Implement stealing logic based on runner speed, pitcher hold, catcher arm - Implement bunting decisions - Adjust strategies based on game situation (score, inning, outs) --- ### 8. roll_types.py **Purpose**: Type-safe dice roll data structures **Key Classes**: - `RollType`: Enum of roll types (AB, JUMP, FIELDING, D20) - `DiceRoll`: Base class with auditing fields - `AbRoll`: At-bat roll (1d6 + 2d6 + 2d20) - `JumpRoll`: Baserunning roll - `FieldingRoll`: Defensive play roll - `D20Roll`: Generic d20 roll **AbRoll Structure**: ```python @dataclass(kw_only=True) class AbRoll(DiceRoll): # Required dice d6_one: int # 1-6 d6_two_a: int # 1-6 d6_two_b: int # 1-6 chaos_d20: int # 1-20 (1=WP, 2=PB, 3+=normal) resolution_d20: int # 1-20 (for WP/PB resolution or split results) # Derived values d6_two_total: int # Sum of 2d6 check_wild_pitch: bool # chaos_d20 == 1 check_passed_ball: bool # chaos_d20 == 2 ``` **Roll Flow**: ``` 1. Roll chaos_d20 first 2. If chaos_d20 == 1: Check wild pitch (use resolution_d20) 3. If chaos_d20 == 2: Check passed ball (use resolution_d20) 4. If chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits) ``` **Auditing Fields** (on all rolls): ```python roll_id: str # Unique cryptographic ID roll_type: RollType # AB, JUMP, FIELDING, D20 league_id: str # 'sba' or 'pd' timestamp: pendulum.DateTime game_id: Optional[UUID] team_id: Optional[int] player_id: Optional[int] # Polymorphic: player_id (SBA) or card_id (PD) context: Optional[Dict] # Additional metadata (JSONB) ``` ## Common Patterns ### 1. Async Database Operations All database operations use async/await and don't block game logic: ```python # Write operations are fire-and-forget await self.db_ops.save_play(play_data) await self.db_ops.update_game_state(...) # But still use try/except for error handling try: await self.db_ops.save_play(play_data) except Exception as e: logger.error(f"Failed to save play: {e}") # Game continues - play is in memory ``` ### 2. State Snapshot Pattern GameEngine prepares a snapshot BEFORE each play for database persistence: ```python # BEFORE play await self._prepare_next_play(state) # Sets snapshot fields # Snapshot fields used by _save_play_to_db(): state.current_batter_lineup_id # Who's batting state.current_pitcher_lineup_id # Who's pitching state.current_catcher_lineup_id # Who's catching state.current_on_base_code # Bit field (1=1st, 2=2nd, 4=3rd, 7=loaded) # AFTER play result = await self.resolve_play(...) await self._save_play_to_db(state, result) # Uses snapshot ``` ### 3. Orchestration Sequence GameEngine always follows this sequence: ```python # STEP 1: Resolve play result = resolver.resolve_outcome(...) # STEP 2: Save play to DB (uses snapshot) await self._save_play_to_db(state, result) # STEP 3: Apply result to state self._apply_play_result(state, result) # STEP 4: Update game state in DB (conditional) if state_changed: await self.db_ops.update_game_state(...) # STEP 5: Check for inning change if state.outs >= 3: await self._advance_inning(state, game_id) # Batch save rolls at half-inning boundary await self._batch_save_inning_rolls(game_id) # STEP 6: Prepare next play (always last) await self._prepare_next_play(state) ``` ### 4. Lineup Caching StateManager caches lineups to avoid redundant DB queries: ```python # First access - fetches from DB lineup = await db_ops.get_active_lineup(game_id, team_id) lineup_state = TeamLineupState(team_id=team_id, players=[...]) state_manager.set_lineup(game_id, team_id, lineup_state) # Subsequent accesses - uses cache lineup_state = state_manager.get_lineup(game_id, team_id) # Cache persists for entire game ``` ### 5. Error Handling Pattern Core modules use "Raise or Return" pattern: ```python # ✅ DO: Raise exceptions for invalid states if game_id not in self._states: raise ValueError(f"Game {game_id} not found") # ✅ DO: Return None only when semantically valid def get_state(self, game_id: UUID) -> Optional[GameState]: return self._states.get(game_id) # ❌ DON'T: Return Optional[T] unless specifically required def process_action(...) -> Optional[Result]: # Avoid this pattern ``` ## Integration Points ### With Database Layer ```python from app.database.operations import DatabaseOperations db_ops = DatabaseOperations() # Game CRUD await db_ops.create_game(game_id, league_id, home_id, away_id, ...) await db_ops.update_game_state(game_id, inning, half, home_score, away_score, status) # Play persistence await db_ops.save_play(play_data) plays = await db_ops.get_plays(game_id, limit=10) # Lineup management lineup_id = await db_ops.create_lineup_entry(game_id, team_id, card_id, position, ...) lineup = await db_ops.get_active_lineup(game_id, team_id) # State recovery game_data = await db_ops.load_game_state(game_id) # Batch operations await db_ops.save_rolls_batch(rolls) # Rollback deleted = await db_ops.delete_plays_after(game_id, play_number) deleted_subs = await db_ops.delete_substitutions_after(game_id, play_number) ``` ### With Models ```python from app.models.game_models import ( GameState, DefensiveDecision, OffensiveDecision, LineupPlayerState, TeamLineupState, ManualOutcomeSubmission ) # Game state state = GameState( game_id=game_id, league_id='sba', home_team_id=1, away_team_id=2 ) # Strategic decisions def_decision = DefensiveDecision( alignment='shifted_left', infield_depth='normal', outfield_depth='normal', hold_runners=[3] ) off_decision = OffensiveDecision( approach='power', steal_attempts=[2], hit_and_run=False, bunt_attempt=False ) # Manual outcome submission submission = ManualOutcomeSubmission( outcome='groundball_a', hit_location='SS' ) ``` ### With Config ```python from app.config import PlayOutcome, get_league_config # Get league configuration config = get_league_config(state.league_id) # Check league capabilities supports_auto = config.supports_auto_mode() # PD: True, SBA: False # Play outcomes outcome = PlayOutcome.GROUNDBALL_A # Outcome helpers if outcome.is_hit(): print("Hit!") if outcome.is_uncapped(): # Trigger advancement decision tree pass if outcome.requires_hit_location(): # Must specify where ball was hit pass ``` ### With WebSocket (Future) ```python # TODO Week 7-8: WebSocket integration # Emit state updates await connection_manager.broadcast_to_game( game_id, 'game_state_update', state_data ) # Request decisions await connection_manager.emit_decision_required( game_id=game_id, team_id=team_id, decision_type='defensive', timeout=30, game_situation=state.to_situation_summary() ) # Broadcast play result await connection_manager.broadcast_play_result( game_id, result_data ) ``` ## Common Tasks ### Starting a New Game ```python from app.core.game_engine import game_engine from app.core.state_manager import state_manager # 1. Create game in database game_id = uuid4() await db_ops.create_game( game_id=game_id, league_id='sba', home_team_id=1, away_team_id=2, game_mode='friendly', visibility='public' ) # 2. Create state in memory state = await state_manager.create_game( game_id=game_id, league_id='sba', home_team_id=1, away_team_id=2 ) # 3. Set up lineups (must have 9+ players, all positions filled) # ... create lineup entries in database ... # 4. Start game (transitions from 'pending' to 'active') state = await game_engine.start_game(game_id) ``` ### Resolving a Play (Manual Mode) ```python # 1. Get decisions await game_engine.submit_defensive_decision(game_id, defensive_decision) await game_engine.submit_offensive_decision(game_id, offensive_decision) # 2. Player submits outcome from physical card submission = ManualOutcomeSubmission( outcome='groundball_a', hit_location='SS' ) # 3. Server rolls dice for audit trail ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id) # 4. Resolve play result = await game_engine.resolve_manual_play( game_id=game_id, ab_roll=ab_roll, outcome=PlayOutcome(submission.outcome), hit_location=submission.hit_location ) # Result is automatically: # - Saved to database # - Applied to game state # - Prepared for next play ``` ### Resolving a Play (Auto Mode - PD Only) ```python # 1. Get decisions (same as manual) # 2. Fetch player data with ratings batter = await api_client.get_pd_player(batter_id, include_ratings=True) pitcher = await api_client.get_pd_player(pitcher_id, include_ratings=True) # 3. Create resolver in auto mode resolver = PlayResolver(league_id='pd', auto_mode=True) # 4. Auto-generate outcome from ratings result = resolver.resolve_auto_play( state=state, batter=batter, pitcher=pitcher, defensive_decision=defensive_decision, offensive_decision=offensive_decision ) # 5. Apply to game engine (same as manual) await game_engine.resolve_play(game_id) ``` ### Adding a New PlayOutcome ```python # 1. Add to PlayOutcome enum (app/config/result_charts.py) class PlayOutcome(str, Enum): NEW_OUTCOME = "new_outcome" # 2. Add helper method if needed def is_new_category(self) -> bool: return self in [PlayOutcome.NEW_OUTCOME] # 3. Add resolution logic (app/core/play_resolver.py) def resolve_outcome(self, outcome, ...): # ... existing outcomes ... elif outcome == PlayOutcome.NEW_OUTCOME: # Calculate movements runners_advanced = self._advance_on_new_outcome(state) runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( outcome=outcome, outs_recorded=..., runs_scored=runs_scored, batter_result=..., runners_advanced=runners_advanced, description="...", ab_roll=ab_roll, is_hit=True/False ) # 4. Add advancement helper method def _advance_on_new_outcome(self, state: GameState) -> List[tuple[int, int]]: advances = [] # ... calculate runner movements ... return advances # 5. Add unit tests (tests/unit/core/test_play_resolver.py) ``` ### Recovering a Game After Restart ```python # 1. Load game from database state = await state_manager.recover_game(game_id) # 2. State is automatically: # - Rebuilt from last completed play # - Runner positions recovered # - Batter indices set # - Cached in memory # 3. Game ready to continue # (Next call to _prepare_next_play will correct batter index if needed) ``` ### Rolling Back Plays ```python # Roll back last 3 plays state = await game_engine.rollback_plays(game_id, num_plays=3) # This automatically: # - Deletes plays from database # - Deletes related substitutions # - Recovers game state by replaying remaining plays # - Updates in-memory state ``` ### Debugging Game State ```python # Get current state state = state_manager.get_state(game_id) # Print state details print(f"Inning {state.inning} {state.half}") print(f"Score: {state.away_score} - {state.home_score}") print(f"Outs: {state.outs}") print(f"Runners: {state.get_all_runners()}") print(f"Play count: {state.play_count}") # Check decisions print(f"Pending: {state.pending_decision}") print(f"Decisions: {state.decisions_this_play}") # Check batter print(f"Batter lineup_id: {state.current_batter_lineup_id}") # Get statistics stats = state_manager.get_stats() print(f"Active games: {stats['active_games']}") print(f"By league: {stats['games_by_league']}") ``` ## Troubleshooting ### Issue: Game state not found **Symptom**: `ValueError: Game {game_id} not found` **Causes**: 1. Game was evicted from memory (idle timeout) 2. Game never created in state manager 3. Server restarted **Solutions**: ```python # Try recovery first state = await state_manager.recover_game(game_id) # If still None, game doesn't exist in database if not state: # Create new game or return error to client ``` ### Issue: outs_before incorrect in database **Symptom**: Play records show wrong outs_before value **Cause**: Play saved AFTER outs were applied to state **Solution**: This was fixed in 2025-10-28 update. Ensure `_save_play_to_db` is called in STEP 2 (before `_apply_play_result`). ```python # CORRECT sequence: result = resolver.resolve_outcome(...) # STEP 1 await self._save_play_to_db(state, result) # STEP 2 - outs not yet applied self._apply_play_result(state, result) # STEP 3 - outs applied here ``` ### Issue: Cannot find batter/pitcher in lineup **Symptom**: `ValueError: Cannot save play: batter_id is None` **Cause**: `_prepare_next_play()` not called before play resolution **Solution**: ```python # Always call _prepare_next_play before resolving play await self._prepare_next_play(state) # This is handled automatically by game_engine orchestration # But check if you're calling resolver directly ``` ### Issue: Runner positions lost after recovery **Symptom**: Recovered game has no runners on base but should have runners **Cause**: Last play was incomplete or not marked complete **Solution**: ```python # Only complete plays are used for recovery # Verify play was marked complete in database play_data['complete'] = True # Check if last play exists plays = await db_ops.get_plays(game_id, limit=1) if not plays: # No plays saved - fresh game pass ``` ### Issue: Lineup cache out of sync **Symptom**: Wrong player batting or incorrect defensive positions **Cause**: Lineup modified in database but cache not updated **Solution**: ```python # After lineup changes, update cache lineup = await db_ops.get_active_lineup(game_id, team_id) lineup_state = TeamLineupState(team_id=team_id, players=[...]) state_manager.set_lineup(game_id, team_id, lineup_state) # Or clear and recover game state_manager.remove_game(game_id) state = await state_manager.recover_game(game_id) ``` ### Issue: Integration tests fail with connection errors **Symptom**: `asyncpg connection closed` or `event loop closed` **Cause**: Database connection pooling conflicts when tests run in parallel **Solution**: ```bash # Run integration tests individually pytest tests/integration/test_game_engine.py::TestGameEngine::test_resolve_play -v # Or use -x flag to stop on first failure pytest tests/integration/test_game_engine.py -x -v ``` ### Issue: Type errors with SQLAlchemy models **Symptom**: `Type "Column[int]" is not assignable to type "int | None"` **Cause**: Known false positive - SQLAlchemy Column is int at runtime **Solution**: Use targeted type: ignore comment ```python state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment] ``` See `backend/CLAUDE.md` section on Type Checking for comprehensive guidance. ## Testing ### Unit Tests ```bash # All core unit tests pytest tests/unit/core/ -v # Specific module pytest tests/unit/core/test_game_engine.py -v pytest tests/unit/core/test_play_resolver.py -v pytest tests/unit/core/test_runner_advancement.py -v # Groundball tests (30 tests) pytest tests/unit/core/test_flyball_advancement.py -v # Flyball tests (21 tests) pytest tests/unit/core/test_state_manager.py -v # With coverage pytest tests/unit/core/ --cov=app.core --cov-report=html ``` ### Integration Tests ```bash # All core integration tests pytest tests/integration/test_state_persistence.py -v # Run individually (recommended due to connection pooling) pytest tests/integration/test_game_engine.py::TestGameEngine::test_complete_game -v ``` ### Terminal Client Best tool for testing game engine in isolation: ```bash # Start REPL python -m terminal_client # Create and play game ⚾ > new_game ⚾ > defensive ⚾ > offensive ⚾ > resolve ⚾ > status ⚾ > quick_play 10 ⚾ > quit ``` See `terminal_client/CLAUDE.md` for full documentation. ## Performance Notes ### Current Performance - State access: O(1) dictionary lookup (~1μs) - Play resolution: 50-100ms (includes DB write) - Lineup cache: Eliminates 2 SELECT queries per play - Conditional updates: ~40-60% fewer UPDATE queries ### Optimization History **2025-10-28: 60% Query Reduction** - Before: 5 queries per play - After: 2 queries per play (INSERT + UPDATE) - Changes: - Added lineup caching - Removed unnecessary refresh after save - Direct UPDATE statements - Conditional game state updates ### Memory Usage - GameState: ~1-2KB per game - TeamLineupState: ~500 bytes per team - Roll history: ~200 bytes per roll - Total per active game: ~3-5KB ### Scaling Targets - Support: 10+ simultaneous games - Memory: <1GB with 10 active games - Response: <500ms action to state update - Recovery: <2 seconds from database ## Examples ### Complete Game Flow ```python from uuid import uuid4 from app.core.game_engine import game_engine from app.core.state_manager import state_manager from app.models.game_models import DefensiveDecision, OffensiveDecision from app.config import PlayOutcome # 1. Create game game_id = uuid4() await db_ops.create_game(game_id, 'sba', home_id=1, away_id=2) state = await state_manager.create_game(game_id, 'sba', 1, 2) # 2. Set up lineups # ... create 9+ players per team in database ... # 3. Start game state = await game_engine.start_game(game_id) # 4. Submit decisions def_dec = DefensiveDecision(alignment='normal', infield_depth='normal', outfield_depth='normal') off_dec = OffensiveDecision(approach='normal') await game_engine.submit_defensive_decision(game_id, def_dec) await game_engine.submit_offensive_decision(game_id, off_dec) # 5. Resolve play (manual mode) ab_roll = dice_system.roll_ab('sba', game_id) result = await game_engine.resolve_manual_play( game_id=game_id, ab_roll=ab_roll, outcome=PlayOutcome.GROUNDBALL_A, hit_location='SS' ) # 6. Check result print(f"{result.description}") print(f"Outs: {result.outs_recorded}, Runs: {result.runs_scored}") print(f"Batter result: {result.batter_result}") # 7. Continue game loop state = state_manager.get_state(game_id) print(f"Score: {state.away_score} - {state.home_score}") print(f"Inning {state.inning} {state.half}, {state.outs} outs") ``` ### Groundball Resolution ```python from app.core.runner_advancement import RunnerAdvancement from app.models.game_models import GameState, DefensiveDecision from app.config import PlayOutcome # Set up situation state = GameState(game_id=game_id, league_id='sba', ...) state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position='RF') state.on_third = LineupPlayerState(lineup_id=2, card_id=102, position='CF') state.outs = 1 state.current_on_base_code = 5 # 1st and 3rd # Defensive positioning def_dec = DefensiveDecision(infield_depth='infield_in') # Resolve groundball runner_adv = RunnerAdvancement() result = runner_adv.advance_runners( outcome=PlayOutcome.GROUNDBALL_A, hit_location='SS', state=state, defensive_decision=def_dec ) # Check result print(f"Result type: {result.result_type}") # GroundballResultType.BATTER_OUT_FORCED_ONLY print(f"Description: {result.description}") print(f"Outs recorded: {result.outs_recorded}") print(f"Runs scored: {result.runs_scored}") # Check movements for movement in result.movements: print(f" {movement}") ``` ### AI Decision Making ```python from app.core.ai_opponent import ai_opponent # Check if team is AI-controlled if state.is_fielding_team_ai(): # Generate defensive decision def_decision = await ai_opponent.generate_defensive_decision(state) await game_engine.submit_defensive_decision(game_id, def_decision) if state.is_batting_team_ai(): # Generate offensive decision off_decision = await ai_opponent.generate_offensive_decision(state) await game_engine.submit_offensive_decision(game_id, off_decision) ``` --- ## Summary The `core` directory is the beating heart of the baseball simulation engine. It manages in-memory game state for fast performance, orchestrates complete gameplay flow, resolves play outcomes using card-based mechanics, and enforces all baseball rules. **Key files**: - `state_manager.py` - O(1) state lookups, lineup caching, recovery - `game_engine.py` - Orchestration, workflow, persistence - `play_resolver.py` - Outcome-first resolution (manual + auto) - `runner_advancement.py` - Groundball (13 result types) & flyball (4 types) advancement - `dice.py` - Cryptographic dice rolling system - `validators.py` - Rule enforcement - `ai_opponent.py` - AI decision-making (stub) - `roll_types.py` - Type-safe roll data structures **Architecture principles**: - Async-first: All DB operations are non-blocking - Outcome-first: Manual submissions are primary workflow - Type-safe: Pydantic models throughout - Single source of truth: StateManager for active games - Raise or Return: No Optional unless semantically valid **Performance**: 50-100ms play resolution, 60% query reduction, <500ms target achieved **Next phase**: WebSocket integration (Week 7-8) for real-time multiplayer gameplay