# 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()
```

**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
uv run pytest tests/integration/test_game_engine.py::TestGameEngine::test_resolve_play -v

# Or use -x flag to stop on first failure
uv run 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
uv run pytest tests/unit/core/ -v

# Specific module
uv run pytest tests/unit/core/test_game_engine.py -v
uv run pytest tests/unit/core/test_play_resolver.py -v
uv run pytest tests/unit/core/test_runner_advancement.py -v  # Groundball tests (30 tests)
uv run pytest tests/unit/core/test_flyball_advancement.py -v  # Flyball tests (21 tests)
uv run pytest tests/unit/core/test_state_manager.py -v

# With coverage
uv run pytest tests/unit/core/ --cov=app.core --cov-report=html
```

### Integration Tests

```bash
# All core integration tests
uv run pytest tests/integration/test_state_persistence.py -v

# Run individually (recommended due to connection pooling)
uv run 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
uv run 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
