strat-gameplay-webapp/backend/app/core/CLAUDE.md
2025-11-03 12:43:54 -06:00

1372 lines
38 KiB
Markdown

# 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
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