strat-gameplay-webapp/backend/app/core/CLAUDE.md
Cal Corum 440adf2c26 CLAUDE: Update REPL for new GameState and standardize UV commands
Updated terminal client REPL to work with refactored GameState structure
where current_batter/pitcher/catcher are now LineupPlayerState objects
instead of integer IDs. Also standardized all documentation to properly
show 'uv run' prefixes for Python commands.

REPL Updates:
- terminal_client/display.py: Access lineup_id from LineupPlayerState objects
- terminal_client/repl.py: Fix typos (self.current_game → self.current_game_id)
- tests/unit/terminal_client/test_commands.py: Create proper LineupPlayerState
  objects in test fixtures (2 tests fixed, all 105 terminal client tests passing)

Documentation Updates (100+ command examples):
- CLAUDE.md: Updated pytest examples to use 'uv run' prefix
- terminal_client/CLAUDE.md: Updated ~40 command examples
- tests/CLAUDE.md: Updated all test commands (unit, integration, debugging)
- app/*/CLAUDE.md: Updated test and server startup commands (5 files)

All Python commands now consistently use 'uv run' prefix to align with
project's UV migration, improving developer experience and preventing
confusion about virtual environment activation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 09:59:13 -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
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